Add native Picture-in-Picture mode with Media Session API

- Click video enters native browser PiP mode directly
- Uses Media Session API to add prev/next track buttons to PiP
- Falls back to small floating player if PiP not supported
- Cleaner than pop-out window, truly floats over all apps
This commit is contained in:
russell@unturf.com 2026-01-21 10:44:01 -05:00
parent a93d5e457f
commit dcbfd10101
2 changed files with 134 additions and 22 deletions

View file

@ -96,19 +96,75 @@
</section>
<script>
function openMediaPlayer(productId) {
const features = 'width=800,height=600,menubar=no,toolbar=no,location=no,status=no,scrollbars=no';
const playerWindow = window.open('/player/' + productId, 'mediaPlayer', features);
async function openMediaPlayer(productId) {
// Fetch player data to get media URL and navigation info
try {
const response = await fetch(`/player/${productId}`);
const html = await response.text();
// Apply always-on-top if enabled in localStorage
if (localStorage.getItem('player_always_on_top') === 'true' && playerWindow) {
const focusInterval = setInterval(() => {
if (playerWindow.closed) {
clearInterval(focusInterval);
} else {
playerWindow.focus();
// 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 scriptEl = doc.querySelector('script:last-of-type');
if (!videoEl) {
console.error('No media element found');
return;
}
// Create hidden video 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;
document.body.appendChild(media);
// Extract playerData from script
let playerData = null;
if (scriptEl) {
const scriptContent = scriptEl.textContent;
const match = scriptContent.match(/window\.playerData\s*=\s*(\{[^}]+\})/);
if (match) {
playerData = eval('(' + match[1] + ')');
}
}, 500);
}
// Enter Picture-in-Picture mode
if (document.pictureInPictureEnabled && media.tagName === 'VIDEO') {
await media.play();
await media.requestPictureInPicture();
// Set up Media Session API for prev/next controls
if ('mediaSession' in navigator && playerData) {
navigator.mediaSession.setActionHandler('previoustrack', () => {
if (playerData.prevProductId) {
window.location.href = `/player/${playerData.prevProductId}`;
}
});
navigator.mediaSession.setActionHandler('nexttrack', () => {
if (playerData.nextProductId) {
window.location.href = `/player/${playerData.nextProductId}`;
}
});
}
// Clean up when PiP closes
media.addEventListener('leavepictureinpicture', () => {
document.body.removeChild(media);
});
} else {
// Fallback: keep small floating player on page
media.play();
}
} catch (err) {
console.error('Error opening media player:', err);
}
}
</script>

View file

@ -174,19 +174,75 @@
</section>
<script>
function openMediaPlayer(productId) {
const features = 'width=800,height=600,menubar=no,toolbar=no,location=no,status=no,scrollbars=no';
const playerWindow = window.open('/player/' + productId, 'mediaPlayer', features);
async function openMediaPlayer(productId) {
// Fetch player data to get media URL and navigation info
try {
const response = await fetch(`/player/${productId}`);
const html = await response.text();
// Apply always-on-top if enabled in localStorage
if (localStorage.getItem('player_always_on_top') === 'true' && playerWindow) {
const focusInterval = setInterval(() => {
if (playerWindow.closed) {
clearInterval(focusInterval);
} else {
playerWindow.focus();
// 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 scriptEl = doc.querySelector('script:last-of-type');
if (!videoEl) {
console.error('No media element found');
return;
}
// Create hidden video 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;
document.body.appendChild(media);
// Extract playerData from script
let playerData = null;
if (scriptEl) {
const scriptContent = scriptEl.textContent;
const match = scriptContent.match(/window\.playerData\s*=\s*(\{[^}]+\})/);
if (match) {
playerData = eval('(' + match[1] + ')');
}
}, 500);
}
// Enter Picture-in-Picture mode
if (document.pictureInPictureEnabled && media.tagName === 'VIDEO') {
await media.play();
await media.requestPictureInPicture();
// Set up Media Session API for prev/next controls
if ('mediaSession' in navigator && playerData) {
navigator.mediaSession.setActionHandler('previoustrack', () => {
if (playerData.prevProductId) {
window.location.href = `/player/${playerData.prevProductId}`;
}
});
navigator.mediaSession.setActionHandler('nexttrack', () => {
if (playerData.nextProductId) {
window.location.href = `/player/${playerData.nextProductId}`;
}
});
}
// Clean up when PiP closes
media.addEventListener('leavepictureinpicture', () => {
document.body.removeChild(media);
});
} else {
// Fallback: keep small floating player on page
media.play();
}
} catch (err) {
console.error('Error opening media player:', err);
}
}
</script>