Replace video player with a fully custom fake-fullscreen HUD

Single fullscreen player state everywhere (desktop/Android/iOS/feed) instead
of the old modal-vs-native-fullscreen split, with custom controls: draggable
timeline with buffered range, dynamically-escalating skip buttons (double-tap
zones too), per-video format switching, favorites, PiP with auto-PiP on
backgrounding, volume swipe, TikTok-style HUD auto-hide, and swipe-down/
back-button/close-button dismissal. Reels feed reuses the same skip/format/
PiP logic via the new customPlayer.js shared module.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Simon
2026-07-01 19:08:21 +00:00
parent 5d739bec12
commit 7207e36510
9 changed files with 1605 additions and 326 deletions

View File

@@ -233,6 +233,77 @@ App.feed = App.feed || {};
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();
await App.customPlayer.togglePiP(video);
};
pipBtn.addEventListener('click', onClick);
cleanups.push(() => pipBtn.removeEventListener('click', onClick));
}
cleanups.push(App.customPlayer.bindAutoPiP(video));
const formatBtn = slide.querySelector('.feed-format-btn');
const formatMenu = slide.querySelector('.feed-format-menu');
cleanups.push(App.customPlayer.bindFormatMenu(formatBtn, formatMenu, videoData, (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);
}));
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;
};
const loadSlideSource = function(slide, videoData, autoplay) {
const video = slide.querySelector('.feed-video');
if (!video) return;
@@ -246,8 +317,10 @@ App.feed = App.feed || {};
}
slide.classList.add('is-loaded');
const resolved = App.videos.resolveStreamSource(videoData);
if (!resolved.url) {
const resolved = slide._formatOverride
? App.videos.resolveSourceForFormat(videoData, slide._formatOverride)
: App.videos.resolveStreamSource(videoData);
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);
@@ -332,6 +405,12 @@ App.feed = App.feed || {};
<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}"></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" hidden></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>` : ''}
@@ -347,6 +426,7 @@ App.feed = App.feed || {};
App.videos.attachNoReferrerRetry(poster);
const slideVideo = slide.querySelector('.feed-video');
bindTimeline(slide, slideVideo);
bindSharedControls(slide, 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
@@ -388,10 +468,23 @@ App.feed = App.feed || {};
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).
const teardownSlide = function(slide) {
destroySlidePlayback(slide);
if (Array.isArray(slide._sharedControlCleanups)) {
slide._sharedControlCleanups.forEach((fn) => { try { fn(); } catch (err) { /* ignore */ } });
slide._sharedControlCleanups = null;
}
};
const removeSlide = function(index) {
const slide = slidesByIndex.get(index);
if (!slide) return;
destroySlidePlayback(slide);
teardownSlide(slide);
slide.remove();
slidesByIndex.delete(index);
};
@@ -628,7 +721,7 @@ App.feed = App.feed || {};
App.feed.reset = function() {
slidesByIndex.forEach((slide) => {
destroySlidePlayback(slide);
teardownSlide(slide);
slide.remove();
});
slidesByIndex.clear();
@@ -653,7 +746,12 @@ App.feed = App.feed || {};
state.feedOpen = true;
if (App.player && typeof App.player.close === 'function') {
App.player.close();
// 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');