Implement Document Picture-in-Picture with prev/next navigation

Replace basic PiP with Document Picture-in-Picture API that provides:
- Custom navigation controls (Prev/Next buttons) in PiP window
- Automatic aspect ratio calculation from video dimensions
- True OS-level always-on-top (no window.focus() polling needed)
- Window is movable by default (browser feature)
- Syncs playback between main window and PiP
- Auto-navigates to next video when current ends
- Product title displayed in PiP controls
- Fallback to regular PiP for unsupported browsers

Technical details:
- Window sized to video aspect ratio (640px wide, height calculated)
- Controls bar at bottom with prev/next navigation
- Pauses main window video when PiP is active
- Syncs seek position and play/pause state bidirectionally
- Closes PiP window on toggle or window close

Document PiP is supported in Chrome/Edge 111+ and provides the best
experience with custom UI controls AND true always-on-top behavior.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
russell@unturf.com 2026-01-21 13:05:37 -05:00
parent d244617554
commit 2258385cf4

View file

@ -230,7 +230,7 @@
<!-- Row 2: Features -->
<button id="alwaysOnTopBtn" onclick="toggleAlwaysOnTop()" title="Always on Top (T)">📌 On Top</button>
{% if media_type == 'video' %}
<button id="pipBtn" onclick="togglePip()" title="Picture-in-Picture (P) - Right-click PiP for true always-on-top">📺 PiP</button>
<button id="pipBtn" onclick="togglePip()" title="Picture-in-Picture with Prev/Next (P) - True always-on-top">📺 PiP</button>
{% endif %}
<button onclick="goToProductPage()" title="Go to Product Page (G)">🔗 Page</button>
@ -260,13 +260,209 @@
window.open(window.playerData.productUrl, '_blank');
}
// Toggle native Picture-in-Picture (true always-on-top via browser)
function togglePip() {
// Document Picture-in-Picture with custom controls
let pipWindow = null;
async function togglePip() {
const media = document.getElementById('media');
if (!media || media.tagName !== 'VIDEO') {
return;
}
// Check for Document Picture-in-Picture API support
if ('documentPictureInPicture' in window) {
if (pipWindow) {
// Close existing PiP window
pipWindow.close();
pipWindow = null;
updatePipButtonState(false);
return;
}
try {
// Calculate aspect ratio from video
const aspectRatio = media.videoWidth / media.videoHeight;
let width = 640;
let height = Math.round(width / aspectRatio);
// Constrain to reasonable sizes
if (height > 480) {
height = 480;
width = Math.round(height * aspectRatio);
}
// Open Document PiP window with custom content
pipWindow = await window.documentPictureInPicture.requestWindow({
width: width,
height: height + 60 // Extra space for controls
});
updatePipButtonState(true);
// Copy stylesheets to PiP window
const stylesheets = Array.from(document.styleSheets);
stylesheets.forEach(stylesheet => {
try {
const cssRules = Array.from(stylesheet.cssRules);
const style = pipWindow.document.createElement('style');
cssRules.forEach(rule => {
style.textContent += rule.cssText;
});
pipWindow.document.head.appendChild(style);
} catch (e) {
// External stylesheet, link it
const link = pipWindow.document.createElement('link');
link.rel = 'stylesheet';
link.href = stylesheet.href;
pipWindow.document.head.appendChild(link);
}
});
// Add PiP-specific styles
const pipStyle = pipWindow.document.createElement('style');
pipStyle.textContent = `
body {
margin: 0;
padding: 0;
background: #000;
overflow: hidden;
}
.pip-container {
display: grid;
grid-template-rows: 1fr auto;
width: 100vw;
height: 100vh;
}
.pip-video {
width: 100%;
height: 100%;
object-fit: contain;
}
.pip-controls {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.5rem;
padding: 0.5rem;
background: rgba(0,0,0,0.9);
align-items: center;
}
.pip-controls button {
background: rgba(255,255,255,0.2);
border: none;
color: white;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
}
.pip-controls button:hover:not(:disabled) {
background: rgba(255,255,255,0.4);
}
.pip-controls button:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.pip-title {
color: white;
font-size: 0.9rem;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`;
pipWindow.document.head.appendChild(pipStyle);
// Build PiP content with navigation controls
const container = pipWindow.document.createElement('div');
container.className = 'pip-container';
// Clone video element
const pipVideo = pipWindow.document.createElement('video');
pipVideo.className = 'pip-video';
pipVideo.src = media.src;
pipVideo.currentTime = media.currentTime;
pipVideo.controls = true;
pipVideo.autoplay = true;
// Create controls
const controls = pipWindow.document.createElement('div');
controls.className = 'pip-controls';
// Previous button
const prevBtn = pipWindow.document.createElement('button');
prevBtn.textContent = '◀ Prev';
prevBtn.disabled = !window.playerData.prevProductId;
prevBtn.onclick = () => {
if (window.playerData.prevProductId) {
window.location.href = '/player/' + window.playerData.prevProductId;
}
};
// Title
const title = pipWindow.document.createElement('div');
title.className = 'pip-title';
title.textContent = '{{ product.title }}';
// Next button
const nextBtn = pipWindow.document.createElement('button');
nextBtn.textContent = 'Next ▶';
nextBtn.disabled = !window.playerData.nextProductId;
nextBtn.onclick = () => {
if (window.playerData.nextProductId) {
window.location.href = '/player/' + window.playerData.nextProductId;
}
};
controls.appendChild(prevBtn);
controls.appendChild(title);
controls.appendChild(nextBtn);
container.appendChild(pipVideo);
container.appendChild(controls);
pipWindow.document.body.appendChild(container);
// Sync playback between main window and PiP
pipVideo.addEventListener('play', () => media.play());
pipVideo.addEventListener('pause', () => media.pause());
pipVideo.addEventListener('seeked', () => {
media.currentTime = pipVideo.currentTime;
});
// When PiP video ends, navigate to next
pipVideo.addEventListener('ended', () => {
if (window.playerData.nextProductId) {
window.location.href = '/player/' + window.playerData.nextProductId;
}
});
// Handle PiP window close
pipWindow.addEventListener('pagehide', () => {
pipWindow = null;
updatePipButtonState(false);
// Sync playback state back to main window
if (!pipVideo.paused) {
media.currentTime = pipVideo.currentTime;
media.play();
}
});
// Pause main video (playback continues in PiP)
media.pause();
} catch (err) {
console.error('Failed to open Document PiP:', err);
// Fallback to regular PiP
fallbackToPip(media);
}
} else {
// Fallback to regular PiP for unsupported browsers
fallbackToPip(media);
}
}
// Fallback to regular Picture-in-Picture API
function fallbackToPip(media) {
if (document.pictureInPictureElement) {
document.exitPictureInPicture().catch(err => {
console.error('Failed to exit PiP:', err);
@ -279,19 +475,29 @@
}
// Update PiP button state
document.addEventListener('enterpictureinpicture', () => {
function updatePipButtonState(isActive) {
const pipBtn = document.getElementById('pipBtn');
if (pipBtn) {
pipBtn.classList.add('active');
pipBtn.textContent = '📺 Exit PiP';
if (isActive) {
pipBtn.classList.add('active');
pipBtn.textContent = '📺 Exit PiP';
} else {
pipBtn.classList.remove('active');
pipBtn.textContent = '📺 PiP';
}
}
}
// Listen for regular PiP events (fallback mode)
document.addEventListener('enterpictureinpicture', () => {
if (!pipWindow) {
updatePipButtonState(true);
}
});
document.addEventListener('leavepictureinpicture', () => {
const pipBtn = document.getElementById('pipBtn');
if (pipBtn) {
pipBtn.classList.remove('active');
pipBtn.textContent = '📺 PiP';
if (!pipWindow) {
updatePipButtonState(false);
}
});