A turn is not one shape change: the viewport passes through every shape between the one it left and the one it lands in, and each of them re-letterboxes the picture somewhere else. Painted, that run of hard jumps is the stutter. So for the length of a turn the slides are held at the size they already had, and the moment the viewport stops moving they take the size it ended at -- measured, not guessed. The picture moves exactly once, straight into the position and size it then keeps. The device's own `orientationchange` starts the hold, which is the earliest anything can know a turn is coming and the one signal that separates it from a window being dragged wider; failing that, the viewport swapping its long side for its short one does. A viewport that only changes height is the browser's chrome sliding away, and is never held. Underneath: snapping is off while the slides are held, so the container cannot re-anchor on the old height at the same time; a scroll arriving during the window is put back in the event rather than a frame later, so the stale position is never painted; and the per-frame realign only writes what actually differs instead of costing a layout sixty times a second. Measured over a recorded turn: the picture moves 12 times -> 2, once per turn; the worst of those was a 660px jump between two intermediate shapes and is now the one step into the final size; the reel is never off its step (was 660px out); and the clip never pauses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2089 lines
94 KiB
JavaScript
2089 lines
94 KiB
JavaScript
window.App = window.App || {};
|
|
App.feed = App.feed || {};
|
|
|
|
(function() {
|
|
const state = App.state;
|
|
|
|
// Tuning knobs for the virtualized feed.
|
|
//
|
|
// The feed is a y-scroll-snap list where every slide is exactly one
|
|
// viewport tall. We never keep the whole list in the DOM. Instead we keep
|
|
// a sliding window of slides around the active one:
|
|
//
|
|
// [active - HISTORY_COUNT .. active + RENDER_AHEAD]
|
|
//
|
|
// Everything outside that range is removed from the document; its video
|
|
// JSON stays in state.loadedVideos so the slide is rebuilt instantly when
|
|
// the user scrolls back to (or forward into) it. A top spacer absorbs the
|
|
// height of the not-yet-rendered slides above the window, so adding or
|
|
// removing slides never shifts the scroll position: any slide at index i
|
|
// always sits at scrollTop === i * slideHeight regardless of the window.
|
|
//
|
|
// Separately we prefetch PREFETCH_PAGES worth of video JSON ahead of the
|
|
// active slide so the data buffer is always full before we need to render
|
|
// a slide from it.
|
|
// Preloading is counted in steps, so it scales with the split rather than
|
|
// being shared out between the panels: every panel keeps its own next three
|
|
// videos ready, which is N * 3 videos loading ahead for a step of N panels.
|
|
// The step right behind the one being watched buffers for real; the two
|
|
// behind that hold their metadata, which is what makes their first frame
|
|
// immediate without four more streams downloading in full.
|
|
const PRELOAD_STEPS = 3; // steps ahead kept loaded, per panel
|
|
const BUFFER_STEPS = 1; // of those, how many buffer rather than just resolve
|
|
const RENDER_AHEAD = 5; // slides ahead kept materialized in the DOM
|
|
const HISTORY_COUNT = 5; // slides behind kept materialized in the DOM
|
|
const PREFETCH_PAGES = 2; // pages of JSON to keep buffered ahead of the active slide
|
|
|
|
// Map of loadedVideos index -> rendered .feed-slide element.
|
|
const slidesByIndex = new Map();
|
|
let scrollBound = false;
|
|
let scrollRaf = null;
|
|
|
|
// While true, scroll events are ignored. A viewport change (e.g. an
|
|
// orientation switch) makes the scroll-snap container re-snap and fire
|
|
// scroll events with positions that no longer map to the active slide;
|
|
// onResize sets this for the brief realign window so those events don't
|
|
// flip the active video -- rotating the device must never change which
|
|
// slide is playing. Normal swipes (no resize in flight) are unaffected.
|
|
let suppressScroll = false;
|
|
let resizeSettleRaf = null;
|
|
let resizeSettleTimer = null;
|
|
// A rotation is not one event. The viewport, the browser's own chrome and
|
|
// the scroll-snap container settle over many frames and often several
|
|
// `resize` events, so the realign window is held open until the slide
|
|
// height has held still for a stretch of frames -- not for a fixed two,
|
|
// which ran out while the viewport was still moving and let the stale
|
|
// scroll positions through, skipping the reel forward a few videos.
|
|
const SETTLE_STABLE_FRAMES = 10;
|
|
const SETTLE_MAX_MS = 1500;
|
|
let settleStartedAt = 0;
|
|
let settleStableFrames = 0;
|
|
let settleHeight = -1;
|
|
let settleDone = null; // run once the window closes, if anything asked
|
|
let lastMeasuredHeight = -1;
|
|
|
|
// A turn is not just another resize. The viewport passes through every
|
|
// shape between the one it left and the one it lands in, and each one
|
|
// re-letterboxes the picture somewhere else: that run of hard jumps is
|
|
// what reads as a stutter. So for the length of the turn the slides are
|
|
// held at the size they already had, and the moment the viewport stops
|
|
// moving they take the size it ended at -- measured, not guessed, so the
|
|
// picture moves exactly once, straight into the place it then keeps.
|
|
// Only a real turn gets that: the browser's own chrome sliding away moves
|
|
// the viewport too, and holding for those would be worse than the jump it
|
|
// saves.
|
|
//
|
|
// `orientationchange` fires before the new metrics are readable, which is
|
|
// the earliest anything can know a turn is coming -- early enough to pin
|
|
// before the first new shape is painted. It is also the one signal that
|
|
// separates a turn from a window being dragged wider on a desktop.
|
|
const TURN_SIGNAL_MS = 1200;
|
|
// The hold comes off once the viewport has stopped moving -- sooner than
|
|
// the realign window closes, which goes on holding the reel in place for
|
|
// a while after that in case a last stale scroll arrives. Long enough
|
|
// that a viewport animating round in steps is not mistaken for one that
|
|
// has arrived, short enough not to be a wait.
|
|
const TURN_HOLD_FRAMES = 6;
|
|
let turnSignalAt = 0;
|
|
let turnPin = null; // the shape the slides are held at, mid-turn
|
|
let turnResolved = false;
|
|
let settleFromShape = null; // the shape the viewport had before this turn
|
|
let lastShape = null;
|
|
|
|
// HUD auto-hide: the reels HUD fades out after this much inactivity and
|
|
// reappears on any pointer movement / tap / scroll. Buttons keep their
|
|
// pointer-events while hidden, so they stay clickable even when invisible.
|
|
const HUD_IDLE_MS = 1000;
|
|
let hudIdleTimer = null;
|
|
let hudActivityBound = false;
|
|
|
|
// Everything the feed owns hangs off #feed-view, and the whole subtree can
|
|
// be moved into a document picture-in-picture window (see openDocPip). So
|
|
// it is looked up once and kept: after the move it is no longer in this
|
|
// document, and getElementById would return nothing.
|
|
let feedRoot = null;
|
|
const getRoot = function() {
|
|
if (!feedRoot || !feedRoot.isConnected) feedRoot = document.getElementById('feed-view') || feedRoot;
|
|
return feedRoot;
|
|
};
|
|
|
|
// The document the feed currently lives in -- this one, or the picture-in-
|
|
// picture window's.
|
|
const feedDocument = function() {
|
|
const root = getRoot();
|
|
return (root && root.ownerDocument) || document;
|
|
};
|
|
|
|
// The HUD-idle class goes on both the feed root and the body: the root so it
|
|
// travels into the picture-in-picture window, the body for .mode-toggle-btn,
|
|
// which sits outside the feed and stays behind.
|
|
const setHudIdle = function(on) {
|
|
const root = getRoot();
|
|
if (root) root.classList.toggle('feed-hud-idle', on);
|
|
document.body.classList.toggle('feed-hud-idle', on);
|
|
};
|
|
|
|
const scheduleHudHide = function() {
|
|
if (hudIdleTimer) clearTimeout(hudIdleTimer);
|
|
hudIdleTimer = setTimeout(() => {
|
|
hudIdleTimer = null;
|
|
if (!state.feedOpen) return;
|
|
setHudIdle(true);
|
|
// The quality menu only fades with the rest of the HUD if we close
|
|
// it: it's an opened popover, not a permanently mounted control.
|
|
const root = getRoot();
|
|
if (root) root.querySelectorAll('.feed-format-menu').forEach((menu) => { menu.hidden = true; });
|
|
}, HUD_IDLE_MS);
|
|
};
|
|
|
|
const wakeHud = function() {
|
|
setHudIdle(false);
|
|
if (state.feedOpen) scheduleHudHide();
|
|
};
|
|
|
|
// Moving through the feed is not using the HUD. A swipe to the next reel
|
|
// used to put the controls up over the video arriving -- the one thing the
|
|
// swipe was for -- and leave them there for a second. So the scroll puts
|
|
// them away instead, and only a real interaction (a tap, the mouse moving,
|
|
// a control being used) brings them back.
|
|
const sleepHud = function() {
|
|
if (hudIdleTimer) {
|
|
clearTimeout(hudIdleTimer);
|
|
hudIdleTimer = null;
|
|
}
|
|
setHudIdle(true);
|
|
const root = getRoot();
|
|
if (root) root.querySelectorAll('.feed-format-menu').forEach((menu) => { menu.hidden = true; });
|
|
};
|
|
|
|
const inRoot = function(id) {
|
|
const root = getRoot();
|
|
return root ? root.querySelector(`#${id}`) : null;
|
|
};
|
|
|
|
const getScroller = () => inRoot('feed-scroll');
|
|
const getSentinel = () => inRoot('feed-sentinel');
|
|
const getTopSpacer = () => inRoot('feed-top-spacer');
|
|
|
|
const slideHeight = function() {
|
|
// Mid-turn the slides are the size the turn is heading for, not the
|
|
// size the viewport happens to be passing through, and every scroll
|
|
// position in the feed is a multiple of a slide.
|
|
if (turnPin) return turnPin.h;
|
|
const scroller = getScroller();
|
|
const view = feedDocument().defaultView || window;
|
|
return (scroller && scroller.clientHeight) || view.innerHeight || 1;
|
|
};
|
|
|
|
// Steps, not videos: with N panes a step covers N videos at once.
|
|
const stepCount = function() {
|
|
const total = (state.loadedVideos || []).length;
|
|
return total === 0 ? 0 : Math.ceil(total / paneCount());
|
|
};
|
|
|
|
const clampIndex = function(index) {
|
|
const total = stepCount();
|
|
if (total === 0) return -1;
|
|
return Math.min(total - 1, Math.max(0, index));
|
|
};
|
|
|
|
// True when the user wants a finished clip to replay; false when it should
|
|
// auto-advance to the next video. Defaults to looping (see storage).
|
|
const shouldLoop = function() {
|
|
return App.storage.getFeedEndBehavior() !== 'scroll';
|
|
};
|
|
|
|
// Smoothly scrolls to the slide after `fromIndex`; the scroll-snap container
|
|
// fires onScroll, which promotes the new slide to active. No-ops at the end
|
|
// of the list so the final clip simply stops on its last frame.
|
|
const advanceToNext = function(fromIndex) {
|
|
const next = clampIndex(fromIndex + 1);
|
|
if (next < 0 || next === fromIndex) return;
|
|
const scroller = getScroller();
|
|
if (scroller) scroller.scrollTo({ top: next * slideHeight(), behavior: 'smooth' });
|
|
};
|
|
|
|
// ------------------------------------------------------------------
|
|
// Sound in a split
|
|
//
|
|
// iOS gives a page one audible video at a time. Start a second one with
|
|
// sound and the system pauses the first -- which, in a split of two or
|
|
// four panels, reads as "only one panel is playing". Muted video carries
|
|
// no such limit, so where the rule applies exactly one panel keeps its
|
|
// sound and the rest are silenced: four pictures moving is what the split
|
|
// is for, and four audio tracks at once was never the point.
|
|
//
|
|
// Nothing in the platform announces this, and by the time the symptom
|
|
// shows -- a video we started, stopped by something that isn't us -- the
|
|
// reader has already watched a panel die. So it is read off the device
|
|
// rather than discovered.
|
|
const audioIsExclusive = (function() {
|
|
const ua = navigator.userAgent || '';
|
|
if (/iPhone|iPad|iPod/.test(ua)) return true;
|
|
// iPadOS 13+ claims to be a Mac; the touch points give it away.
|
|
return /Macintosh/.test(ua) && (navigator.maxTouchPoints || 0) > 1;
|
|
})();
|
|
|
|
// Leaves at most one audible panel in the step being watched. `prefer` is
|
|
// the panel whose sound was just asked for, if any; otherwise the first
|
|
// one already audible keeps it. Silenced panels get their own button
|
|
// updated too, so what is shown never disagrees with what is heard.
|
|
const limitAudibleToOne = function(prefer) {
|
|
if (!audioIsExclusive) return;
|
|
const slide = slidesByIndex.get(state.feedActiveIndex);
|
|
if (!slide) return;
|
|
let kept = (prefer && prefer._muted === false) ? prefer : null;
|
|
panesOf(slide).forEach((pane) => {
|
|
if (pane._muted) return;
|
|
if (!kept) { kept = pane; return; }
|
|
if (pane === kept) return;
|
|
pane._muted = true;
|
|
if (pane._syncMute) pane._syncMute();
|
|
});
|
|
};
|
|
|
|
// Starts whatever in the current step is loaded but stopped -- which is
|
|
// what iOS leaves behind when a panel's sound is taken away from it.
|
|
const resumeActivePanes = function() {
|
|
const slide = slidesByIndex.get(state.feedActiveIndex);
|
|
if (!slide) return;
|
|
panesOf(slide).forEach((pane) => {
|
|
if (!pane.classList.contains('is-loaded')) return;
|
|
const video = pane.querySelector('.feed-video');
|
|
if (!video || !video.paused) return;
|
|
const playPromise = video.play();
|
|
if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(() => {});
|
|
});
|
|
};
|
|
|
|
// Remembers playback position per video id so scrolling away and back
|
|
// resumes where the user left off. Slides kept in the window are merely
|
|
// paused (instant resume); slides whose <video> is torn down to free
|
|
// resources still have their position restored on reload via applyResume.
|
|
const KEEP_BEHIND = 2; // slides behind the active one kept loaded & paused
|
|
const resumeTimes = new Map();
|
|
|
|
const slideVideoId = (slide) => (slide && slide._videoData ? slide._videoData.id : null);
|
|
|
|
const rememberTime = function(slide, video) {
|
|
if (!slide || !video || slide.classList.contains('is-live')) return;
|
|
const id = slideVideoId(slide);
|
|
if (id == null) return;
|
|
const t = video.currentTime;
|
|
if (isFinite(t) && t > 0.5) resumeTimes.set(id, t);
|
|
};
|
|
|
|
const applyResume = function(video, videoId, isLive) {
|
|
if (!video || isLive || videoId == null) return;
|
|
const t = resumeTimes.get(videoId);
|
|
if (t == null || t <= 0) return;
|
|
const seek = () => {
|
|
let target = t;
|
|
if (isFinite(video.duration) && video.duration > 0) {
|
|
target = Math.min(t, video.duration - 0.25);
|
|
}
|
|
try { video.currentTime = Math.max(0, target); } catch (err) { /* ignore */ }
|
|
};
|
|
if (video.readyState >= 1) seek();
|
|
else video.addEventListener('loadedmetadata', seek, { once: true });
|
|
};
|
|
|
|
// Pauses a slide but keeps its <video> loaded so returning to it resumes
|
|
// instantly from the exact frame it was paused on.
|
|
const pauseSlide = function(slide) {
|
|
const video = slide.querySelector('.feed-video');
|
|
slide.classList.remove('is-active');
|
|
if (video && !video.paused) video.pause();
|
|
rememberTime(slide, video);
|
|
};
|
|
|
|
const destroySlidePlayback = function(slide) {
|
|
const video = slide.querySelector('.feed-video');
|
|
slide.classList.remove('is-active');
|
|
rememberTime(slide, video);
|
|
const fill = slide.querySelector('.feed-timeline-fill');
|
|
if (fill) fill.style.width = '0%';
|
|
const handle = slide.querySelector('.feed-timeline-handle');
|
|
if (handle) handle.style.left = '0%';
|
|
if (!video) return;
|
|
if (video._hlsPlayer) {
|
|
video._hlsPlayer.destroy();
|
|
video._hlsPlayer = null;
|
|
}
|
|
// Clearing the src below makes the element fire a spurious `error` event;
|
|
// flag the teardown so the failure handler ignores it (see markSlideFailed).
|
|
video._tearingDown = true;
|
|
video.pause();
|
|
video.removeAttribute('src');
|
|
video.load();
|
|
slide.classList.remove('is-loaded');
|
|
};
|
|
|
|
// Single-line slide title that scrolls when it overflows. Only the active
|
|
// slide's title is measured, so like the player it always scrolls.
|
|
const measureFeedTitle = function(slide) {
|
|
if (!slide) return;
|
|
App.marquee.measure(slide.querySelector('.feed-title'), slide.querySelector('.feed-title-text'));
|
|
};
|
|
|
|
const setTimelinePosition = function(slide, ratio) {
|
|
const fill = slide.querySelector('.feed-timeline-fill');
|
|
const handle = slide.querySelector('.feed-timeline-handle');
|
|
const pct = `${Math.min(1, Math.max(0, ratio)) * 100}%`;
|
|
if (fill) fill.style.width = pct;
|
|
if (handle) handle.style.left = pct;
|
|
};
|
|
|
|
const seekFromPointer = function(slide, video, timeline, clientX) {
|
|
if (!isFinite(video.duration) || video.duration <= 0) return;
|
|
const rect = timeline.getBoundingClientRect();
|
|
const ratio = rect.width > 0 ? (clientX - rect.left) / rect.width : 0;
|
|
const clamped = Math.min(1, Math.max(0, ratio));
|
|
video.currentTime = clamped * video.duration;
|
|
setTimelinePosition(slide, clamped);
|
|
};
|
|
|
|
const bindTimeline = function(slide, video) {
|
|
const timeline = slide.querySelector('.feed-timeline');
|
|
if (!timeline) return;
|
|
let scrubbing = false;
|
|
|
|
video.addEventListener('timeupdate', () => {
|
|
if (scrubbing || !isFinite(video.duration) || video.duration <= 0) return;
|
|
setTimelinePosition(slide, video.currentTime / video.duration);
|
|
});
|
|
|
|
timeline.addEventListener('pointerdown', (event) => {
|
|
scrubbing = true;
|
|
// Dragging the scrubber is the one drag that *is* the HUD.
|
|
wakeHud();
|
|
timeline.classList.add('is-scrubbing');
|
|
timeline.setPointerCapture(event.pointerId);
|
|
seekFromPointer(slide, video, timeline, event.clientX);
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
});
|
|
|
|
timeline.addEventListener('pointermove', (event) => {
|
|
if (!scrubbing) return;
|
|
wakeHud();
|
|
seekFromPointer(slide, video, timeline, event.clientX);
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
});
|
|
|
|
const stopScrubbing = (event) => {
|
|
if (!scrubbing) return;
|
|
scrubbing = false;
|
|
timeline.classList.remove('is-scrubbing');
|
|
if (timeline.hasPointerCapture(event.pointerId)) {
|
|
timeline.releasePointerCapture(event.pointerId);
|
|
}
|
|
event.stopPropagation();
|
|
};
|
|
timeline.addEventListener('pointerup', stopScrubbing);
|
|
timeline.addEventListener('pointercancel', stopScrubbing);
|
|
};
|
|
|
|
const flashFeed = function(slide, text) {
|
|
const flashEl = slide.querySelector('.feed-flash');
|
|
if (!flashEl) return;
|
|
flashEl.textContent = text;
|
|
flashEl.classList.remove('is-visible');
|
|
void flashEl.offsetWidth;
|
|
flashEl.classList.add('is-visible');
|
|
};
|
|
|
|
// Wires the controls shared with the standalone fullscreen player (skip
|
|
// escalation + double-tap zones, format switching, PiP) onto a reels
|
|
// slide, reusing the same App.customPlayer logic so both surfaces behave
|
|
// identically. Feed's own timeline/favorite/title and scroll-snap
|
|
// slide-to-slide navigation are untouched (see bindTimeline above and
|
|
// setActive/onScroll below).
|
|
const bindSharedControls = function(pane, video, videoData) {
|
|
const cleanups = [];
|
|
const escalator = App.customPlayer.createSkipEscalator();
|
|
cleanups.push(() => escalator.destroy());
|
|
|
|
const doSkip = (direction) => {
|
|
const amount = App.customPlayer.skip(video, direction, escalator);
|
|
flashFeed(pane, `${direction === 'forward' ? '+' : '-'}${amount}s`);
|
|
wakeHud();
|
|
};
|
|
|
|
// On iOS these are the only source of enter/leave events; everywhere
|
|
// else this is a no-op and the browser fires them itself.
|
|
cleanups.push(App.customPlayer.bindPiPEvents(video));
|
|
|
|
const pipBtn = pane.querySelector('.feed-pip-btn');
|
|
if (pipBtn) {
|
|
pipBtn.hidden = !App.customPlayer.supportsPiP() && !App.feed.docPipSupported();
|
|
const onClick = async (event) => {
|
|
event.stopPropagation();
|
|
if (App.feed.docPipOpen()) {
|
|
App.feed.closeDocPip();
|
|
return;
|
|
}
|
|
if (App.customPlayer.pipElement()) {
|
|
await App.customPlayer.exitPiP();
|
|
return;
|
|
}
|
|
// The whole feed in a real window beats one pane's frames in a
|
|
// video window: it scrolls, and every control comes with it.
|
|
if (await App.feed.openDocPip()) return;
|
|
await App.feed.openPip(pane);
|
|
};
|
|
pipBtn.addEventListener('click', onClick);
|
|
cleanups.push(() => pipBtn.removeEventListener('click', onClick));
|
|
}
|
|
// Auto-PiP is not bound per pane: only one picture-in-picture window can
|
|
// exist, so binding every pane makes them race and the winner arbitrary.
|
|
// The feed picks one deliberately -- see updateAutoPiPTarget.
|
|
|
|
const formatBtn = pane.querySelector('.feed-format-btn');
|
|
const formatMenu = pane.querySelector('.feed-format-menu');
|
|
const onFormatPick = (fmt) => {
|
|
pane._formatOverride = fmt;
|
|
const t = video.currentTime;
|
|
if (isFinite(t) && t > 0) resumeTimes.set(videoData.id, t);
|
|
// Tear down the current source (mirrors destroySlidePlayback's
|
|
// hls/video reset) before reloading with the new format -- this
|
|
// is a live in-place reload, not a fresh never-loaded pane, so
|
|
// the old Hls.js instance must be destroyed or it keeps running
|
|
// (fetching segments, attached to the same <video>) forever.
|
|
if (video._hlsPlayer) {
|
|
video._hlsPlayer.destroy();
|
|
video._hlsPlayer = null;
|
|
}
|
|
video._tearingDown = true;
|
|
video.pause();
|
|
video.removeAttribute('src');
|
|
video.load();
|
|
pane.classList.remove('is-loaded');
|
|
loadSlideSource(pane, videoData, true);
|
|
};
|
|
const bindFormats = () => App.customPlayer.bindFormatMenu(formatBtn, formatMenu, videoData, onFormatPick,
|
|
{ getCurrentUrl: () => pane._activeUrl || '', onOpen: wakeHud });
|
|
let destroyFormatMenu = bindFormats();
|
|
cleanups.push(() => destroyFormatMenu());
|
|
// A pane can go active before its formats have been resolved (feed items
|
|
// carry only a page URL until then), which would leave the quality menu
|
|
// empty. Playback already runs from that page URL through the proxy, so
|
|
// resolve in the background and rebuild the menu once the real qualities
|
|
// land -- same as the standalone player does.
|
|
if (App.videos && typeof App.videos.ensureFormats === 'function') {
|
|
App.videos.ensureFormats(videoData).then((meta) => {
|
|
// Bail if the pane was torn down (or rebound) in the meantime.
|
|
if (!meta || pane._sharedControlCleanups !== cleanups) return;
|
|
destroyFormatMenu();
|
|
destroyFormatMenu = bindFormats();
|
|
});
|
|
}
|
|
|
|
cleanups.push(App.customPlayer.attachGestures(pane, {
|
|
onSingleTap: wakeHud,
|
|
onDoubleTapLeft: () => doSkip('back'),
|
|
onDoubleTapRight: () => doSkip('forward'),
|
|
ignoreSelector: 'button, input, a, .cp-format-menu, .feed-timeline'
|
|
}));
|
|
|
|
pane._sharedControlCleanups = cleanups;
|
|
};
|
|
|
|
// The tallest rendition worth decoding for this pane: its own height in
|
|
// device pixels, rounded up to the next common rendition so a pane a little
|
|
// over 360px doesn't get 360p. Returns 0 (no cap) for a single full-screen
|
|
// pane, which is the old behaviour.
|
|
const RENDITION_STEPS = [240, 360, 480, 720, 1080, 1440, 2160];
|
|
|
|
const paneHeightCap = function(pane) {
|
|
if (paneCount() <= 1) return 0;
|
|
const box = pane.getBoundingClientRect();
|
|
if (!box.height) return 0;
|
|
const needed = box.height * (window.devicePixelRatio || 1);
|
|
return RENDITION_STEPS.find((step) => step >= needed) || 0;
|
|
};
|
|
|
|
// `opts.silent` plays without sound whatever the panel's own switch says --
|
|
// for a panel that is only half on screen (see peek), whose audio would
|
|
// otherwise talk over the one still being watched. `opts.buffer` asks for a
|
|
// real buffer rather than metadata alone, for a step close enough that it
|
|
// is about to be swiped to.
|
|
const loadSlideSource = function(slide, videoData, autoplay, opts) {
|
|
const video = slide.querySelector('.feed-video');
|
|
if (!video) return;
|
|
const silent = !!(opts && opts.silent);
|
|
if (slide.classList.contains('is-loaded')) {
|
|
if (autoplay) {
|
|
// Picks up a stream that was parked after preloading.
|
|
if (video._hlsPlayer) video._hlsPlayer.startLoad();
|
|
video.muted = silent || slide._muted !== false;
|
|
const playPromise = video.play();
|
|
if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(() => {});
|
|
}
|
|
return;
|
|
}
|
|
|
|
// A slide whose formats haven't been resolved yet carries only a page
|
|
// URL, and handing that to /api/stream makes the backend re-run yt-dlp
|
|
// per request (slow, and a hard failure on some sites). Resolve once,
|
|
// then load for real -- `_awaitingFormats` keeps a failed resolve from
|
|
// looping, so we still fall back to the page URL as a last resort.
|
|
const meta = videoData && (videoData.meta || videoData);
|
|
const hasFormats = !!(meta && Array.isArray(meta.formats) && meta.formats.length);
|
|
if (!hasFormats && !slide._awaitingFormats && App.videos && typeof App.videos.ensureFormats === 'function') {
|
|
slide._awaitingFormats = true;
|
|
App.videos.ensureFormats(videoData).then(() => {
|
|
if (slide._videoData === videoData) loadSlideSource(slide, videoData, autoplay);
|
|
});
|
|
return;
|
|
}
|
|
slide.classList.add('is-loaded');
|
|
|
|
const resolved = slide._formatOverride
|
|
? App.videos.resolveSourceForFormat(videoData, slide._formatOverride)
|
|
: App.videos.resolveStreamSource(videoData, {
|
|
maxHeight: paneHeightCap(slide),
|
|
// Several panels at once is a decoder problem, not a picture
|
|
// problem: prefer a progressive file over HLS, H.264 over AV1,
|
|
// 30fps over 60.
|
|
cheapest: paneCount() > 1
|
|
});
|
|
if (!resolved || !resolved.url) {
|
|
// No playable source -- treat exactly like a load failure so the
|
|
// clip is dropped from the queue and the next one takes its place.
|
|
markSlideFailed(slide);
|
|
return;
|
|
}
|
|
// What's actually on screen, so the quality menu can tick it.
|
|
slide._activeUrl = resolved.url;
|
|
const streamUrl = App.videos.buildStreamUrlFromSource(resolved);
|
|
const isHls = App.videos.classifySource(resolved).isHls;
|
|
|
|
video.muted = silent || slide._muted !== false;
|
|
// The step being watched and the one right behind it buffer properly,
|
|
// so a swipe lands on video that is already there. Steps further ahead
|
|
// take their metadata only: enough to start without delay, without four
|
|
// more streams downloading in full behind them.
|
|
video.preload = (autoplay || (opts && opts.buffer)) ? 'auto' : 'metadata';
|
|
video._tearingDown = false;
|
|
applyResume(video, videoData && videoData.id, resolved.isLive);
|
|
|
|
const startPlay = () => {
|
|
if (!autoplay) return;
|
|
const playPromise = video.play();
|
|
if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(() => {});
|
|
};
|
|
|
|
const attachHls = (HlsLib) => {
|
|
const split = paneCount() > 1;
|
|
const hls = new HlsLib(split ? {
|
|
// Never fetch a rendition larger than the pane it draws into.
|
|
capLevelToPlayerSize: true,
|
|
// Several streams at once, each holding a minute of video, is
|
|
// memory and demuxing work for footage nobody has reached yet.
|
|
maxBufferLength: 10,
|
|
maxMaxBufferLength: 20,
|
|
backBufferLength: 10
|
|
} : {});
|
|
video._hlsPlayer = hls;
|
|
hls.loadSource(streamUrl);
|
|
hls.attachMedia(video);
|
|
if (!autoplay && split && !(opts && opts.buffer)) {
|
|
// Buffered enough to start instantly, then stopped: four
|
|
// panels' worth of hls.js all fetching and demuxing ahead is
|
|
// work for a step the reader has not swiped to yet. The step
|
|
// right behind the active one is exempt -- it is the one about
|
|
// to be swiped to, and maxBufferLength stops it on its own.
|
|
// loadSlideSource runs again with autoplay when they do.
|
|
hls.on(HlsLib.Events.FRAG_BUFFERED, function once() {
|
|
hls.off(HlsLib.Events.FRAG_BUFFERED, once);
|
|
if (video._hlsPlayer === hls) hls.stopLoad();
|
|
});
|
|
}
|
|
hls.on(HlsLib.Events.ERROR, (event, data) => {
|
|
if (data && data.fatal && video._hlsPlayer === hls) {
|
|
hls.destroy();
|
|
video._hlsPlayer = null;
|
|
// A fatal HLS error means the stream won't play: drop it.
|
|
markSlideFailed(slide);
|
|
}
|
|
});
|
|
startPlay();
|
|
};
|
|
|
|
const startNative = () => {
|
|
video.src = streamUrl;
|
|
startPlay();
|
|
};
|
|
|
|
if (!isHls) {
|
|
startNative();
|
|
return;
|
|
}
|
|
|
|
if (window.Hls && window.Hls.isSupported()) {
|
|
attachHls(window.Hls);
|
|
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
|
startNative();
|
|
} else {
|
|
// Lazy-load hls.js on demand for the first HLS slide.
|
|
App.ensureHls()
|
|
.then((HlsLib) => {
|
|
if (HlsLib && HlsLib.isSupported()) attachHls(HlsLib);
|
|
else startNative();
|
|
})
|
|
.catch(startNative);
|
|
}
|
|
};
|
|
|
|
// Builds (or returns) the .feed-slide element for loadedVideos[index] and
|
|
// inserts it into the DOM in index order, between the top spacer and the
|
|
// sentinel.
|
|
// ------------------------------------------------------------------
|
|
// Panes
|
|
//
|
|
// A step of the feed is one screenful, and it holds one or more panes. The
|
|
// arrangement is a tree: every pane can be split to the right or below,
|
|
// which replaces it with a two-child split, so any nesting is reachable.
|
|
// Leaves, read in order, are the panes of a step.
|
|
//
|
|
// Scrolling drives all of them at once: with N panes, step i shows videos
|
|
// i*N to i*N+N-1, and one swipe advances the whole set.
|
|
// ------------------------------------------------------------------
|
|
let paneTree = { type: 'pane' };
|
|
|
|
const paneLeaves = function(node, out) {
|
|
out = out || [];
|
|
if (node.type === 'pane') out.push(node);
|
|
else node.children.forEach((child) => paneLeaves(child, out));
|
|
return out;
|
|
};
|
|
|
|
// Panes per step, which is the stride between one swipe and the next.
|
|
const paneCount = function() {
|
|
return paneLeaves(paneTree).length;
|
|
};
|
|
|
|
App.feed.paneCount = paneCount;
|
|
|
|
// Replaces `target` with a split holding it and a new pane.
|
|
const splitPane = function(target, direction) {
|
|
const replace = function(node) {
|
|
if (node === target) {
|
|
return { type: 'split', dir: direction, children: [target, { type: 'pane' }] };
|
|
}
|
|
if (node.type === 'split') {
|
|
node.children = node.children.map(replace);
|
|
}
|
|
return node;
|
|
};
|
|
paneTree = replace(paneTree);
|
|
rebuildLayout();
|
|
};
|
|
|
|
// Drops a pane, collapsing the split that held it so no split is ever left
|
|
// with a single child.
|
|
const closePane = function(target) {
|
|
if (paneCount() <= 1) return;
|
|
const prune = function(node) {
|
|
if (node.type !== 'split') return node;
|
|
const kept = node.children.filter((child) => child !== target).map(prune);
|
|
return kept.length === 1 ? kept[0] : Object.assign(node, { children: kept });
|
|
};
|
|
paneTree = prune(paneTree);
|
|
rebuildLayout();
|
|
};
|
|
|
|
// The pane count is the stride, so changing it renumbers every step. Rebuild
|
|
// around whatever video is playing so the reader keeps their place.
|
|
const rebuildLayout = function() {
|
|
const keepId = state.feedActiveVideoId;
|
|
reserveSlides();
|
|
const videos = state.loadedVideos || [];
|
|
let videoIndex = 0;
|
|
if (keepId != null) {
|
|
const found = videos.findIndex((v) => String(v.id) === String(keepId));
|
|
if (found >= 0) videoIndex = found;
|
|
}
|
|
state.feedActiveIndex = -1;
|
|
const step = Math.floor(videoIndex / paneCount());
|
|
setActive(step);
|
|
releaseReserve();
|
|
const scroller = getScroller();
|
|
if (scroller) scroller.scrollTop = step * slideHeight();
|
|
};
|
|
|
|
// ------------------------------------------------------------------
|
|
// Reusing panes across a layout change
|
|
//
|
|
// Splitting or closing a panel changes the stride, so every step is
|
|
// renumbered and the slides have to be built again -- but the videos
|
|
// haven't changed. A pane already holds one: its <video> with the bytes it
|
|
// has buffered, where it had got to, which quality it was told to play.
|
|
// Building a fresh one throws all of that away and fetches it again, so
|
|
// the panes are set aside by video id first and whoever needs that video
|
|
// next takes the pane that already has it.
|
|
//
|
|
// Everything happens in one go, without yielding, so a pane never stops
|
|
// being in the document long enough to be paused for it.
|
|
// ------------------------------------------------------------------
|
|
const paneReserve = new Map();
|
|
|
|
const reserveSlides = function() {
|
|
slidesByIndex.forEach((slide) => {
|
|
panesOf(slide).forEach((pane) => {
|
|
const id = slideVideoId(pane);
|
|
const key = id == null ? null : String(id);
|
|
if (key !== null && !paneReserve.has(key)) {
|
|
paneReserve.set(key, pane);
|
|
if (pane.parentNode) pane.parentNode.removeChild(pane);
|
|
return;
|
|
}
|
|
teardownPane(pane); // a second pane on the same video
|
|
});
|
|
slide._panes = [];
|
|
slide.remove();
|
|
});
|
|
slidesByIndex.clear();
|
|
forgetPeeks();
|
|
};
|
|
|
|
// Whatever the new layout had no place for -- it is holding a stream open.
|
|
const releaseReserve = function() {
|
|
paneReserve.forEach(teardownPane);
|
|
paneReserve.clear();
|
|
};
|
|
|
|
// Everything one video needs on screen. Identical to what a slide used to
|
|
// hold: the per-video helpers below take any element shaped like this, so a
|
|
// pane and the old single-video slide are interchangeable to them.
|
|
const buildPane = function(v, index) {
|
|
const held = v && v.id != null ? paneReserve.get(String(v.id)) : null;
|
|
if (held) {
|
|
paneReserve.delete(String(v.id));
|
|
// The stride changed, so this video sits at a different place in
|
|
// the queue than it did; nothing else about the pane has moved.
|
|
held.dataset.index = String(index);
|
|
held._index = index;
|
|
held._videoData = v;
|
|
return held;
|
|
}
|
|
const pane = document.createElement('div');
|
|
pane.className = v.isLive ? 'feed-pane is-live' : 'feed-pane';
|
|
pane.dataset.videoId = v.id;
|
|
pane.dataset.index = String(index);
|
|
pane._videoData = v;
|
|
pane._index = index;
|
|
const uploaderText = v.uploader || '';
|
|
const liveBadge = v.isLive ? '<span class="live-badge feed-live-badge">● LIVE</span>' : '';
|
|
const favKey = App.favorites ? App.favorites.getKey(v) : null;
|
|
pane.innerHTML = `
|
|
<img class="feed-poster" alt="" loading="lazy" decoding="async">
|
|
<video class="feed-video" muted playsinline webkit-playsinline preload="none"></video>
|
|
${liveBadge}
|
|
${favKey ? `<button class="favorite-btn feed-fav-btn" type="button" data-fav-key="${favKey}" data-fav-url="${v.url || ''}"></button>` : ''}
|
|
<button class="cp-pip-btn feed-pip-btn" type="button" aria-label="Picture in picture" hidden>
|
|
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/arrow-top-right-on-square.svg" alt="">
|
|
</button>
|
|
<button class="cp-format-btn feed-format-btn" type="button" aria-label="Quality" hidden>HD</button>
|
|
<div class="cp-format-menu feed-format-menu" role="menu" hidden></div>
|
|
<div class="feed-pane-tools">
|
|
<button class="feed-pane-btn feed-pane-mute" type="button" aria-label="Mute this panel"></button>
|
|
<button class="feed-pane-btn feed-pane-split-right" type="button" title="Add a panel to the right" aria-label="Add a panel to the right">◫</button>
|
|
<button class="feed-pane-btn feed-pane-split-down" type="button" title="Add a panel below" aria-label="Add a panel below">⊟</button>
|
|
<button class="feed-pane-btn feed-pane-close" type="button" title="Close this panel" aria-label="Close this panel" hidden>✕</button>
|
|
</div>
|
|
<div class="cp-flash feed-flash" aria-hidden="true"></div>
|
|
<div class="feed-info">
|
|
<h4 class="feed-title"><span class="feed-title-text">${v.title || ''}</span></h4>
|
|
${uploaderText ? `<p class="feed-uploader">${uploaderText}</p>` : ''}
|
|
</div>
|
|
<div class="feed-timeline" role="slider" aria-label="Seek">
|
|
<div class="feed-timeline-track">
|
|
<div class="feed-timeline-fill"></div>
|
|
<div class="feed-timeline-handle"></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
const poster = pane.querySelector('.feed-poster');
|
|
App.videos.attachThumbnail(poster, v.thumb, App.videos.sourcePageUrl(v));
|
|
const slideVideo = pane.querySelector('.feed-video');
|
|
bindTimeline(pane, slideVideo);
|
|
bindSharedControls(pane, slideVideo, v);
|
|
|
|
// A media error (bad/expired source, network failure, unsupported codec)
|
|
// means this clip can't play -- drop it from the queue. Errors fired by
|
|
// our own teardown (src cleared) carry the _tearingDown flag and are
|
|
// ignored inside markSlideFailed.
|
|
slideVideo.addEventListener('error', () => markSlideFailed(pane));
|
|
|
|
// On video end, either loop (handled by the `loop` flag, so `ended`
|
|
// never fires) or auto-scroll to the next clip. We only advance for the
|
|
// active slide so a preloaded neighbour ending early can't hijack focus.
|
|
slideVideo.loop = shouldLoop();
|
|
slideVideo.addEventListener('ended', () => {
|
|
if (shouldLoop()) return;
|
|
// Only the active step advances, and only from its first pane, so a
|
|
// short clip in one panel can't drag the others along with it.
|
|
const slide = pane.closest('.feed-slide');
|
|
if (!slide || !slide.classList.contains('is-active')) return;
|
|
if (slide._panes && slide._panes[0] !== pane) return;
|
|
advanceToNext(slide._step);
|
|
});
|
|
|
|
const favBtn = pane.querySelector('.feed-fav-btn');
|
|
if (favBtn && App.favorites) {
|
|
App.favorites.setButtonState(favBtn, App.favorites.has(v));
|
|
favBtn.addEventListener('click', (event) => {
|
|
event.stopPropagation();
|
|
App.favorites.toggle(v);
|
|
});
|
|
}
|
|
|
|
// Each panel owns its own sound, so two can play at once if that is
|
|
// what the reader wants. A new panel inherits whatever the feed-wide
|
|
// control is set to, so a step built later doesn't disagree with the
|
|
// ones already on screen -- which does mean splitting while unmuted
|
|
// gives you two audio tracks.
|
|
const muteBtn = pane.querySelector('.feed-pane-mute');
|
|
const syncMute = () => {
|
|
slideVideo.muted = pane._muted;
|
|
muteBtn.textContent = pane._muted ? '🔇' : '🔊';
|
|
muteBtn.setAttribute('aria-label', pane._muted ? 'Unmute this panel' : 'Mute this panel');
|
|
muteBtn.classList.toggle('is-muted', !!pane._muted);
|
|
};
|
|
pane._muted = state.feedMuted !== false;
|
|
pane._syncMute = syncMute;
|
|
syncMute();
|
|
muteBtn.addEventListener('click', (event) => {
|
|
event.stopPropagation();
|
|
pane._muted = !pane._muted;
|
|
syncMute();
|
|
// Asking for this panel's sound gives up another's, where only one
|
|
// panel may have any -- and the one that lost it may have been
|
|
// stopped by the system on the way, so it is started again.
|
|
limitAudibleToOne(pane);
|
|
resumeActivePanes();
|
|
refreshFeedMuteState();
|
|
// The panel you can hear is the one that should follow you out.
|
|
updateAutoPiPTarget();
|
|
});
|
|
|
|
return pane;
|
|
};
|
|
|
|
// The feed-wide button reads as muted only while every panel is, so it
|
|
// can't claim silence over a panel someone unmuted by hand.
|
|
const refreshFeedMuteState = function() {
|
|
let anyAudible = false;
|
|
slidesByIndex.forEach((slide) => {
|
|
panesOf(slide).forEach((pane) => { if (!pane._muted) anyAudible = true; });
|
|
});
|
|
state.feedMuted = !anyAudible;
|
|
App.feed.updateMuteButton();
|
|
};
|
|
|
|
// Renders the layout tree into elements, handing each leaf the next video.
|
|
const renderPaneTree = function(node, videos, cursor) {
|
|
if (node.type === 'pane') {
|
|
const v = videos[cursor.next++];
|
|
if (!v) return null;
|
|
const pane = buildPane(v, cursor.next - 1);
|
|
pane._node = node;
|
|
bindPaneTools(pane, node);
|
|
return pane;
|
|
}
|
|
const split = document.createElement('div');
|
|
split.className = 'feed-split ' + (node.dir === 'row' ? 'is-row' : 'is-col');
|
|
node.children.forEach((child) => {
|
|
const el = renderPaneTree(child, videos, cursor);
|
|
if (el) split.appendChild(el);
|
|
});
|
|
return split.childElementCount ? split : null;
|
|
};
|
|
|
|
// Rebindable, because a pane outlives the tree it was built for: a reused
|
|
// pane comes back under a different node, and its old buttons would still
|
|
// be splitting and closing the one it left.
|
|
const bindPaneTools = function(pane, node) {
|
|
if (Array.isArray(pane._toolCleanups)) {
|
|
pane._toolCleanups.forEach((fn) => { try { fn(); } catch (err) { /* ignore */ } });
|
|
}
|
|
const cleanups = [];
|
|
const on = function(selector, handler) {
|
|
const el = pane.querySelector(selector);
|
|
if (!el) return el;
|
|
el.addEventListener('click', handler);
|
|
cleanups.push(() => el.removeEventListener('click', handler));
|
|
return el;
|
|
};
|
|
const closeBtn = pane.querySelector('.feed-pane-close');
|
|
closeBtn.hidden = paneCount() <= 1;
|
|
on('.feed-pane-split-right', (event) => {
|
|
event.stopPropagation();
|
|
splitPane(node, 'row');
|
|
});
|
|
on('.feed-pane-split-down', (event) => {
|
|
event.stopPropagation();
|
|
splitPane(node, 'col');
|
|
});
|
|
on('.feed-pane-close', (event) => {
|
|
event.stopPropagation();
|
|
closePane(node);
|
|
});
|
|
pane._toolCleanups = cleanups;
|
|
};
|
|
|
|
// One screenful: the pane tree, filled with this step's run of videos.
|
|
const createSlide = function(step) {
|
|
if (slidesByIndex.has(step)) return slidesByIndex.get(step);
|
|
const videos = state.loadedVideos || [];
|
|
const per = paneCount();
|
|
const first = step * per;
|
|
if (!videos[first]) return null;
|
|
const scroller = getScroller();
|
|
if (!scroller) return null;
|
|
|
|
const slide = document.createElement('div');
|
|
slide.className = 'feed-slide';
|
|
slide.dataset.step = String(step);
|
|
slide._step = step;
|
|
const cursor = { next: first };
|
|
const tree = renderPaneTree(paneTree, videos, cursor);
|
|
if (!tree) return null;
|
|
slide.appendChild(tree);
|
|
slide._panes = Array.from(slide.querySelectorAll('.feed-pane'));
|
|
|
|
// Insert before the rendered slide with the next-highest step so DOM
|
|
// order always matches step order; fall back to the sentinel.
|
|
let ref = getSentinel();
|
|
let refStep = Infinity;
|
|
slidesByIndex.forEach((el, i) => {
|
|
if (i > step && i < refStep) {
|
|
refStep = i;
|
|
ref = el;
|
|
}
|
|
});
|
|
scroller.insertBefore(slide, ref);
|
|
slidesByIndex.set(step, slide);
|
|
return slide;
|
|
};
|
|
|
|
// Tears down everything a slide holds -- playback (video/hls) plus the
|
|
// shared skip/format/PiP/gesture bindings from bindSharedControls -- but
|
|
// does not remove it from the DOM or from slidesByIndex (callers differ
|
|
// on that: removeSlide always does, reset() removes the whole tree at
|
|
// once).
|
|
// A slide is now a container: everything below that used to act on one
|
|
// video acts on one pane, and the slide fans out over its panes.
|
|
const panesOf = function(slide) {
|
|
return (slide && slide._panes) || [];
|
|
};
|
|
|
|
const teardownPane = function(pane) {
|
|
destroySlidePlayback(pane);
|
|
if (Array.isArray(pane._sharedControlCleanups)) {
|
|
pane._sharedControlCleanups.forEach((fn) => { try { fn(); } catch (err) { /* ignore */ } });
|
|
pane._sharedControlCleanups = null;
|
|
}
|
|
};
|
|
|
|
const teardownSlide = function(slide) {
|
|
panesOf(slide).forEach(teardownPane);
|
|
};
|
|
|
|
const removeSlide = function(index) {
|
|
const slide = slidesByIndex.get(index);
|
|
if (!slide) return;
|
|
// Removing the element in the picture-in-picture window closes it.
|
|
if (pipPinned(slide)) return;
|
|
teardownSlide(slide);
|
|
slide.remove();
|
|
slidesByIndex.delete(index);
|
|
};
|
|
|
|
// Drops a video that failed to load/resolve from the queue and pulls the
|
|
// next clip into its place.
|
|
//
|
|
// Removing a video renumbers everything after it, and with panes a step is
|
|
// a run of videos rather than one -- so the steps from the hole onwards
|
|
// have to be rebuilt. The steps *before* it keep their videos, which is
|
|
// what lets a failed preload neighbour leave the active panes playing
|
|
// untouched; only a failure at or before what's on screen forces the
|
|
// active step to be rebuilt with it.
|
|
const removeVideoFromQueue = function(videoId) {
|
|
const videos = state.loadedVideos || [];
|
|
const r = videos.findIndex((v) => String(v.id) === String(videoId));
|
|
if (r < 0) return;
|
|
|
|
// Which video to stay with afterwards: the one that slides into the
|
|
// failed clip's place, or the last one if it was at the end.
|
|
const keep = videos[r + 1] || videos[r - 1] || null;
|
|
videos.splice(r, 1);
|
|
|
|
// Remove the failed clip's grid card element from the DOM too (the grid
|
|
// shares the queue) and re-pack the remaining cards.
|
|
if (App.virtualGrid && typeof App.virtualGrid.removeVideo === 'function') {
|
|
App.virtualGrid.removeVideo(videoId);
|
|
}
|
|
|
|
if (videos.length === 0) {
|
|
App.feed.close();
|
|
return;
|
|
}
|
|
|
|
const per = paneCount();
|
|
const activeLast = (state.feedActiveIndex + 1) * per - 1;
|
|
if (state.feedActiveIndex >= 0 && r > activeLast) {
|
|
// The hole is past everything on screen: drop the steps from it
|
|
// onwards and let the window rebuild them, leaving the active panes
|
|
// mid-playback exactly as they are.
|
|
const fromStep = Math.floor(r / per);
|
|
const stale = [];
|
|
slidesByIndex.forEach((slide, i) => { if (i >= fromStep) stale.push(i); });
|
|
stale.forEach(removeSlide);
|
|
syncWindow(state.feedActiveIndex);
|
|
return;
|
|
}
|
|
|
|
state.feedActiveVideoId = keep ? keep.id : null;
|
|
rebuildLayout();
|
|
};
|
|
|
|
// Flags a slide whose video failed and schedules its removal from the queue.
|
|
// Deferred to a macrotask so we never mutate slidesByIndex while setActive /
|
|
// syncWindow is mid-iteration over it. Teardown-induced errors (src cleared)
|
|
// are ignored via the video's _tearingDown flag, and we only act while the
|
|
// feed is open so late errors after close are harmless.
|
|
const markSlideFailed = function(pane) {
|
|
if (!pane || pane._failed || !state.feedOpen) return;
|
|
const video = pane.querySelector('.feed-video');
|
|
if (video && video._tearingDown) return;
|
|
const id = slideVideoId(pane);
|
|
if (id == null) return;
|
|
pane._failed = true;
|
|
setTimeout(() => removeVideoFromQueue(id), 0);
|
|
};
|
|
|
|
// Brings the rendered window in line with the active index: drops slides
|
|
// that fell outside [active - HISTORY_COUNT, active + RENDER_AHEAD], builds
|
|
// any missing ones inside it, and sizes the top spacer to stand in for the
|
|
// slides above the window.
|
|
// Which steps are materialised. Behind, the window narrows as panes are
|
|
// added -- history is the cheap thing to give up. Ahead it does not: what
|
|
// is preloaded has to be in the document to be preloaded at all, so the
|
|
// window always reaches at least PRELOAD_STEPS forward however many panels
|
|
// there are. A four-way split therefore holds four times as many elements
|
|
// as a single panel, which is the price of four panels never waiting.
|
|
//
|
|
// The top spacer stands in for the steps below `start`, so anything that
|
|
// sizes the spacer has to agree with this exactly -- disagreeing by a step
|
|
// shifts every rendered slide and lands the reader on the wrong videos.
|
|
const windowBounds = function(activeIndex) {
|
|
const per = paneCount();
|
|
// The floor of one step is not a rounding guard: it is what guarantees
|
|
// the step after this one always exists, however many panes there are.
|
|
// Every panel's next video lives in that step, so lowering it below one
|
|
// would leave a panel with nothing buffered to swipe to.
|
|
return {
|
|
start: Math.max(0, activeIndex - Math.max(1, Math.round(HISTORY_COUNT / per))),
|
|
end: Math.min(stepCount() - 1,
|
|
activeIndex + Math.max(PRELOAD_STEPS, Math.round(RENDER_AHEAD / per)))
|
|
};
|
|
};
|
|
|
|
const syncWindow = function(activeIndex) {
|
|
if (stepCount() === 0) return;
|
|
const { start, end } = windowBounds(activeIndex);
|
|
const per = paneCount();
|
|
const videos = state.loadedVideos || [];
|
|
|
|
slidesByIndex.forEach((slide, i) => {
|
|
if (i < start || i > end) {
|
|
removeSlide(i);
|
|
return;
|
|
}
|
|
// A step built before all of its videos had arrived is short a pane
|
|
// or more. Once the rest land it has to be rebuilt, or the videos
|
|
// that would have filled it are skipped for good: the next step
|
|
// starts past them.
|
|
const have = (slide._panes || []).length;
|
|
if (have < per && videos[i * per + have]) removeSlide(i);
|
|
});
|
|
for (let i = start; i <= end; i++) {
|
|
if (!slidesByIndex.has(i)) createSlide(i);
|
|
}
|
|
|
|
const spacer = getTopSpacer();
|
|
if (spacer) spacer.style.height = `${start * slideHeight()}px`;
|
|
};
|
|
|
|
// The videos the next few swipes will want, resolved now and all at once.
|
|
// Resolving is a round trip per video (yt-dlp, on the backend), and leaving
|
|
// it until load time put that round trip between a panel and its first
|
|
// frame -- with four panels, four of them, arriving one after another. They
|
|
// are deduped per video, so asking early costs nothing but the request, and
|
|
// asking for all of them at once is the point: they run in parallel.
|
|
const warmFormats = function(activeIndex) {
|
|
if (!App.videos || typeof App.videos.ensureFormats !== 'function') return;
|
|
const videos = state.loadedVideos || [];
|
|
const per = paneCount();
|
|
const from = Math.max(0, activeIndex * per);
|
|
const to = Math.min(videos.length, from + per * (PRELOAD_STEPS + 1));
|
|
for (let i = from; i < to; i++) {
|
|
if (videos[i]) App.videos.ensureFormats(videos[i]);
|
|
}
|
|
};
|
|
|
|
// Keeps the JSON buffer ahead of what the feed is about to eat. A step of N
|
|
// panels consumes N videos per swipe, so the same page of listing goes N
|
|
// times as fast -- the buffer it needs, and the number of pages worth having
|
|
// on the wire at once, both scale with the split.
|
|
const prefetchIfNeeded = function(activeIndex) {
|
|
const per = paneCount();
|
|
const perPage = state.perPage || 12;
|
|
const total = (state.loadedVideos || []).length;
|
|
const bufferAhead = total - 1 - ((activeIndex + 1) * per - 1);
|
|
// Everything the rendered window will reach into, plus room to grow.
|
|
const needed = Math.max(PREFETCH_PAGES * perPage, per * (PRELOAD_STEPS + 2));
|
|
if (App.videos.setPrefetchDepth) {
|
|
App.videos.setPrefetchDepth(Math.ceil(needed / perPage));
|
|
}
|
|
if (bufferAhead < needed && state.hasNextPage && !state.isLoading) {
|
|
// The feed is its own reader: the grid's scroll position says
|
|
// nothing about whether it needs the next page.
|
|
App.videos.loadVideos({ force: true });
|
|
}
|
|
};
|
|
|
|
// Promotes the slide at `index` to active: syncs the window, updates the
|
|
// active styling, plays it, preloads the next PRELOAD_STEPS, and tears down
|
|
// playback for everything else in the window.
|
|
const setActive = function(index) {
|
|
const clamped = clampIndex(index);
|
|
if (clamped < 0) return;
|
|
state.feedActiveIndex = clamped;
|
|
const activeVideo = (state.loadedVideos || [])[clamped * paneCount()];
|
|
state.feedActiveVideoId = activeVideo ? activeVideo.id : null;
|
|
|
|
syncWindow(clamped);
|
|
|
|
slidesByIndex.forEach((slide, i) => {
|
|
slide.classList.toggle('is-active', i === clamped);
|
|
});
|
|
|
|
const activeSlide = slidesByIndex.get(clamped);
|
|
if (activeSlide) {
|
|
// Settled before anything starts: a second panel starting with
|
|
// sound is exactly what stops the first one (see audioIsExclusive).
|
|
limitAudibleToOne();
|
|
panesOf(activeSlide).forEach((pane) => {
|
|
loadSlideSource(pane, pane._videoData, true);
|
|
requestAnimationFrame(() => measureFeedTitle(pane));
|
|
});
|
|
}
|
|
|
|
slidesByIndex.forEach((slide, i) => {
|
|
if (i === clamped) return;
|
|
const ahead = i - clamped;
|
|
panesOf(slide).forEach((pane) => {
|
|
if (ahead > 0 && ahead <= PRELOAD_STEPS) {
|
|
// Every panel of the step, started together: what used to
|
|
// make them start one after another is that only the first
|
|
// step ahead was loaded, so each panel did its resolving
|
|
// and buffering at the moment it was swiped to.
|
|
loadSlideSource(pane, pane._videoData, false, { buffer: ahead <= BUFFER_STEPS });
|
|
} else if (i >= clamped - KEEP_BEHIND && i < clamped) {
|
|
// Recently-watched panes stay loaded but paused so scrolling
|
|
// back resumes seamlessly from where it was paused.
|
|
if (pane.classList.contains('is-loaded')) pauseSlide(pane);
|
|
} else if (pane.classList.contains('is-loaded') && pane !== pipPane) {
|
|
destroySlidePlayback(pane);
|
|
}
|
|
});
|
|
});
|
|
|
|
updateAutoPiPTarget();
|
|
warmFormats(clamped);
|
|
prefetchIfNeeded(clamped);
|
|
};
|
|
|
|
// ------------------------------------------------------------------
|
|
// Peeking
|
|
//
|
|
// A step used to start playing when the swipe was already half done --
|
|
// `Math.round` of the scroll position -- and then still had to load. So
|
|
// the video slid into view as a still picture and began some time after
|
|
// it arrived, which is most of the waiting in a reel.
|
|
//
|
|
// Now the step coming into view starts as soon as any of it is showing,
|
|
// silently, while the one being left plays on. A swipe that is carried
|
|
// through therefore lands on a video that is already running; one that is
|
|
// abandoned leaves nothing behind -- the step that was peeked at is
|
|
// stopped and wound back, so the next swipe to it starts it from the top
|
|
// rather than from the second and a half nobody watched.
|
|
// ------------------------------------------------------------------
|
|
const PEEK_MIN_PX = 8; // less than this is a twitch, not a swipe
|
|
const PEEK_SETTLE_MS = 140; // quiet for this long means the scroll is over
|
|
const peeked = new Set(); // step elements started by a peek
|
|
let peekSettleTimer = null;
|
|
|
|
const startPeek = function(index) {
|
|
const slide = slidesByIndex.get(index);
|
|
if (!slide || peeked.has(slide)) return;
|
|
peeked.add(slide);
|
|
panesOf(slide).forEach((pane) => {
|
|
const video = pane.querySelector('.feed-video');
|
|
const id = slideVideoId(pane);
|
|
// Where this panel stood before anyone looked at it, so an
|
|
// abandoned swipe can put it back exactly there: the start for a
|
|
// step never watched, and where it was left for one scrolled past.
|
|
pane._peekFrom = (video && video.currentTime) ||
|
|
(id != null && resumeTimes.get(id)) || 0;
|
|
loadSlideSource(pane, pane._videoData, true, { silent: true });
|
|
});
|
|
};
|
|
|
|
// Stops a panel and puts it back where the peek found it -- the opposite
|
|
// of pauseSlide, which keeps where it got to on purpose.
|
|
const rewindPane = function(pane) {
|
|
const video = pane.querySelector('.feed-video');
|
|
const back = pane._peekFrom || 0;
|
|
pane.classList.remove('is-active');
|
|
if (video) {
|
|
if (!video.paused) video.pause();
|
|
try { video.currentTime = back; } catch (err) { /* not seekable yet */ }
|
|
}
|
|
const id = slideVideoId(pane);
|
|
if (id != null) {
|
|
if (back > 0) resumeTimes.set(id, back);
|
|
else resumeTimes.delete(id);
|
|
}
|
|
pane._peekFrom = 0;
|
|
};
|
|
|
|
// The scroll has stopped somewhere: whatever was peeked at and not arrived
|
|
// at goes back to where it started.
|
|
//
|
|
// "Stopped" has to mean stopped *on* a step. Scroll events also stop while
|
|
// a finger holds the list still between two of them -- the swipe is then
|
|
// neither finished nor abandoned, and winding the incoming step back under
|
|
// a held finger is precisely the stall this is here to remove.
|
|
const settlePeek = function() {
|
|
peekSettleTimer = null;
|
|
if (!peeked.size) return;
|
|
const scroller = getScroller();
|
|
const height = slideHeight();
|
|
if (scroller && height) {
|
|
const offset = scroller.scrollTop % height;
|
|
if (offset > PEEK_MIN_PX && offset < height - PEEK_MIN_PX) return;
|
|
}
|
|
const active = slidesByIndex.get(state.feedActiveIndex);
|
|
peeked.forEach((slide) => {
|
|
if (slide === active) return;
|
|
panesOf(slide).forEach(rewindPane);
|
|
});
|
|
peeked.clear();
|
|
};
|
|
|
|
// Nothing to put back once the steps themselves are gone.
|
|
const forgetPeeks = function() {
|
|
if (peekSettleTimer) {
|
|
clearTimeout(peekSettleTimer);
|
|
peekSettleTimer = null;
|
|
}
|
|
peeked.clear();
|
|
};
|
|
|
|
App.feed._settlePeek = settlePeek; // tests drive the settle directly
|
|
|
|
const peekIncoming = function(scroller) {
|
|
const height = slideHeight();
|
|
if (!height) return;
|
|
const first = Math.floor(scroller.scrollTop / height);
|
|
const offset = scroller.scrollTop - first * height;
|
|
if (offset < PEEK_MIN_PX) return; // nothing else is showing yet
|
|
// Two steps are on screen; the one that isn't being watched is the one
|
|
// arriving, whichever direction the swipe is going.
|
|
[first, first + 1].forEach((index) => {
|
|
if (index === state.feedActiveIndex) return;
|
|
if (index < 0 || index >= stepCount()) return;
|
|
startPeek(index);
|
|
});
|
|
};
|
|
|
|
const onScroll = function() {
|
|
// A turn's re-anchor arrives as a scroll event carrying a position
|
|
// measured against the height from before it. Putting it back here,
|
|
// in the event, is what keeps that position from being painted --
|
|
// waiting for the next frame means showing it first.
|
|
if (suppressScroll) {
|
|
realignToActive();
|
|
return;
|
|
}
|
|
if (scrollRaf) return;
|
|
scrollRaf = requestAnimationFrame(() => {
|
|
scrollRaf = null;
|
|
const scroller = getScroller();
|
|
if (!scroller) return;
|
|
// Ignore scroll events fired by a resize/orientation re-snap; the
|
|
// active video is realigned by onResize instead (see suppressScroll).
|
|
if (suppressScroll) return;
|
|
sleepHud();
|
|
peekIncoming(scroller);
|
|
if (peekSettleTimer) clearTimeout(peekSettleTimer);
|
|
peekSettleTimer = setTimeout(settlePeek, PEEK_SETTLE_MS);
|
|
const index = clampIndex(Math.round(scroller.scrollTop / slideHeight()));
|
|
if (index < 0) return;
|
|
if (index !== state.feedActiveIndex) {
|
|
setActive(index);
|
|
}
|
|
});
|
|
};
|
|
|
|
const viewportShape = function() {
|
|
const scroller = getScroller();
|
|
const view = feedDocument().defaultView || window;
|
|
return {
|
|
w: (scroller && scroller.clientWidth) || view.innerWidth || 1,
|
|
h: (scroller && scroller.clientHeight) || view.innerHeight || 1
|
|
};
|
|
};
|
|
|
|
// What a wrist did, as against what the browser's chrome did: the device
|
|
// said so, or the viewport swapped its long side for its short one. A
|
|
// viewport that only changes height -- chrome sliding away, a keyboard
|
|
// coming up -- is not a turn and is not worth a fade.
|
|
const isTurn = function(before, now) {
|
|
if (Date.now() - turnSignalAt < TURN_SIGNAL_MS) return true;
|
|
if (!before) return false;
|
|
return (before.w > before.h) !== (now.w > now.h);
|
|
};
|
|
|
|
// Holds the slides at `shape` -- the size they have as the turn starts --
|
|
// until the viewport has finished moving. Where a turn arrives in one
|
|
// step, which is most of them, this lasts a few frames and changes
|
|
// nothing anybody sees.
|
|
const pinTurn = function(shape) {
|
|
turnPin = { w: shape.w, h: shape.h };
|
|
const root = getRoot();
|
|
if (!root) return;
|
|
root.style.setProperty('--turn-w', `${turnPin.w}px`);
|
|
root.style.setProperty('--turn-h', `${turnPin.h}px`);
|
|
root.classList.add('is-turning');
|
|
};
|
|
|
|
const releaseTurn = function() {
|
|
if (!turnPin) return;
|
|
turnPin = null;
|
|
const root = getRoot();
|
|
if (!root) return;
|
|
root.classList.remove('is-turning');
|
|
root.style.removeProperty('--turn-w');
|
|
root.style.removeProperty('--turn-h');
|
|
};
|
|
|
|
// Re-anchors the scroll position on the currently active video after the
|
|
// viewport changes. The active slide is resolved by id (not by a possibly
|
|
// stale scroll position) so an orientation change always keeps the same
|
|
// video playing/focused rather than snapping to a neighbour.
|
|
const realignToActive = function() {
|
|
if (stepCount() === 0) return;
|
|
let index = state.feedActiveIndex;
|
|
if (state.feedActiveVideoId != null) {
|
|
const found = (state.loadedVideos || [])
|
|
.findIndex((v) => String(v.id) === String(state.feedActiveVideoId));
|
|
if (found >= 0) index = Math.floor(found / paneCount());
|
|
}
|
|
index = clampIndex(index);
|
|
if (index < 0) return;
|
|
state.feedActiveIndex = index;
|
|
const h = slideHeight();
|
|
const start = windowBounds(index).start;
|
|
// Only write what actually differs. This runs on every frame of a
|
|
// turn, and a spacer height or a scrollTop written back unchanged
|
|
// still costs a layout and still sets the snap container off again --
|
|
// the page fighting itself at the moment it is busiest.
|
|
const spacer = getTopSpacer();
|
|
const spacerHeight = `${start * h}px`;
|
|
if (spacer && spacer.style.height !== spacerHeight) spacer.style.height = spacerHeight;
|
|
const scroller = getScroller();
|
|
const top = index * h;
|
|
if (scroller && Math.abs(scroller.scrollTop - top) > 0.5) scroller.scrollTop = top;
|
|
const activeSlide = slidesByIndex.get(index);
|
|
// The title only needs re-measuring when the screen it has to fit in
|
|
// changed size, not sixty times while it doesn't.
|
|
if (activeSlide && h !== lastMeasuredHeight) {
|
|
lastMeasuredHeight = h;
|
|
measureFeedTitle(activeSlide);
|
|
}
|
|
};
|
|
|
|
// Ends the realign window: one last realign, then real swipes count again.
|
|
const finishSettle = function() {
|
|
if (resizeSettleRaf) {
|
|
cancelAnimationFrame(resizeSettleRaf);
|
|
resizeSettleRaf = null;
|
|
}
|
|
if (resizeSettleTimer) {
|
|
clearTimeout(resizeSettleTimer);
|
|
resizeSettleTimer = null;
|
|
}
|
|
releaseTurn();
|
|
realignToActive();
|
|
suppressScroll = false;
|
|
lastShape = viewportShape();
|
|
const after = settleDone;
|
|
settleDone = null;
|
|
if (after) after();
|
|
};
|
|
|
|
// Watches the viewport settle, frame by frame, holding the active video in
|
|
// place the whole way.
|
|
const settleStep = function() {
|
|
resizeSettleRaf = null;
|
|
if (!state.feedOpen || state.feedActiveIndex < 0) {
|
|
suppressScroll = false;
|
|
return;
|
|
}
|
|
// The viewport's own height, not the pinned one: the pin is a fixed
|
|
// number and would look settled from the first frame.
|
|
const shape = viewportShape();
|
|
if (shape.h === settleHeight) settleStableFrames += 1;
|
|
else {
|
|
settleHeight = shape.h;
|
|
settleStableFrames = 0;
|
|
}
|
|
// A turn arrives in several steps, and the first of them can be small
|
|
// enough to pass for the chrome sliding; it is the shape it ends up in
|
|
// that says what it was.
|
|
if (!turnPin && !turnResolved && isTurn(settleFromShape, shape)) {
|
|
pinTurn(settleFromShape);
|
|
}
|
|
// The viewport has stopped moving, so what it is now is better than
|
|
// what the pin guessed it would be. Letting go here, with the realign
|
|
// below in the same frame, is the one step the picture takes.
|
|
if (turnPin && settleStableFrames >= TURN_HOLD_FRAMES) {
|
|
releaseTurn();
|
|
turnResolved = true;
|
|
}
|
|
realignToActive();
|
|
if (settleStableFrames < SETTLE_STABLE_FRAMES &&
|
|
(Date.now() - settleStartedAt) < SETTLE_MAX_MS) {
|
|
resizeSettleRaf = requestAnimationFrame(settleStep);
|
|
return;
|
|
}
|
|
// One more frame, so a re-snap the container has already scheduled
|
|
// lands inside the window rather than just after it.
|
|
resizeSettleRaf = requestAnimationFrame(finishSettle);
|
|
};
|
|
|
|
// The device announcing the turn: dim first, measure after. Nothing about
|
|
// the viewport has changed yet at this point, so the reel is already on
|
|
// its way out when the first new shape lands.
|
|
const onOrientation = function() {
|
|
turnSignalAt = Date.now();
|
|
turnResolved = false;
|
|
if (state.feedOpen && state.feedActiveIndex >= 0 && !turnPin) {
|
|
pinTurn(viewportShape());
|
|
}
|
|
onResize();
|
|
};
|
|
|
|
const onResize = function() {
|
|
if (!state.feedOpen || state.feedActiveIndex < 0) return;
|
|
// Suppress scroll handling while we realign so the container's re-snap
|
|
// doesn't flip the active video, then re-enable it once layout settles.
|
|
suppressScroll = true;
|
|
// The shape to compare against is the one from before the turn
|
|
// started, not the one from the previous step of it.
|
|
if (!resizeSettleTimer) {
|
|
settleFromShape = lastShape || viewportShape();
|
|
turnResolved = false;
|
|
}
|
|
settleStartedAt = Date.now();
|
|
settleStableFrames = 0;
|
|
settleHeight = -1;
|
|
lastMeasuredHeight = -1;
|
|
if (!turnPin && !turnResolved && isTurn(settleFromShape, viewportShape())) {
|
|
pinTurn(settleFromShape);
|
|
}
|
|
realignToActive();
|
|
if (resizeSettleRaf) cancelAnimationFrame(resizeSettleRaf);
|
|
resizeSettleRaf = requestAnimationFrame(settleStep);
|
|
// Frames stop in a hidden tab, which would leave the window open for
|
|
// as long as the tab stays away; the clock closes it regardless.
|
|
if (resizeSettleTimer) clearTimeout(resizeSettleTimer);
|
|
resizeSettleTimer = setTimeout(finishSettle, SETTLE_MAX_MS + 500);
|
|
};
|
|
|
|
// ------------------------------------------------------------------
|
|
// Picture-in-picture that can be moved through
|
|
//
|
|
// This is the fallback path. Where document picture-in-picture exists (see
|
|
// openDocPip below) the feed itself goes into the window and scrolls; this
|
|
// is what is left when it doesn't -- and it is still the only path the
|
|
// browser can take on its own when the tab is hidden, since opening a
|
|
// document window needs a user gesture and a tab switch has none.
|
|
//
|
|
// 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, App.videos.sourcePageUrl(v));
|
|
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 && App.customPlayer.pipElement()) 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 (!App.customPlayer.supportsPiP()) 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;
|
|
return App.customPlayer.enterPiP(video);
|
|
};
|
|
|
|
// ------------------------------------------------------------------
|
|
// A picture-in-picture window you can actually scroll
|
|
//
|
|
// Everything above works around the fact that a video picture-in-picture
|
|
// window renders frames and nothing else -- no markup, so no scrolling, and
|
|
// moving through the reel has to be smuggled in through media keys.
|
|
//
|
|
// Document picture-in-picture lifts that restriction: it opens an empty
|
|
// always-on-top window with a real document in it. So instead of rebuilding
|
|
// the feed there, the feed is *moved* there -- #feed-view and its whole
|
|
// subtree are appended to the new window's body. Nothing is copied and no
|
|
// <video> is re-created, so playback continues across the move and the
|
|
// scroll-snap list, the panes, the split tree and every control keep
|
|
// working; they are the same elements, just in another window. Scrolling
|
|
// the window scrolls the reel, because it is the reel.
|
|
//
|
|
// The lookups in this file go through getRoot() rather than
|
|
// document.getElementById for exactly this reason: after the move the feed
|
|
// is no longer in this document.
|
|
//
|
|
// Chrome-only for now (Firefox and Safari have no documentPictureInPicture),
|
|
// and it needs a user gesture, so the tab-switch path stays with the video
|
|
// window above. Closing the window moves the feed back.
|
|
// ------------------------------------------------------------------
|
|
let pipWindow = null;
|
|
|
|
const docPipSupported = function() {
|
|
return !!(window.documentPictureInPicture && window.documentPictureInPicture.requestWindow);
|
|
};
|
|
|
|
App.feed.docPipSupported = docPipSupported;
|
|
App.feed.docPipOpen = () => !!pipWindow;
|
|
|
|
// The window starts as a blank document with no styles at all. Cloning this
|
|
// page's <link>s and <style>s is what makes the moved feed look like itself;
|
|
// the hrefs read off the property are already absolute.
|
|
const adoptStyles = function(win) {
|
|
document.querySelectorAll('link[rel="stylesheet"], style').forEach((node) => {
|
|
win.document.head.appendChild(node.cloneNode(true));
|
|
});
|
|
const base = win.document.createElement('style');
|
|
base.textContent = 'html,body{margin:0;padding:0;height:100%;overflow:hidden;background:#000;}';
|
|
win.document.head.appendChild(base);
|
|
};
|
|
|
|
// Keep the shape the feed already has, at a size that suits a floating
|
|
// window -- a single pane is portrait, a row split is wide, and the browser
|
|
// clamps whatever it cannot honour.
|
|
const pipWindowSize = function(root) {
|
|
const w = root.clientWidth || window.innerWidth || 360;
|
|
const h = root.clientHeight || window.innerHeight || 640;
|
|
const height = Math.max(240, Math.min(720, Math.round(window.screen.availHeight * 0.6)));
|
|
return { width: Math.max(200, Math.round(height * (w / h))), height: height };
|
|
};
|
|
|
|
// The move changes the viewport the slides are sized against, so scroll
|
|
// offsets measured in pixels are meaningless afterwards. realignToActive
|
|
// recomputes them from the active video's id -- the same thing an
|
|
// orientation change does.
|
|
const settleAfterMove = function() {
|
|
const resume = function() {
|
|
const slide = slidesByIndex.get(state.feedActiveIndex);
|
|
if (slide) panesOf(slide).forEach((pane) => {
|
|
const video = pane.querySelector('.feed-video');
|
|
if (video && video.paused && pane.classList.contains('is-loaded')) {
|
|
video.play().catch(() => {});
|
|
}
|
|
});
|
|
};
|
|
if (!state.feedOpen || state.feedActiveIndex < 0) {
|
|
resume();
|
|
return;
|
|
}
|
|
settleDone = resume;
|
|
onResize();
|
|
};
|
|
|
|
// Puts the feed back in the page. Called when the window closes, however it
|
|
// closed -- its own button, the feed closing, or the tab going away.
|
|
const undockFeed = function() {
|
|
const root = getRoot();
|
|
pipWindow = null;
|
|
if (!root || root.ownerDocument === document) return;
|
|
document.body.appendChild(root);
|
|
if (state.feedOpen) {
|
|
document.body.classList.add('feed-mode-open');
|
|
document.body.style.overflow = 'hidden';
|
|
settleAfterMove();
|
|
App.feed.updateToggleButton();
|
|
} else {
|
|
App.feed.close();
|
|
}
|
|
};
|
|
|
|
App.feed.openDocPip = async function() {
|
|
if (!docPipSupported() || pipWindow) return false;
|
|
const root = getRoot();
|
|
if (!root || !state.feedOpen) return false;
|
|
// Two picture-in-picture windows cannot both hold this feed, and the
|
|
// video one holds an element that is about to move.
|
|
if (App.customPlayer.pipElement()) {
|
|
await App.customPlayer.exitPiP();
|
|
}
|
|
let win;
|
|
try {
|
|
win = await window.documentPictureInPicture.requestWindow(pipWindowSize(root));
|
|
} catch (err) {
|
|
return false;
|
|
}
|
|
pipWindow = win;
|
|
adoptStyles(win);
|
|
win.document.body.appendChild(root); // adopts the live subtree, playback and all
|
|
// The page underneath is usable again: the grid is right there while the
|
|
// reel plays in the window.
|
|
document.body.classList.remove('feed-mode-open');
|
|
document.body.style.overflow = 'auto';
|
|
win.addEventListener('pagehide', undockFeed);
|
|
win.addEventListener('resize', onResize);
|
|
settleAfterMove();
|
|
wakeHud();
|
|
return true;
|
|
};
|
|
|
|
App.feed.closeDocPip = function() {
|
|
if (pipWindow) pipWindow.close(); // fires pagehide -> undockFeed
|
|
};
|
|
|
|
// ------------------------------------------------------------------
|
|
// Auto picture-in-picture
|
|
//
|
|
// Leaving the tab while reels is playing should carry the video out with
|
|
// the reader. With panes there are several candidates and only one window,
|
|
// so the choice is made here rather than left to whichever pane's handler
|
|
// fires first: the panel you can hear, or the first one if they are all
|
|
// muted.
|
|
// ------------------------------------------------------------------
|
|
const autoPipVideo = function() {
|
|
// Already out of the tab, in a better window. Marking a video here would
|
|
// have the browser tear it out of the document window on the next tab
|
|
// switch and replace a scrollable reel with one video's frames.
|
|
if (pipWindow) return null;
|
|
const slide = slidesByIndex.get(state.feedActiveIndex);
|
|
if (!slide) return null;
|
|
const panes = panesOf(slide);
|
|
if (!panes.length) return null;
|
|
const audible = panes.find((pane) => !pane._muted);
|
|
const chosen = audible || panes[0];
|
|
return chosen ? chosen.querySelector('.feed-video') : null;
|
|
};
|
|
|
|
// Marks the chosen video and clears every other, so the browser's own
|
|
// automatic handling targets the same one this would.
|
|
const updateAutoPiPTarget = function() {
|
|
const wanted = state.feedOpen ? autoPipVideo() : null;
|
|
slidesByIndex.forEach((slide) => {
|
|
panesOf(slide).forEach((pane) => {
|
|
const video = pane.querySelector('.feed-video');
|
|
if (video) App.customPlayer.setAutoPiP(video, video === wanted);
|
|
});
|
|
});
|
|
};
|
|
|
|
App.feed.updateAutoPiPTarget = updateAutoPiPTarget;
|
|
|
|
// The fallback for browsers that ignore the attribute but would allow the
|
|
// request. It fails without a user gesture in most of them, which is why
|
|
// the attribute above is the real mechanism.
|
|
const onFeedHidden = function() {
|
|
if (!state.feedOpen) return;
|
|
if (document.visibilityState !== 'hidden') return;
|
|
if (!App.customPlayer.supportsPiP()) return;
|
|
if (App.customPlayer.pipElement()) return;
|
|
const video = autoPipVideo();
|
|
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.
|
|
App.customPlayer.enterPiP(video);
|
|
};
|
|
|
|
let autoPipBound = false;
|
|
|
|
App.feed.isOpen = function() {
|
|
return !!state.feedOpen;
|
|
};
|
|
|
|
// Re-applies the on-video-end preference to every rendered slide so toggling
|
|
// the setting takes effect immediately, without needing to reopen the feed.
|
|
App.feed.applyEndBehavior = function() {
|
|
const loop = shouldLoop();
|
|
slidesByIndex.forEach((slide) => {
|
|
panesOf(slide).forEach((pane) => {
|
|
const video = pane.querySelector('.feed-video');
|
|
if (video) video.loop = loop;
|
|
});
|
|
});
|
|
};
|
|
|
|
// Called whenever new video JSON is appended (e.g. after a prefetch). Lets
|
|
// the open feed pick up newly buffered slides and extend its window if the
|
|
// active slide is near the end.
|
|
App.feed.renderSlides = function() {
|
|
if (!state.feedOpen || state.feedActiveIndex < 0) return;
|
|
setActive(state.feedActiveIndex);
|
|
};
|
|
|
|
App.feed.reset = function() {
|
|
slidesByIndex.forEach((slide) => {
|
|
teardownSlide(slide);
|
|
slide.remove();
|
|
});
|
|
slidesByIndex.clear();
|
|
forgetPeeks();
|
|
releaseTurn();
|
|
resumeTimes.clear();
|
|
state.feedActiveIndex = -1;
|
|
state.feedActiveVideoId = null;
|
|
suppressScroll = false;
|
|
if (resizeSettleRaf) {
|
|
cancelAnimationFrame(resizeSettleRaf);
|
|
resizeSettleRaf = null;
|
|
}
|
|
if (resizeSettleTimer) {
|
|
clearTimeout(resizeSettleTimer);
|
|
resizeSettleTimer = null;
|
|
}
|
|
const spacer = getTopSpacer();
|
|
if (spacer) spacer.style.height = '0px';
|
|
const scroller = getScroller();
|
|
if (scroller) scroller.scrollTop = 0;
|
|
};
|
|
|
|
App.feed.open = function(startVideoId) {
|
|
const container = getRoot();
|
|
const scroller = getScroller();
|
|
if (!container || !scroller) return;
|
|
state.feedOpen = true;
|
|
|
|
if (App.player && typeof App.player.close === 'function') {
|
|
// fromPopState: true suppresses the player's own history.back()
|
|
// -- this is an incidental "make sure it's closed" call when
|
|
// switching to Reels view, not the user pressing the player's
|
|
// close button, so it must not silently consume a back-button
|
|
// entry out from under real browser navigation.
|
|
App.player.close({ fromPopState: true });
|
|
}
|
|
|
|
container.classList.add('open');
|
|
container.setAttribute('aria-hidden', 'false');
|
|
lastShape = viewportShape();
|
|
document.body.classList.add('feed-mode-open');
|
|
document.body.style.overflow = 'hidden';
|
|
|
|
if (!scrollBound) {
|
|
scroller.addEventListener('scroll', onScroll, { passive: true });
|
|
window.addEventListener('resize', onResize);
|
|
// `orientationchange` fires before the new metrics are readable and
|
|
// the visual viewport moves on its own as the browser's chrome
|
|
// slides away, so both open the realign window too -- the watcher
|
|
// below waits for the height to stop moving either way.
|
|
window.addEventListener('orientationchange', onOrientation);
|
|
if (window.visualViewport) {
|
|
window.visualViewport.addEventListener('resize', onResize);
|
|
}
|
|
scrollBound = true;
|
|
}
|
|
|
|
if (!autoPipBound) {
|
|
document.addEventListener('visibilitychange', onFeedHidden);
|
|
window.addEventListener('pagehide', onFeedHidden);
|
|
bindPipTracking();
|
|
autoPipBound = true;
|
|
}
|
|
|
|
if (!hudActivityBound) {
|
|
container.addEventListener('mousemove', wakeHud, { passive: true });
|
|
// A press on the video is how a swipe begins, and waking on that
|
|
// flashed the HUD at the start of every drag -- a press that turns
|
|
// out to be a tap wakes it through the gesture layer's onSingleTap
|
|
// instead. A press on a control is not a swipe, and the control it
|
|
// lands on is one the reader can't see while the HUD is hidden.
|
|
container.addEventListener('pointerdown', (event) => {
|
|
const target = event.target;
|
|
if (target && target.closest &&
|
|
target.closest('button, input, a, .feed-timeline, .feed-format-menu')) {
|
|
wakeHud();
|
|
}
|
|
}, { passive: true });
|
|
hudActivityBound = true;
|
|
}
|
|
|
|
// Start from whichever grid video the user was looking at.
|
|
let startIndex = 0;
|
|
if (startVideoId != null) {
|
|
const found = (state.loadedVideos || [])
|
|
.findIndex((v) => String(v.id) === String(startVideoId));
|
|
if (found >= 0) startIndex = Math.floor(found / paneCount());
|
|
}
|
|
|
|
// Force a fresh activation even if the index happens to match.
|
|
state.feedActiveIndex = -1;
|
|
setActive(startIndex);
|
|
scroller.scrollTop = startIndex * slideHeight();
|
|
|
|
App.feed.updateToggleButton();
|
|
App.feed.updateMuteButton();
|
|
// The scroll assignment above fires a scroll event, which puts the HUD
|
|
// away; the controls should be up when the feed opens, so this waits
|
|
// for that to land (scroll events run before animation frames).
|
|
requestAnimationFrame(wakeHud);
|
|
};
|
|
|
|
App.feed.close = function() {
|
|
const container = getRoot();
|
|
if (!container) return;
|
|
state.feedOpen = false;
|
|
// Bring the feed home before tearing it down, and do the move here
|
|
// rather than waiting for the window's pagehide -- that fires later, and
|
|
// undockFeed would call straight back into this function.
|
|
if (pipWindow) {
|
|
const win = pipWindow;
|
|
pipWindow = null;
|
|
document.body.appendChild(container);
|
|
win.close();
|
|
}
|
|
if (hudIdleTimer) {
|
|
clearTimeout(hudIdleTimer);
|
|
hudIdleTimer = null;
|
|
}
|
|
setHudIdle(false);
|
|
updateAutoPiPTarget(); // feedOpen is false now, so this clears them
|
|
unbindMediaSession();
|
|
if (pipPane && App.customPlayer.pipElement()) {
|
|
App.customPlayer.exitPiP();
|
|
}
|
|
pipPane = null;
|
|
slidesByIndex.forEach((slide) => panesOf(slide).forEach(destroySlidePlayback));
|
|
container.classList.remove('open');
|
|
container.setAttribute('aria-hidden', 'true');
|
|
document.body.classList.remove('feed-mode-open');
|
|
document.body.style.overflow = 'auto';
|
|
// The grid reveals a page at a time and is back in charge of the
|
|
// buffer: the feed's deeper one was for swipes eating N videos each.
|
|
if (App.videos.setPrefetchDepth) App.videos.setPrefetchDepth(1);
|
|
App.feed.updateToggleButton();
|
|
};
|
|
|
|
App.feed.toggle = function() {
|
|
if (state.feedOpen) {
|
|
App.feed.close();
|
|
} else {
|
|
const focusedId = App.videos && typeof App.videos.getFocusedVideoId === 'function'
|
|
? App.videos.getFocusedVideoId()
|
|
: null;
|
|
App.feed.open(focusedId);
|
|
}
|
|
};
|
|
|
|
// The feed-wide control now sets every panel at once; each panel still has
|
|
// its own switch for when they should differ.
|
|
App.feed.toggleMute = function() {
|
|
state.feedMuted = !state.feedMuted;
|
|
slidesByIndex.forEach((slide) => {
|
|
panesOf(slide).forEach((pane) => {
|
|
pane._muted = state.feedMuted;
|
|
if (pane._syncMute) pane._syncMute();
|
|
});
|
|
});
|
|
// Unmuting everything is still one panel's worth of sound where the
|
|
// device allows only one; the rest keep the picture.
|
|
limitAudibleToOne();
|
|
resumeActivePanes();
|
|
App.feed.updateMuteButton();
|
|
};
|
|
|
|
App.feed.updateToggleButton = function() {
|
|
const btn = document.getElementById('mode-toggle-btn');
|
|
const icon = document.getElementById('mode-toggle-icon');
|
|
if (!btn || !icon) return;
|
|
const open = !!state.feedOpen;
|
|
btn.setAttribute('aria-pressed', open ? 'true' : 'false');
|
|
const label = open ? 'Back to grid' : 'Switch to Reels view';
|
|
btn.title = label;
|
|
icon.alt = label;
|
|
icon.src = open
|
|
? 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/squares-2x2.svg'
|
|
: 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/device-phone-mobile.svg';
|
|
};
|
|
|
|
App.feed.updateMuteButton = function() {
|
|
const icon = inRoot('feed-mute-icon');
|
|
if (!icon) return;
|
|
icon.src = state.feedMuted
|
|
? 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/speaker-x-mark.svg'
|
|
: 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/speaker-wave.svg';
|
|
icon.alt = state.feedMuted ? 'Unmute' : 'Mute';
|
|
// Pulse a brass ring while muted to hint "tap to hear sound".
|
|
const btn = inRoot('feed-mute-btn');
|
|
if (btn) btn.classList.toggle('is-muted', !!state.feedMuted);
|
|
};
|
|
})();
|