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
1395 lines
61 KiB
JavaScript
1395 lines
61 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.
|
|
const PRELOAD_COUNT = 2; // slides ahead kept with a live <video> playing/preloaded
|
|
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;
|
|
|
|
// 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;
|
|
|
|
const scheduleHudHide = function() {
|
|
if (hudIdleTimer) clearTimeout(hudIdleTimer);
|
|
hudIdleTimer = setTimeout(() => {
|
|
hudIdleTimer = null;
|
|
if (!state.feedOpen) return;
|
|
document.body.classList.add('feed-hud-idle');
|
|
// 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.
|
|
document.querySelectorAll('.feed-format-menu').forEach((menu) => { menu.hidden = true; });
|
|
}, HUD_IDLE_MS);
|
|
};
|
|
|
|
const wakeHud = function() {
|
|
document.body.classList.remove('feed-hud-idle');
|
|
if (state.feedOpen) scheduleHudHide();
|
|
};
|
|
|
|
const getScroller = () => document.getElementById('feed-scroll');
|
|
const getSentinel = () => document.getElementById('feed-sentinel');
|
|
const getTopSpacer = () => document.getElementById('feed-top-spacer');
|
|
|
|
const slideHeight = function() {
|
|
const scroller = getScroller();
|
|
return (scroller && scroller.clientHeight) || window.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' });
|
|
};
|
|
|
|
// 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;
|
|
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;
|
|
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(slide, 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(slide, `${direction === 'forward' ? '+' : '-'}${amount}s`);
|
|
wakeHud();
|
|
};
|
|
|
|
const pipBtn = slide.querySelector('.feed-pip-btn');
|
|
if (pipBtn) {
|
|
pipBtn.hidden = !App.customPlayer.supportsPiP();
|
|
const onClick = async (event) => {
|
|
event.stopPropagation();
|
|
if (document.pictureInPictureElement) {
|
|
await document.exitPictureInPicture().catch(() => {});
|
|
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 = slide.querySelector('.feed-format-btn');
|
|
const formatMenu = slide.querySelector('.feed-format-menu');
|
|
const onFormatPick = (fmt) => {
|
|
slide._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 slide, 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();
|
|
slide.classList.remove('is-loaded');
|
|
loadSlideSource(slide, videoData, true);
|
|
};
|
|
const bindFormats = () => App.customPlayer.bindFormatMenu(formatBtn, formatMenu, videoData, onFormatPick,
|
|
{ getCurrentUrl: () => slide._activeUrl || '', onOpen: wakeHud });
|
|
let destroyFormatMenu = bindFormats();
|
|
cleanups.push(() => destroyFormatMenu());
|
|
// A slide 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 slide was torn down (or rebound) in the meantime.
|
|
if (!meta || slide._sharedControlCleanups !== cleanups) return;
|
|
destroyFormatMenu();
|
|
destroyFormatMenu = bindFormats();
|
|
});
|
|
}
|
|
|
|
cleanups.push(App.customPlayer.attachGestures(slide, {
|
|
onSingleTap: wakeHud,
|
|
onDoubleTapLeft: () => doSkip('back'),
|
|
onDoubleTapRight: () => doSkip('forward'),
|
|
ignoreSelector: 'button, input, a, .cp-format-menu, .feed-timeline'
|
|
}));
|
|
|
|
slide._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;
|
|
};
|
|
|
|
const loadSlideSource = function(slide, videoData, autoplay) {
|
|
const video = slide.querySelector('.feed-video');
|
|
if (!video) return;
|
|
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 = 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 = slide._muted !== false;
|
|
// The step being watched buffers properly; the one preloaded behind it
|
|
// only needs enough to start instantly on the swipe. With four panes
|
|
// that is the difference between four extra streams downloading and
|
|
// four holding a few seconds each.
|
|
video.preload = autoplay ? '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) {
|
|
// 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. loadSlide-
|
|
// Source 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;
|
|
teardownAllSlides();
|
|
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);
|
|
const scroller = getScroller();
|
|
if (scroller) scroller.scrollTop = step * slideHeight();
|
|
};
|
|
|
|
const teardownAllSlides = function() {
|
|
slidesByIndex.forEach((slide) => {
|
|
teardownSlide(slide);
|
|
slide.remove();
|
|
});
|
|
slidesByIndex.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 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);
|
|
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();
|
|
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;
|
|
};
|
|
|
|
const bindPaneTools = function(pane, node) {
|
|
const closeBtn = pane.querySelector('.feed-pane-close');
|
|
closeBtn.hidden = paneCount() <= 1;
|
|
pane.querySelector('.feed-pane-split-right').addEventListener('click', (event) => {
|
|
event.stopPropagation();
|
|
splitPane(node, 'row');
|
|
});
|
|
pane.querySelector('.feed-pane-split-down').addEventListener('click', (event) => {
|
|
event.stopPropagation();
|
|
splitPane(node, 'col');
|
|
});
|
|
closeBtn.addEventListener('click', (event) => {
|
|
event.stopPropagation();
|
|
closePane(node);
|
|
});
|
|
};
|
|
|
|
// 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. Steps ahead/behind are counted in steps,
|
|
// but each one now costs a whole step's worth of <video> elements, so
|
|
// splitting four ways should not put four times as many in the document:
|
|
// the window narrows as panes are added.
|
|
//
|
|
// 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(1, 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`;
|
|
};
|
|
|
|
// Once the active slide gets within PREFETCH_PAGES of the end of the loaded
|
|
// JSON, pull the next page so the buffer stays ahead of the rendered window.
|
|
const prefetchIfNeeded = function(activeIndex) {
|
|
const total = (state.loadedVideos || []).length;
|
|
const bufferAhead = total - 1 - ((activeIndex + 1) * paneCount() - 1);
|
|
if (bufferAhead < PREFETCH_PAGES * (state.perPage || 12)
|
|
&& 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_COUNT, 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) {
|
|
panesOf(activeSlide).forEach((pane) => {
|
|
loadSlideSource(pane, pane._videoData, true);
|
|
requestAnimationFrame(() => measureFeedTitle(pane));
|
|
});
|
|
}
|
|
|
|
slidesByIndex.forEach((slide, i) => {
|
|
if (i === clamped) return;
|
|
// Same floor, same reason: at least the next step is preloaded, so
|
|
// every panel has its next video ready before the swipe. It is
|
|
// preloaded for *all* of that step's panes, which is what makes the
|
|
// guarantee hold per panel rather than only for the first.
|
|
const preloadAhead = Math.max(1, Math.round(PRELOAD_COUNT / paneCount()));
|
|
panesOf(slide).forEach((pane) => {
|
|
if (i > clamped && i <= clamped + preloadAhead) {
|
|
loadSlideSource(pane, pane._videoData, false);
|
|
} 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();
|
|
prefetchIfNeeded(clamped);
|
|
};
|
|
|
|
const onScroll = function() {
|
|
wakeHud();
|
|
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;
|
|
const index = clampIndex(Math.round(scroller.scrollTop / slideHeight()));
|
|
if (index < 0) return;
|
|
if (index !== state.feedActiveIndex) {
|
|
setActive(index);
|
|
}
|
|
});
|
|
};
|
|
|
|
// 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;
|
|
const spacer = getTopSpacer();
|
|
if (spacer) spacer.style.height = `${start * h}px`;
|
|
const scroller = getScroller();
|
|
if (scroller) scroller.scrollTop = index * h;
|
|
const activeSlide = slidesByIndex.get(index);
|
|
if (activeSlide) measureFeedTitle(activeSlide);
|
|
};
|
|
|
|
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;
|
|
realignToActive();
|
|
// Orientation changes can settle over more than one frame (the visual
|
|
// viewport and the scroll-snap re-anchor in stages); realign again once
|
|
// layout has settled, then stop suppressing real swipes.
|
|
if (resizeSettleRaf) cancelAnimationFrame(resizeSettleRaf);
|
|
resizeSettleRaf = requestAnimationFrame(() => {
|
|
realignToActive();
|
|
resizeSettleRaf = requestAnimationFrame(() => {
|
|
resizeSettleRaf = null;
|
|
suppressScroll = false;
|
|
});
|
|
});
|
|
};
|
|
|
|
// ------------------------------------------------------------------
|
|
// 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
|
|
//
|
|
// 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() {
|
|
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 (!document.pictureInPictureEnabled) return;
|
|
if (document.pictureInPictureElement) 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.
|
|
video.requestPictureInPicture().catch(() => {});
|
|
};
|
|
|
|
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();
|
|
resumeTimes.clear();
|
|
state.feedActiveIndex = -1;
|
|
state.feedActiveVideoId = null;
|
|
suppressScroll = false;
|
|
if (resizeSettleRaf) {
|
|
cancelAnimationFrame(resizeSettleRaf);
|
|
resizeSettleRaf = 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 = document.getElementById('feed-view');
|
|
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');
|
|
document.body.classList.add('feed-mode-open');
|
|
document.body.style.overflow = 'hidden';
|
|
|
|
if (!scrollBound) {
|
|
scroller.addEventListener('scroll', onScroll, { passive: true });
|
|
window.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 });
|
|
container.addEventListener('pointerdown', wakeHud, { passive: true });
|
|
container.addEventListener('touchstart', 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();
|
|
wakeHud();
|
|
};
|
|
|
|
App.feed.close = function() {
|
|
const container = document.getElementById('feed-view');
|
|
if (!container) return;
|
|
state.feedOpen = false;
|
|
if (hudIdleTimer) {
|
|
clearTimeout(hudIdleTimer);
|
|
hudIdleTimer = null;
|
|
}
|
|
document.body.classList.remove('feed-hud-idle');
|
|
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));
|
|
container.classList.remove('open');
|
|
container.setAttribute('aria-hidden', 'true');
|
|
document.body.classList.remove('feed-mode-open');
|
|
document.body.style.overflow = 'auto';
|
|
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();
|
|
});
|
|
});
|
|
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 = document.getElementById('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 = document.getElementById('feed-mute-btn');
|
|
if (btn) btn.classList.toggle('is-muted', !!state.feedMuted);
|
|
};
|
|
})();
|