Switch to native PiP with Media Session API for prev/next controls
Use native browser PiP instead of Document PiP: - Native PiP has right-click "always on top" option that actually works - Media Session API adds prev/next track buttons to PiP controls - Video stays in PiP when navigating between tracks - Auto-advances to next video when current one ends Key features: - Creates hidden video element for PiP playback - requestPictureInPicture() for native browser PiP - navigator.mediaSession for prev/next track handlers - Updates MediaMetadata with current title - Stays in PiP mode during navigation User can right-click PiP window and select "Always on top" for true OS-level always-on-top behavior. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
3153a98dff
commit
7a2aabcd6e
1 changed files with 80 additions and 103 deletions
|
|
@ -183,20 +183,16 @@ function openMediaPlayer(productId) {
|
|||
window.open(`/player/${productId}`, 'mediaPlayer', features);
|
||||
}
|
||||
|
||||
// Open directly in Document Picture-in-Picture mode
|
||||
async function openPipPlayer(productId) {
|
||||
console.log('=== Opening PiP for product:', productId, '===');
|
||||
// PiP state
|
||||
let pipVideo = null;
|
||||
let pipData = null;
|
||||
|
||||
// Check for Document PiP support
|
||||
if (!('documentPictureInPicture' in window)) {
|
||||
alert('Document Picture-in-Picture requires Chrome 111+ or Edge 111+.\n\nFalling back to pop-up window.');
|
||||
openMediaPlayer(productId);
|
||||
return;
|
||||
}
|
||||
// Open native PiP with Media Session controls for prev/next
|
||||
async function openPipPlayer(productId) {
|
||||
console.log('=== Opening Native PiP for product:', productId, '===');
|
||||
|
||||
try {
|
||||
// Fetch video data from JSON endpoint
|
||||
console.log('Fetching video data from /player/' + productId + '/json');
|
||||
const response = await fetch(`/player/${productId}/json`);
|
||||
const data = await response.json();
|
||||
|
||||
|
|
@ -206,92 +202,78 @@ async function openPipPlayer(productId) {
|
|||
throw new Error(data.error || 'Failed to load video data');
|
||||
}
|
||||
|
||||
// Open Document PiP window
|
||||
console.log('Requesting Document PiP window...');
|
||||
const pipWindow = await window.documentPictureInPicture.requestWindow({
|
||||
width: 640,
|
||||
height: 420
|
||||
});
|
||||
// Create or reuse hidden video element
|
||||
if (!pipVideo) {
|
||||
pipVideo = document.createElement('video');
|
||||
pipVideo.id = 'pipVideo';
|
||||
pipVideo.style.cssText = 'position:fixed;bottom:10px;right:10px;width:320px;height:180px;z-index:9999;display:none;';
|
||||
pipVideo.controls = true;
|
||||
pipVideo.autoplay = true;
|
||||
document.body.appendChild(pipVideo);
|
||||
|
||||
console.log('Document PiP window opened successfully');
|
||||
// Handle video end - auto advance
|
||||
pipVideo.addEventListener('ended', async () => {
|
||||
console.log('PiP video ended');
|
||||
if (pipData && pipData.nextProductId) {
|
||||
console.log('Auto-advancing to:', pipData.nextProductId);
|
||||
await loadPipVideo(pipData.nextProductId);
|
||||
}
|
||||
});
|
||||
|
||||
// Add styles
|
||||
const pipStyle = pipWindow.document.createElement('style');
|
||||
pipStyle.textContent = `
|
||||
body { margin: 0; padding: 0; background: #000; overflow: hidden; font-family: sans-serif; }
|
||||
.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 content
|
||||
const container = pipWindow.document.createElement('div');
|
||||
container.className = 'pip-container';
|
||||
|
||||
const video = pipWindow.document.createElement('video');
|
||||
video.className = 'pip-video';
|
||||
video.src = data.videoUrl;
|
||||
video.controls = true;
|
||||
video.autoplay = true;
|
||||
|
||||
const controls = pipWindow.document.createElement('div');
|
||||
controls.className = 'pip-controls';
|
||||
|
||||
const prevBtn = pipWindow.document.createElement('button');
|
||||
prevBtn.textContent = '◀ Prev';
|
||||
prevBtn.disabled = !data.prevProductId;
|
||||
if (data.prevProductId) {
|
||||
prevBtn.onclick = () => loadInPip(data.prevProductId, video, pipWindow, prevBtn, nextBtn, title);
|
||||
// Handle leaving PiP
|
||||
pipVideo.addEventListener('leavepictureinpicture', () => {
|
||||
console.log('Left PiP mode');
|
||||
pipVideo.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
const title = pipWindow.document.createElement('div');
|
||||
title.className = 'pip-title';
|
||||
title.textContent = data.title;
|
||||
// Store current data
|
||||
pipData = data;
|
||||
|
||||
const nextBtn = pipWindow.document.createElement('button');
|
||||
nextBtn.textContent = 'Next ▶';
|
||||
nextBtn.disabled = !data.nextProductId;
|
||||
if (data.nextProductId) {
|
||||
nextBtn.onclick = () => loadInPip(data.nextProductId, video, pipWindow, prevBtn, nextBtn, title);
|
||||
// Load video
|
||||
pipVideo.src = data.videoUrl;
|
||||
pipVideo.style.display = 'block';
|
||||
|
||||
// Set up Media Session for prev/next controls in PiP
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: data.title,
|
||||
artist: 'Media Player',
|
||||
album: ''
|
||||
});
|
||||
|
||||
// Previous track handler
|
||||
navigator.mediaSession.setActionHandler('previoustrack', async () => {
|
||||
console.log('Media Session: previous track');
|
||||
if (pipData && pipData.prevProductId) {
|
||||
await loadPipVideo(pipData.prevProductId);
|
||||
}
|
||||
});
|
||||
|
||||
// Next track handler
|
||||
navigator.mediaSession.setActionHandler('nexttrack', async () => {
|
||||
console.log('Media Session: next track');
|
||||
if (pipData && pipData.nextProductId) {
|
||||
await loadPipVideo(pipData.nextProductId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
controls.appendChild(prevBtn);
|
||||
controls.appendChild(title);
|
||||
controls.appendChild(nextBtn);
|
||||
|
||||
container.appendChild(video);
|
||||
container.appendChild(controls);
|
||||
pipWindow.document.body.appendChild(container);
|
||||
|
||||
// Store current data for navigation
|
||||
pipWindow.currentData = data;
|
||||
|
||||
// Auto-advance when video ends
|
||||
video.addEventListener('ended', () => {
|
||||
console.log('Video ended, checking for next...');
|
||||
if (pipWindow.currentData && pipWindow.currentData.nextProductId) {
|
||||
console.log('Auto-advancing to:', pipWindow.currentData.nextProductId);
|
||||
loadInPip(pipWindow.currentData.nextProductId, video, pipWindow, prevBtn, nextBtn, title);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('PiP setup complete');
|
||||
// Enter native PiP mode
|
||||
console.log('Requesting native PiP...');
|
||||
await pipVideo.play();
|
||||
await pipVideo.requestPictureInPicture();
|
||||
console.log('Native PiP active - right-click for always-on-top option');
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to open Document PiP:', err);
|
||||
alert('PiP failed: ' + err.message + '\n\nOpening in pop-up window.');
|
||||
openMediaPlayer(productId);
|
||||
console.error('Failed to open PiP:', err);
|
||||
alert('PiP failed: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Load new video in existing PiP window (no page navigation needed)
|
||||
async function loadInPip(productId, videoEl, pipWindow, prevBtn, nextBtn, titleEl) {
|
||||
console.log('Loading in PiP:', productId);
|
||||
// Load new video while staying in PiP
|
||||
async function loadPipVideo(productId) {
|
||||
console.log('Loading video in PiP:', productId);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/player/${productId}/json`);
|
||||
|
|
@ -302,28 +284,23 @@ async function loadInPip(productId, videoEl, pipWindow, prevBtn, nextBtn, titleE
|
|||
return;
|
||||
}
|
||||
|
||||
// Update video source
|
||||
videoEl.src = data.videoUrl;
|
||||
videoEl.play();
|
||||
// Update stored data
|
||||
pipData = data;
|
||||
|
||||
// Update title
|
||||
titleEl.textContent = data.title;
|
||||
// Update video source (stays in PiP)
|
||||
pipVideo.src = data.videoUrl;
|
||||
await pipVideo.play();
|
||||
|
||||
// Update navigation buttons
|
||||
prevBtn.disabled = !data.prevProductId;
|
||||
prevBtn.onclick = data.prevProductId
|
||||
? () => loadInPip(data.prevProductId, videoEl, pipWindow, prevBtn, nextBtn, titleEl)
|
||||
: null;
|
||||
// Update Media Session metadata
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: data.title,
|
||||
artist: 'Media Player',
|
||||
album: ''
|
||||
});
|
||||
}
|
||||
|
||||
nextBtn.disabled = !data.nextProductId;
|
||||
nextBtn.onclick = data.nextProductId
|
||||
? () => loadInPip(data.nextProductId, videoEl, pipWindow, prevBtn, nextBtn, titleEl)
|
||||
: null;
|
||||
|
||||
// Update stored data for auto-advance
|
||||
pipWindow.currentData = data;
|
||||
|
||||
console.log('Loaded:', data.title);
|
||||
console.log('Now playing:', data.title);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to load video:', err);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue