Replace PiP mode with draggable pop-out window

The native browser Picture-in-Picture mode had limitations:
- Couldn't stay on top of all windows (terminals went over it)
- No user control over drag/resize
- Browser-controlled positioning

Changes:
- Simplified openMediaPlayer() to always use pop-out window
- Pop-out window is draggable and resizable by user
- Always-on-top toggle uses window.focus() for better control
- Removed PiP button and togglePip() function
- Removed keyboard shortcut for PiP (P key)
- Cleaned up unused helper functions (preloadNextMedia, loadMedia)
- Reorganized player controls (3x3 grid)

The pop-out window gives users full control over positioning and sizing.
This commit is contained in:
russell@unturf.com 2026-01-21 11:24:56 -05:00
parent 9f895f17e0
commit af8da71c9a
4 changed files with 10 additions and 385 deletions

View file

@ -45,26 +45,6 @@
}
};
/**
* Toggle Picture-in-Picture mode
*/
window.togglePip = async function() {
if (!document.pictureInPictureEnabled || !media || media.tagName !== 'VIDEO') {
alert('Picture-in-Picture is not supported for this media');
return;
}
try {
if (document.pictureInPictureElement) {
await document.exitPictureInPicture();
} else {
await media.requestPictureInPicture();
}
} catch (err) {
console.error('Error toggling PiP:', err);
}
};
/**
* Toggle fullscreen mode
*/
@ -407,13 +387,6 @@
goToProductPage();
}
break;
case 'p':
case 'P':
e.preventDefault();
if (typeof togglePip === 'function') {
togglePip();
}
break;
}
}

View file

@ -96,184 +96,10 @@
</section>
<script>
// Global player data for navigation
let currentPlayerData = null;
let preloadedNextMedia = null;
// Preload next media for instant playback
async function preloadNextMedia() {
if (!currentPlayerData || !currentPlayerData.nextProductId) {
return;
}
try {
const response = await fetch(`/player/${currentPlayerData.nextProductId}`);
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const nextMediaEl = doc.querySelector('video, audio');
if (nextMediaEl && nextMediaEl.src) {
// Create hidden preload element
if (preloadedNextMedia) {
preloadedNextMedia.remove();
}
preloadedNextMedia = document.createElement(nextMediaEl.tagName.toLowerCase());
preloadedNextMedia.src = nextMediaEl.src;
preloadedNextMedia.preload = 'auto';
preloadedNextMedia.style.display = 'none';
document.body.appendChild(preloadedNextMedia);
}
} catch (err) {
console.log('Could not preload next media:', err);
}
}
// Load new media into existing element
async function loadMedia(mediaElement, productId) {
try {
const response = await fetch(`/player/${productId}`);
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const newVideoEl = doc.querySelector('video, audio');
const scriptEl = doc.querySelector('script:last-of-type');
if (newVideoEl && newVideoEl.src) {
mediaElement.src = newVideoEl.src;
await mediaElement.play();
// Update playerData for continued navigation
if (scriptEl) {
const scriptContent = scriptEl.textContent;
const match = scriptContent.match(/window\.playerData\s*=\s*(\{[^}]+\})/);
if (match) {
currentPlayerData = eval('(' + match[1] + ')');
// Navigate parent page to show the new product
if (currentPlayerData.productUrl) {
window.history.pushState({}, '', currentPlayerData.productUrl);
}
// Preload next video after switching
preloadNextMedia();
}
}
}
} catch (err) {
console.error('Error loading media:', err);
}
}
async function openMediaPlayer(productId) {
// Mobile detection
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
// On mobile, open in new tab (works better than PiP)
if (isMobile) {
window.open(`/player/${productId}`, '_blank');
return;
}
// Desktop: Quick check if this is video/audio or other media
// For images/PDFs, just open window directly to avoid Firefox blocking
try {
const response = await fetch(`/player/${productId}`);
const html = await response.text();
// Parse the response to extract media URL and nav data
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const videoEl = doc.querySelector('video, audio');
const imageEl = doc.querySelector('img#media');
const iframeEl = doc.querySelector('iframe#media');
// For images and PDFs, just open pop-out window immediately
if (imageEl || iframeEl) {
window.open(`/player/${productId}`, 'mediaPlayer', 'width=800,height=600,menubar=no,toolbar=no,location=no,status=no,scrollbars=no');
return;
}
if (!videoEl) {
console.error('No media element found');
return;
}
// Create hidden video/audio element on this page
const media = document.createElement(videoEl.tagName.toLowerCase());
media.src = videoEl.src;
media.style.position = 'fixed';
media.style.bottom = '20px';
media.style.right = '20px';
media.style.width = '320px';
media.style.zIndex = '9999';
media.controls = true;
media.autoplay = true;
media.playsInline = true; // Important for iOS
document.body.appendChild(media);
// Extract playerData from script
const scriptEl = doc.querySelector('script:last-of-type');
if (scriptEl) {
const scriptContent = scriptEl.textContent;
const match = scriptContent.match(/window\.playerData\s*=\s*(\{[^}]+\})/);
if (match) {
currentPlayerData = eval('(' + match[1] + ')');
}
}
// Enter Picture-in-Picture mode (only for video)
if (document.pictureInPictureEnabled && media.tagName === 'VIDEO') {
await media.play();
await media.requestPictureInPicture();
// Preload next video immediately for instant playback
preloadNextMedia();
// Auto-advance when video ends
media.addEventListener('ended', async () => {
if (currentPlayerData && currentPlayerData.nextProductId) {
await loadMedia(media, currentPlayerData.nextProductId);
}
});
// Set up Media Session API for prev/next controls
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('previoustrack', async () => {
if (currentPlayerData && currentPlayerData.prevProductId) {
await loadMedia(media, currentPlayerData.prevProductId);
}
});
navigator.mediaSession.setActionHandler('nexttrack', async () => {
if (currentPlayerData && currentPlayerData.nextProductId) {
await loadMedia(media, currentPlayerData.nextProductId);
}
});
}
// Clean up when PiP closes
media.addEventListener('leavepictureinpicture', () => {
document.body.removeChild(media);
});
} else if (media.tagName === 'AUDIO') {
// Audio: keep small floating player on page
media.play();
// Preload next audio
preloadNextMedia();
// Auto-advance when audio ends
media.addEventListener('ended', async () => {
if (currentPlayerData && currentPlayerData.nextProductId) {
await loadMedia(media, currentPlayerData.nextProductId);
}
});
}
} catch (err) {
console.error('Error opening media player:', err);
}
function openMediaPlayer(productId) {
// Open pop-out player window (draggable and resizable)
const features = 'width=800,height=600,menubar=no,toolbar=no,location=no,status=no';
window.open(`/player/${productId}`, 'mediaPlayer', features);
}
</script>

