Move through the reel from the picture-in-picture window
A picture-in-picture window shows one video's frames and nothing else -- no markup, no scrolling. What it does have is the controls the page declares through the Media Session API, so moving through the reel from the window means its next and previous buttons. Those are the same controls that appear on a lock screen, on headphones and on media keys, and with no window open they move the feed itself. The window rides the pane's own video. That matters for the automatic case, and it is where the first attempt went wrong: a hidden stand-in element was used so the window could survive the feed advancing, but a browser only carries a video out to a window by itself if that video is already playing, and a hidden one never is. So opening it by hand worked -- a click is a gesture, and an explicit request needs one -- while switching tabs could not, and the autoPictureInPicture attribute meant to handle that case was sitting on a different element than the code used. Both halves now point at the same video. Stepping is what a pane's video normally cannot survive, since the feed destroys a step's videos when it moves on. While a pane is in the window it is pinned against teardown and its source is swapped in place: the pane becomes the video being watched, title and poster included, and the feed is rebuilt around wherever it ended when the window closes. Two panes can name the same video while that is going on; the rebuild puts the steps back in order. The leave handler releases the pin on any leave that finds nothing left in a window, rather than insisting the element match exactly. The two ways of being wrong are not the same size: releasing too readily costs a rebuild nobody sees, while a pin that never releases leaves the feed unable to recycle that slide for the rest of the session. Whether a window opens on a tab switch is browser policy, not ours: Safari honours the attribute, Chrome honours it for installed apps. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
This commit is contained in:
@@ -259,7 +259,11 @@ App.feed = App.feed || {};
|
|||||||
pipBtn.hidden = !App.customPlayer.supportsPiP();
|
pipBtn.hidden = !App.customPlayer.supportsPiP();
|
||||||
const onClick = async (event) => {
|
const onClick = async (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
await App.customPlayer.togglePiP(video);
|
if (document.pictureInPictureElement) {
|
||||||
|
await document.exitPictureInPicture().catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await App.feed.openPip(pane);
|
||||||
};
|
};
|
||||||
pipBtn.addEventListener('click', onClick);
|
pipBtn.addEventListener('click', onClick);
|
||||||
cleanups.push(() => pipBtn.removeEventListener('click', onClick));
|
cleanups.push(() => pipBtn.removeEventListener('click', onClick));
|
||||||
@@ -752,6 +756,8 @@ App.feed = App.feed || {};
|
|||||||
const removeSlide = function(index) {
|
const removeSlide = function(index) {
|
||||||
const slide = slidesByIndex.get(index);
|
const slide = slidesByIndex.get(index);
|
||||||
if (!slide) return;
|
if (!slide) return;
|
||||||
|
// Removing the element in the picture-in-picture window closes it.
|
||||||
|
if (pipPinned(slide)) return;
|
||||||
teardownSlide(slide);
|
teardownSlide(slide);
|
||||||
slide.remove();
|
slide.remove();
|
||||||
slidesByIndex.delete(index);
|
slidesByIndex.delete(index);
|
||||||
@@ -922,7 +928,7 @@ App.feed = App.feed || {};
|
|||||||
// Recently-watched panes stay loaded but paused so scrolling
|
// Recently-watched panes stay loaded but paused so scrolling
|
||||||
// back resumes seamlessly from where it was paused.
|
// back resumes seamlessly from where it was paused.
|
||||||
if (pane.classList.contains('is-loaded')) pauseSlide(pane);
|
if (pane.classList.contains('is-loaded')) pauseSlide(pane);
|
||||||
} else if (pane.classList.contains('is-loaded')) {
|
} else if (pane.classList.contains('is-loaded') && pane !== pipPane) {
|
||||||
destroySlidePlayback(pane);
|
destroySlidePlayback(pane);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -994,6 +1000,172 @@ App.feed = App.feed || {};
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Picture-in-picture that can be moved through
|
||||||
|
//
|
||||||
|
// A picture-in-picture window shows one <video>'s frames and nothing else:
|
||||||
|
// no markup, no scrolling. What it does offer is the media controls the
|
||||||
|
// page declares through the Media Session API, so "scroll the reel from
|
||||||
|
// the window" means its next/previous buttons -- which are the same
|
||||||
|
// controls that appear on a lock screen, on headphones and on media keys.
|
||||||
|
//
|
||||||
|
// Those buttons cannot simply advance the feed, because picture-in-picture
|
||||||
|
// is bound to one element and the feed destroys a step's videos when it
|
||||||
|
// moves on -- the window would close. So the window is given a surface of
|
||||||
|
// its own: one <video> that outlives any step, whose source is swapped as
|
||||||
|
// the reader moves. The feed follows along underneath, so leaving the
|
||||||
|
// window puts them where they expect to be.
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// The element in the window is the pane's own video, not a hidden stand-in.
|
||||||
|
// That matters for the automatic case: a browser will only carry a video
|
||||||
|
// out to a window on its own if that video is already playing, and only the
|
||||||
|
// panes are. A hidden surface can be put there by an explicit request, but
|
||||||
|
// an explicit request needs a user gesture and a tab switch has none --
|
||||||
|
// which is exactly why opening it by hand worked and switching tabs did
|
||||||
|
// not.
|
||||||
|
//
|
||||||
|
// Stepping is what a pane's video normally cannot survive, since the feed
|
||||||
|
// destroys a step's videos when it moves on. So while a pane is in the
|
||||||
|
// window it is pinned against teardown and its source is swapped in place:
|
||||||
|
// the pane becomes the video being watched, and the feed is rebuilt around
|
||||||
|
// it on the way back.
|
||||||
|
let pipPane = null;
|
||||||
|
|
||||||
|
const pipPinned = function(slide) {
|
||||||
|
return !!(pipPane && slide && slide.contains(pipPane));
|
||||||
|
};
|
||||||
|
|
||||||
|
App.feed.pipPinned = pipPinned;
|
||||||
|
|
||||||
|
// Points a pane at another video, in place.
|
||||||
|
const pipRebind = function(pane, v) {
|
||||||
|
const video = pane.querySelector('.feed-video');
|
||||||
|
if (!video || !v) return;
|
||||||
|
if (video._hlsPlayer) {
|
||||||
|
video._hlsPlayer.destroy();
|
||||||
|
video._hlsPlayer = null;
|
||||||
|
}
|
||||||
|
video._tearingDown = true;
|
||||||
|
video.pause();
|
||||||
|
video.removeAttribute('src');
|
||||||
|
video.load();
|
||||||
|
pane.classList.remove('is-loaded');
|
||||||
|
pane._videoData = v;
|
||||||
|
pane._formatOverride = null;
|
||||||
|
pane._awaitingFormats = false;
|
||||||
|
pane._failed = false;
|
||||||
|
pane.dataset.videoId = v.id;
|
||||||
|
// The pane really is this video now, so what it says has to match --
|
||||||
|
// it is on screen again the moment the window closes.
|
||||||
|
const titleText = pane.querySelector('.feed-title-text');
|
||||||
|
if (titleText) titleText.textContent = v.title || '';
|
||||||
|
const poster = pane.querySelector('.feed-poster');
|
||||||
|
if (poster) App.videos.attachThumbnail(poster, v.thumb);
|
||||||
|
loadSlideSource(pane, v, true);
|
||||||
|
setPipMetadata(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Moves a video at a time. With a window open that is the pinned pane; with
|
||||||
|
// none it is the feed itself, since these handlers are also the media keys.
|
||||||
|
const pipStep = function(delta) {
|
||||||
|
const videos = state.loadedVideos || [];
|
||||||
|
if (!videos.length) return;
|
||||||
|
|
||||||
|
if (!pipPane) {
|
||||||
|
const step = clampIndex(state.feedActiveIndex + delta);
|
||||||
|
if (step < 0 || step === state.feedActiveIndex) return;
|
||||||
|
const scroller = getScroller();
|
||||||
|
if (scroller) scroller.scrollTo({ top: step * slideHeight(), behavior: 'smooth' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = videos.findIndex((v) => String(v.id) === String(pipPane.dataset.videoId));
|
||||||
|
const next = Math.min(videos.length - 1, Math.max(0, (current < 0 ? 0 : current) + delta));
|
||||||
|
if (next === current) return;
|
||||||
|
pipRebind(pipPane, videos[next]);
|
||||||
|
// Keep pulling pages if the window is walking towards the end.
|
||||||
|
prefetchIfNeeded(Math.floor(next / paneCount()));
|
||||||
|
};
|
||||||
|
|
||||||
|
App.feed.pipStep = pipStep;
|
||||||
|
|
||||||
|
// What the window, the lock screen and the media keys show.
|
||||||
|
const setPipMetadata = function(v) {
|
||||||
|
if (!navigator.mediaSession || !window.MediaMetadata) return;
|
||||||
|
try {
|
||||||
|
navigator.mediaSession.metadata = new window.MediaMetadata({
|
||||||
|
title: v.title || 'Video',
|
||||||
|
artist: v.uploader || '',
|
||||||
|
artwork: v.thumb ? [{ src: v.thumb }] : []
|
||||||
|
});
|
||||||
|
} catch (err) { /* decoration; never fail playback for it */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindMediaSession = function() {
|
||||||
|
if (!navigator.mediaSession || !navigator.mediaSession.setActionHandler) return;
|
||||||
|
const set = (action, handler) => {
|
||||||
|
try { navigator.mediaSession.setActionHandler(action, handler); }
|
||||||
|
catch (err) { /* this browser doesn't offer that one */ }
|
||||||
|
};
|
||||||
|
set('nexttrack', () => pipStep(1));
|
||||||
|
set('previoustrack', () => pipStep(-1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const unbindMediaSession = function() {
|
||||||
|
if (!navigator.mediaSession || !navigator.mediaSession.setActionHandler) return;
|
||||||
|
['nexttrack', 'previoustrack'].forEach((action) => {
|
||||||
|
try { navigator.mediaSession.setActionHandler(action, null); } catch (err) { /* ignore */ }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// enterpictureinpicture bubbles, so one pair of listeners covers every pane
|
||||||
|
// however the window was opened -- the button, or the browser doing it
|
||||||
|
// itself when the tab is hidden.
|
||||||
|
const bindPipTracking = function() {
|
||||||
|
document.addEventListener('enterpictureinpicture', (event) => {
|
||||||
|
const pane = event.target.closest && event.target.closest('.feed-pane');
|
||||||
|
if (!pane) return;
|
||||||
|
pipPane = pane;
|
||||||
|
bindMediaSession();
|
||||||
|
setPipMetadata(pane._videoData || {});
|
||||||
|
});
|
||||||
|
document.addEventListener('leavepictureinpicture', (event) => {
|
||||||
|
if (!pipPane) return;
|
||||||
|
const pane = event.target.closest && event.target.closest('.feed-pane');
|
||||||
|
// Deliberately not an exact match on the pinned pane. A pin that is
|
||||||
|
// never released leaves the feed unable to recycle that slide for
|
||||||
|
// the rest of the session, so any leave that finds nothing left in
|
||||||
|
// a window releases it -- being wrong here costs a rebuild, being
|
||||||
|
// stuck costs a frozen feed.
|
||||||
|
if (pane && pane !== pipPane && document.pictureInPictureElement) return;
|
||||||
|
const landed = (pipPane.dataset && pipPane.dataset.videoId) || null;
|
||||||
|
pipPane = null;
|
||||||
|
unbindMediaSession();
|
||||||
|
if (navigator.mediaSession) navigator.mediaSession.metadata = null;
|
||||||
|
if (!state.feedOpen) return;
|
||||||
|
// The pinned pane was walked out of its step and now shows a video
|
||||||
|
// that belongs to another one -- two panes can even name the same
|
||||||
|
// video while it is pinned. Rebuilding around where it ended puts
|
||||||
|
// every step back in order.
|
||||||
|
if (landed) state.feedActiveVideoId = landed;
|
||||||
|
rebuildLayout();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
App.feed.openPip = async function(pane) {
|
||||||
|
if (!document.pictureInPictureEnabled) return false;
|
||||||
|
const target = pane || (slidesByIndex.get(state.feedActiveIndex) &&
|
||||||
|
panesOf(slidesByIndex.get(state.feedActiveIndex))[0]);
|
||||||
|
const video = target && target.querySelector('.feed-video');
|
||||||
|
if (!video || video.disablePictureInPicture) return false;
|
||||||
|
try {
|
||||||
|
await video.requestPictureInPicture();
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Auto picture-in-picture
|
// Auto picture-in-picture
|
||||||
//
|
//
|
||||||
@@ -1037,6 +1209,9 @@ App.feed = App.feed || {};
|
|||||||
if (document.pictureInPictureElement) return;
|
if (document.pictureInPictureElement) return;
|
||||||
const video = autoPipVideo();
|
const video = autoPipVideo();
|
||||||
if (!video || video.paused || video.ended || video.disablePictureInPicture) return;
|
if (!video || video.paused || video.ended || video.disablePictureInPicture) return;
|
||||||
|
// The browser may already be doing this itself, from the attribute
|
||||||
|
// updateAutoPiPTarget put on this very element; the request is only for
|
||||||
|
// where the attribute is ignored but a request would be allowed.
|
||||||
video.requestPictureInPicture().catch(() => {});
|
video.requestPictureInPicture().catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1115,6 +1290,7 @@ App.feed = App.feed || {};
|
|||||||
if (!autoPipBound) {
|
if (!autoPipBound) {
|
||||||
document.addEventListener('visibilitychange', onFeedHidden);
|
document.addEventListener('visibilitychange', onFeedHidden);
|
||||||
window.addEventListener('pagehide', onFeedHidden);
|
window.addEventListener('pagehide', onFeedHidden);
|
||||||
|
bindPipTracking();
|
||||||
autoPipBound = true;
|
autoPipBound = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1153,6 +1329,11 @@ App.feed = App.feed || {};
|
|||||||
}
|
}
|
||||||
document.body.classList.remove('feed-hud-idle');
|
document.body.classList.remove('feed-hud-idle');
|
||||||
updateAutoPiPTarget(); // feedOpen is false now, so this clears them
|
updateAutoPiPTarget(); // feedOpen is false now, so this clears them
|
||||||
|
unbindMediaSession();
|
||||||
|
if (pipPane && document.pictureInPictureElement) {
|
||||||
|
document.exitPictureInPicture().catch(() => {});
|
||||||
|
}
|
||||||
|
pipPane = null;
|
||||||
slidesByIndex.forEach((slide) => panesOf(slide).forEach(destroySlidePlayback));
|
slidesByIndex.forEach((slide) => panesOf(slide).forEach(destroySlidePlayback));
|
||||||
container.classList.remove('open');
|
container.classList.remove('open');
|
||||||
container.setAttribute('aria-hidden', 'true');
|
container.setAttribute('aria-hidden', 'true');
|
||||||
|
|||||||
Reference in New Issue
Block a user