From 7a2aabcd6ef62ad8ae26e9b0023f2964e21e2c7f Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Thu, 22 Jan 2026 08:57:43 -0500 Subject: [PATCH] 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 --- make_post_sell/templates/product.j2 | 183 ++++++++++++---------------- 1 file changed, 80 insertions(+), 103 deletions(-) diff --git a/make_post_sell/templates/product.j2 b/make_post_sell/templates/product.j2 index b885a83..ec67c0e 100644 --- a/make_post_sell/templates/product.j2 +++ b/make_post_sell/templates/product.j2 @@ -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);