View file

@ -219,12 +219,12 @@
<!-- Row 2: Features -->
<button id="alwaysOnTopBtn" onclick="toggleAlwaysOnTop()" title="Always on Top (T)">📌 On Top</button>
<button id="fullscreenBtn" onclick="toggleFullscreen()" title="Fullscreen (F)">⛶ Full</button>
<button id="pipBtn" onclick="togglePip()" title="Picture-in-Picture (P)">📺 PiP</button>
<button onclick="goToProductPage()" title="Go to Product Page (G)">🔗 Page</button>
<!-- Row 3: More options -->
<button id="autoResizeBtn" onclick="toggleAutoResize()" title="Auto-Resize Window (R)">↔ Resize</button>
<button onclick="goToProductPage()" title="Go to Product Page (G)">🔗 Page</button>
<button onclick="window.close()" title="Close Window (ESC)">✕ Close</button>
<span></span><!-- Empty cell for grid alignment -->
</div>
</div>
</div>

View file

@ -174,184 +174,10 @@
</section>
<script>
// Global player data for navigation
let currentPlayerData = null;
let preloadedNextMedia = null;
// Preload next media for instant playback
async function preloadNextMedia() {
if (!currentPlayerData || !currentPlayerData.nextProductId) {
return;
}
try {
const response = await fetch(`/player/${currentPlayerData.nextProductId}`);
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const nextMediaEl = doc.querySelector('video, audio');
if (nextMediaEl && nextMediaEl.src) {
// Create hidden preload element
if (preloadedNextMedia) {
preloadedNextMedia.remove();
}
preloadedNextMedia = document.createElement(nextMediaEl.tagName.toLowerCase());
preloadedNextMedia.src = nextMediaEl.src;
preloadedNextMedia.preload = 'auto';
preloadedNextMedia.style.display = 'none';
document.body.appendChild(preloadedNextMedia);
}
} catch (err) {
console.log('Could not preload next media:', err);
}
}
// Load new media into existing element
async function loadMedia(mediaElement, productId) {
try {
const response = await fetch(`/player/${productId}`);
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const newVideoEl = doc.querySelector('video, audio');
const scriptEl = doc.querySelector('script:last-of-type');
if (newVideoEl && newVideoEl.src) {
mediaElement.src = newVideoEl.src;
await mediaElement.play();
// Update playerData for continued navigation
if (scriptEl) {
const scriptContent = scriptEl.textContent;
const match = scriptContent.match(/window\.playerData\s*=\s*(\{[^}]+\})/);
if (match) {
currentPlayerData = eval('(' + match[1] + ')');
// Navigate parent page to show the new product
if (currentPlayerData.productUrl) {
window.history.pushState({}, '', currentPlayerData.productUrl);
}
// Preload next video after switching
preloadNextMedia();
}
}
}
} catch (err) {
console.error('Error loading media:', err);
}
}
async function openMediaPlayer(productId) {
// Mobile detection
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
// On mobile, open in new tab (works better than PiP)
if (isMobile) {
window.open(`/player/${productId}`, '_blank');
return;
}
// Desktop: Quick check if this is video/audio or other media
// For images/PDFs, just open window directly to avoid Firefox blocking
try {
const response = await fetch(`/player/${productId}`);
const html = await response.text();
// Parse the response to extract media URL and nav data
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const videoEl = doc.querySelector('video, audio');
const imageEl = doc.querySelector('img#media');
const iframeEl = doc.querySelector('iframe#media');
// For images and PDFs, just open pop-out window immediately
if (imageEl || iframeEl) {
window.open(`/player/${productId}`, 'mediaPlayer', 'width=800,height=600,menubar=no,toolbar=no,location=no,status=no,scrollbars=no');
return;
}
if (!videoEl) {
console.error('No media element found');
return;
}
// Create hidden video/audio element on this page
const media = document.createElement(videoEl.tagName.toLowerCase());
media.src = videoEl.src;
media.style.position = 'fixed';
media.style.bottom = '20px';
media.style.right = '20px';
media.style.width = '320px';
media.style.zIndex = '9999';
media.controls = true;
media.autoplay = true;
media.playsInline = true; // Important for iOS
document.body.appendChild(media);
// Extract playerData from script
const scriptEl = doc.querySelector('script:last-of-type');
if (scriptEl) {
const scriptContent = scriptEl.textContent;
const match = scriptContent.match(/window\.playerData\s*=\s*(\{[^}]+\})/);
if (match) {
currentPlayerData = eval('(' + match[1] + ')');
}
}
// Enter Picture-in-Picture mode (only for video)
if (document.pictureInPictureEnabled && media.tagName === 'VIDEO') {
await media.play();
await media.requestPictureInPicture();
// Preload next video immediately for instant playback
preloadNextMedia();
// Auto-advance when video ends
media.addEventListener('ended', async () => {
if (currentPlayerData && currentPlayerData.nextProductId) {
await loadMedia(media, currentPlayerData.nextProductId);
}
});
// Set up Media Session API for prev/next controls
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('previoustrack', async () => {
if (currentPlayerData && currentPlayerData.prevProductId) {
await loadMedia(media, currentPlayerData.prevProductId);
}
});
navigator.mediaSession.setActionHandler('nexttrack', async () => {
if (currentPlayerData && currentPlayerData.nextProductId) {
await loadMedia(media, currentPlayerData.nextProductId);
}
});
}
// Clean up when PiP closes
media.addEventListener('leavepictureinpicture', () => {
document.body.removeChild(media);
});
} else if (media.tagName === 'AUDIO') {
// Audio: keep small floating player on page
media.play();
// Preload next audio
preloadNextMedia();
// Auto-advance when audio ends
media.addEventListener('ended', async () => {
if (currentPlayerData && currentPlayerData.nextProductId) {
await loadMedia(media, currentPlayerData.nextProductId);
}
});
}
} catch (err) {
console.error('Error opening media player:', err);
}
function openMediaPlayer(productId) {
// Open pop-out player window (draggable and resizable)
const features = 'width=800,height=600,menubar=no,toolbar=no,location=no,status=no';
window.open(`/player/${productId}`, 'mediaPlayer', features);
}
</script>