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:
parent
d244617554
commit
2258385cf4
1 changed files with 216 additions and 10 deletions
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue