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:
russell@unturf.com 2026-01-22 08:57:43 -05:00
parent 3153a98dff
commit 7a2aabcd6e

View file

@ -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);