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:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user