${v.title || ''}
${uploaderText ? `
${uploaderText}
` : ''}
@@ -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');
diff --git a/frontend/js/player.js b/frontend/js/player.js
index 058782a..bd10246 100644
--- a/frontend/js/player.js
+++ b/frontend/js/player.js
@@ -1,175 +1,635 @@
window.App = window.App || {};
App.player = App.player || {};
+// Fully custom fullscreen video player. There is a single player state (no
+// separate "windowed" mode): opening any video fills the viewport with a
+// fixed-position overlay ("fake fullscreen" — a CSS overlay rather than the
+// real Fullscreen API, so the same HUD/gesture code works identically on
+// desktop, Android and iOS, where native fullscreen video can't host custom
+// HTML controls).
(function() {
const state = App.state;
- // Playback heuristics for full-screen behavior on mobile/TV browsers.
- function isMobilePlayback() {
- if (navigator.userAgentData && typeof navigator.userAgentData.mobile === 'boolean') {
- return navigator.userAgentData.mobile;
+ const HUD_IDLE_MS = 2500;
+ const DISMISS_THRESHOLD_PX = 90;
+
+ // Module-local player state, separate from App.state (which other
+ // modules read/write for unrelated things).
+ const cp = {
+ container: null,
+ video: null,
+ formatOverride: null, // manually chosen fmt object, or null (auto)
+ cleanups: [],
+ historyPushed: false,
+ idleTimer: null,
+ originEl: null,
+ attemptToken: 0 // bumps on every open()/format switch to void stale async callbacks
+ };
+
+ const addCleanup = (fn) => cp.cleanups.push(fn);
+ const runCleanups = () => {
+ cp.cleanups.forEach((fn) => { try { fn(); } catch (err) { /* ignore */ } });
+ cp.cleanups = [];
+ };
+ const q = (selector) => cp.container ? cp.container.querySelector(selector) : null;
+
+ // ---------------------------------------------------------------------
+ // DOM construction
+ // ---------------------------------------------------------------------
+ function buildContainer() {
+ let container = document.getElementById('custom-player');
+ if (!container) {
+ container = document.createElement('div');
+ container.id = 'custom-player';
+ document.body.appendChild(container);
}
- const ua = navigator.userAgent || '';
- if (/iPhone|iPad|iPod|Android/i.test(ua)) return true;
- return window.matchMedia('(pointer: coarse)').matches && window.matchMedia('(max-width: 900px)').matches;
+ container.className = 'custom-player';
+ container.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
0:00
+
+
+
+
+
+
0:00
+
+
+
+
+
+
+
+
+
+
+ `;
+ return container;
}
- function isTvPlayback() {
- const ua = navigator.userAgent || '';
- return /SMART-TV|SmartTV|Smart TV|Internet\.TV|HbbTV|NetCast|Web0S|webOS|Tizen|AppleTV|Apple TV|GoogleTV|Android TV|AFTB|AFTS|AFTM|AFTT|AFTQ|AFTK|AFTN|AFTMM|AFTKR|Roku|DTV|BRAVIA|VIZIO|SHIELD|PhilipsTV|Hisense|VIDAA|TOSHIBA/i.test(ua);
- }
-
- function getMobileVideoHost() {
- let host = document.getElementById('mobile-video-host');
- if (!host) {
- host = document.createElement('div');
- host.id = 'mobile-video-host';
- document.body.appendChild(host);
- }
- return host;
- }
-
- App.player.open = async function(source, opts) {
- const modal = document.getElementById('video-modal');
- const video = document.getElementById('player');
- const originEl = opts && opts.originEl ? opts.originEl : null;
- const clearLoading = () => {
- if (originEl) {
- originEl.classList.remove('is-loading');
- }
- };
- if (originEl) {
- originEl.classList.add('is-loading');
- }
- if (!modal || !video) {
- clearLoading();
- return;
- }
- const useMobileFullscreen = isMobilePlayback() || isTvPlayback();
-
- if (!state.playerHome) {
- state.playerHome = video.parentElement;
- }
-
- // Resolve an ordered list of candidate sources (best first). When a
- // source's URL fails to load we fall back to the next one.
- let sources = [];
- if (App.videos && typeof App.videos.resolveStreamSources === 'function') {
- sources = App.videos.resolveStreamSources(source);
+ // ---------------------------------------------------------------------
+ // Title marquee (mirrors App.videos' card marquee math, scoped locally
+ // since the player title isn't a `.video-card`).
+ // ---------------------------------------------------------------------
+ function measureTitle() {
+ const wrap = q('.cp-title');
+ const text = q('.cp-title-text');
+ if (!wrap || !text) return;
+ const overflow = text.scrollWidth - wrap.clientWidth;
+ if (overflow > 4) {
+ const distance = overflow + 12;
+ const MARQUEE_SPEED = 28;
+ const MARQUEE_MIN_DURATION = 6;
+ const duration = Math.max(MARQUEE_MIN_DURATION, distance / MARQUEE_SPEED);
+ text.style.setProperty('--marquee-distance', `${distance}px`);
+ text.style.setProperty('--marquee-duration', `${duration.toFixed(2)}s`);
+ wrap.classList.add('has-marquee');
} else {
- let resolved = { url: '', referer: '' };
- if (App.videos && typeof App.videos.resolveStreamSource === 'function') {
- resolved = App.videos.resolveStreamSource(source);
- } else if (typeof source === 'string') {
- resolved.url = source;
- } else if (source && typeof source === 'object') {
- resolved.url = source.url || '';
- }
- if (resolved.url) sources = [resolved];
+ wrap.classList.remove('has-marquee');
+ text.style.removeProperty('--marquee-distance');
}
- if (!sources.length) {
- if (App.ui && App.ui.showError) {
- App.ui.showError('Unable to play this stream.');
- }
- clearLoading();
+ }
+
+ // ---------------------------------------------------------------------
+ // HUD auto-hide (TikTok-style fade on inactivity)
+ // ---------------------------------------------------------------------
+ function clearIdleTimer() {
+ if (cp.idleTimer) {
+ clearTimeout(cp.idleTimer);
+ cp.idleTimer = null;
+ }
+ }
+
+ function scheduleHudHide() {
+ clearIdleTimer();
+ cp.idleTimer = setTimeout(() => {
+ cp.idleTimer = null;
+ if (cp.container) cp.container.classList.add('cp-hud-idle');
+ }, HUD_IDLE_MS);
+ }
+
+ function wakeHud() {
+ if (!cp.container) return;
+ cp.container.classList.remove('cp-hud-idle');
+ scheduleHudHide();
+ }
+
+ // ---------------------------------------------------------------------
+ // Flash feedback (skip amount, etc.)
+ // ---------------------------------------------------------------------
+ function flash(text) {
+ const flashEl = q('.cp-flash');
+ if (!flashEl) return;
+ flashEl.textContent = text;
+ flashEl.classList.remove('is-visible');
+ void flashEl.offsetWidth; // restart the fade animation
+ flashEl.classList.add('is-visible');
+ }
+
+ // ---------------------------------------------------------------------
+ // Timeline (draggable seek + buffered range)
+ // ---------------------------------------------------------------------
+ function updateBuffered(video) {
+ const bufferedEl = q('.cp-timeline-buffered');
+ if (!bufferedEl) return;
+ if (!video.buffered || !video.buffered.length || !isFinite(video.duration) || video.duration <= 0) {
+ bufferedEl.style.width = '0%';
return;
}
+ const end = video.buffered.end(video.buffered.length - 1);
+ bufferedEl.style.width = `${Math.min(100, (end / video.duration) * 100)}%`;
+ }
- // Expand the candidate sources into an ordered playback plan. When a
- // source has been proven (in the background) to play directly, try the
- // raw upstream URL first and keep the proxy as the immediate fallback;
- // unproven sources go straight through the proxy.
- const directProven = (url) => !!(App.videos && App.videos.isDirectProven && App.videos.isDirectProven(url));
- const playbackPlan = [];
- sources.forEach((resolved) => {
- if (directProven(resolved.url)) {
- playbackPlan.push({ resolved, direct: true });
+ function setTimelinePosition(ratio) {
+ const fill = q('.cp-timeline-fill');
+ const handle = q('.cp-timeline-handle');
+ const pct = `${Math.min(1, Math.max(0, ratio)) * 100}%`;
+ if (fill) fill.style.width = pct;
+ if (handle) handle.style.left = pct;
+ }
+
+ function bindTimeline(video) {
+ const timeline = q('.cp-timeline');
+ const currentEl = q('.cp-time-current');
+ const durationEl = q('.cp-time-duration');
+ if (!timeline) return;
+ let scrubbing = false;
+
+ const onTimeUpdate = () => {
+ if (!scrubbing && isFinite(video.duration) && video.duration > 0) {
+ setTimelinePosition(video.currentTime / video.duration);
}
- playbackPlan.push({ resolved, direct: false });
+ if (currentEl) currentEl.textContent = App.videos.formatDuration(video.currentTime) || '0:00';
+ updateBuffered(video);
+ };
+ const onLoadedMeta = () => {
+ if (durationEl) durationEl.textContent = App.videos.formatDuration(video.duration) || '0:00';
+ };
+ const onProgress = () => updateBuffered(video);
+ video.addEventListener('timeupdate', onTimeUpdate);
+ video.addEventListener('loadedmetadata', onLoadedMeta);
+ video.addEventListener('progress', onProgress);
+ addCleanup(() => {
+ video.removeEventListener('timeupdate', onTimeUpdate);
+ video.removeEventListener('loadedmetadata', onLoadedMeta);
+ video.removeEventListener('progress', onProgress);
});
- if (useMobileFullscreen) {
- const host = getMobileVideoHost();
- if (video.parentElement !== host) {
- host.appendChild(video);
+ const seekFromPointer = (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(clamped);
+ };
+ const onDown = (event) => {
+ scrubbing = true;
+ timeline.classList.add('is-scrubbing');
+ timeline.setPointerCapture(event.pointerId);
+ seekFromPointer(event.clientX);
+ event.preventDefault();
+ event.stopPropagation();
+ wakeHud();
+ };
+ const onMove = (event) => {
+ if (!scrubbing) return;
+ seekFromPointer(event.clientX);
+ event.preventDefault();
+ event.stopPropagation();
+ };
+ const onUpEvt = (event) => {
+ if (!scrubbing) return;
+ scrubbing = false;
+ timeline.classList.remove('is-scrubbing');
+ if (timeline.hasPointerCapture(event.pointerId)) timeline.releasePointerCapture(event.pointerId);
+ event.stopPropagation();
+ };
+ timeline.addEventListener('pointerdown', onDown);
+ timeline.addEventListener('pointermove', onMove);
+ timeline.addEventListener('pointerup', onUpEvt);
+ timeline.addEventListener('pointercancel', onUpEvt);
+ addCleanup(() => {
+ timeline.removeEventListener('pointerdown', onDown);
+ timeline.removeEventListener('pointermove', onMove);
+ timeline.removeEventListener('pointerup', onUpEvt);
+ timeline.removeEventListener('pointercancel', onUpEvt);
+ });
+ }
+
+ // ---------------------------------------------------------------------
+ // Favorite button (shared state with the grid/feed heart toggle)
+ // ---------------------------------------------------------------------
+ function bindFavorite(videoData) {
+ const btn = q('.cp-fav-btn');
+ if (!btn || !App.favorites) return;
+ const key = App.favorites.getKey(videoData);
+ if (!key) { btn.hidden = true; return; }
+ btn.dataset.favKey = key;
+ App.favorites.setButtonState(btn, App.favorites.getSet().has(key));
+ const onClick = (event) => {
+ event.stopPropagation();
+ App.favorites.toggle(videoData);
+ };
+ btn.addEventListener('click', onClick);
+ addCleanup(() => btn.removeEventListener('click', onClick));
+ }
+
+ // ---------------------------------------------------------------------
+ // Transport: play/pause, skip w/ escalation, mute/volume, PiP
+ // ---------------------------------------------------------------------
+ function bindTransport(video) {
+ const playBtn = q('.cp-play-btn');
+ const playIcon = q('.cp-play-icon');
+ const skipBackBtn = q('.cp-skip-back-btn');
+ const skipFwdBtn = q('.cp-skip-fwd-btn');
+ const muteBtn = q('.cp-mute-btn');
+ const muteIcon = q('.cp-mute-icon');
+ const volumeRange = q('.cp-volume-range');
+ const pipBtn = q('.cp-pip-btn');
+ const replayBtn = q('.cp-replay-btn');
+
+ const updatePlayIcon = () => {
+ if (playIcon) {
+ playIcon.src = video.paused
+ ? 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/play.svg'
+ : 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/pause.svg';
}
- state.playerMode = 'mobile';
- video.removeAttribute('playsinline');
- video.removeAttribute('webkit-playsinline');
- video.playsInline = false;
- } else {
- if (state.playerHome && video.parentElement !== state.playerHome) {
- state.playerHome.appendChild(video);
- }
- state.playerMode = 'modal';
- video.setAttribute('playsinline', '');
- video.setAttribute('webkit-playsinline', '');
- video.playsInline = true;
+ if (playBtn) playBtn.setAttribute('aria-label', video.paused ? 'Play' : 'Pause');
+ };
+ const onPlay = () => { updatePlayIcon(); if (replayBtn) replayBtn.hidden = true; };
+ const onPause = updatePlayIcon;
+ video.addEventListener('play', onPlay);
+ video.addEventListener('pause', onPause);
+ addCleanup(() => {
+ video.removeEventListener('play', onPlay);
+ video.removeEventListener('pause', onPause);
+ });
+
+ if (playBtn) {
+ const onPlayClick = (event) => {
+ event.stopPropagation();
+ if (video.paused) {
+ const p = video.play();
+ if (p && p.catch) p.catch(() => {});
+ } else {
+ video.pause();
+ }
+ wakeHud();
+ };
+ playBtn.addEventListener('click', onPlayClick);
+ addCleanup(() => playBtn.removeEventListener('click', onPlayClick));
}
- const requestFullscreen = () => {
- if (state.playerMode !== 'mobile') return;
- if (typeof video.webkitEnterFullscreen === 'function') {
- try {
- video.webkitEnterFullscreen();
- } catch (err) {
- // Ignore if fullscreen is not allowed.
+ const escalator = App.customPlayer.createSkipEscalator();
+ addCleanup(() => escalator.destroy());
+
+ const doSkip = (direction) => {
+ const amount = App.customPlayer.skip(video, direction, escalator);
+ const back = skipBackBtn && skipBackBtn.querySelector('.cp-skip-amount');
+ const fwd = skipFwdBtn && skipFwdBtn.querySelector('.cp-skip-amount');
+ if (direction === 'back' && back) back.textContent = String(amount);
+ if (direction === 'forward' && fwd) fwd.textContent = String(amount);
+ flash(`${direction === 'forward' ? '+' : '-'}${amount}s`);
+ wakeHud();
+ };
+ App.player._doSkip = doSkip; // exposed for gesture wiring below
+
+ if (skipBackBtn) {
+ const onClick = (event) => { event.stopPropagation(); doSkip('back'); };
+ skipBackBtn.addEventListener('click', onClick);
+ addCleanup(() => skipBackBtn.removeEventListener('click', onClick));
+ }
+ if (skipFwdBtn) {
+ const onClick = (event) => { event.stopPropagation(); doSkip('forward'); };
+ skipFwdBtn.addEventListener('click', onClick);
+ addCleanup(() => skipFwdBtn.removeEventListener('click', onClick));
+ }
+
+ const updateMuteIcon = () => {
+ if (muteIcon) {
+ muteIcon.src = (video.muted || video.volume === 0)
+ ? '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';
+ }
+ if (volumeRange) volumeRange.value = video.muted ? 0 : video.volume;
+ };
+ updateMuteIcon();
+ const onVolumeChange = updateMuteIcon;
+ video.addEventListener('volumechange', onVolumeChange);
+ addCleanup(() => video.removeEventListener('volumechange', onVolumeChange));
+
+ if (muteBtn) {
+ const onClick = (event) => {
+ event.stopPropagation();
+ video.muted = !video.muted;
+ if (!video.muted && video.volume === 0) video.volume = 1;
+ wakeHud();
+ };
+ muteBtn.addEventListener('click', onClick);
+ addCleanup(() => muteBtn.removeEventListener('click', onClick));
+ }
+ if (volumeRange) {
+ const onInput = () => {
+ video.volume = parseFloat(volumeRange.value);
+ video.muted = video.volume === 0;
+ wakeHud();
+ };
+ volumeRange.addEventListener('input', onInput);
+ addCleanup(() => volumeRange.removeEventListener('input', onInput));
+ }
+
+ if (pipBtn) {
+ pipBtn.hidden = !App.customPlayer.supportsPiP();
+ const onClick = async (event) => {
+ event.stopPropagation();
+ await App.customPlayer.togglePiP(video);
+ };
+ pipBtn.addEventListener('click', onClick);
+ addCleanup(() => pipBtn.removeEventListener('click', onClick));
+ }
+ addCleanup(App.customPlayer.bindAutoPiP(video));
+
+ if (replayBtn) {
+ const onClick = (event) => {
+ event.stopPropagation();
+ replayBtn.hidden = true;
+ video.currentTime = 0;
+ const p = video.play();
+ if (p && p.catch) p.catch(() => {});
+ };
+ replayBtn.addEventListener('click', onClick);
+ addCleanup(() => replayBtn.removeEventListener('click', onClick));
+ }
+
+ const onEnded = () => {
+ clearIdleTimer();
+ if (cp.container) cp.container.classList.remove('cp-hud-idle');
+ if (replayBtn) replayBtn.hidden = false;
+ };
+ video.addEventListener('ended', onEnded);
+ addCleanup(() => video.removeEventListener('ended', onEnded));
+ }
+
+ // ---------------------------------------------------------------------
+ // Gestures: tap wakes the HUD, double-tap left/right skips, right-column
+ // vertical swipe adjusts volume, top-strip swipe-down dismisses.
+ // ---------------------------------------------------------------------
+ function bindGestures(video) {
+ const surface = q('.cp-surface');
+ if (!surface) return;
+ const destroy = App.customPlayer.attachGestures(surface, {
+ // Always wakes (never explicitly hides) so a double-tap-to-skip
+ // doesn't flicker the HUD off-then-on between its two taps;
+ // hiding is left entirely to the idle-fade timer.
+ onSingleTap: () => wakeHud(),
+ onDoubleTapLeft: () => App.player._doSkip('back'),
+ onDoubleTapRight: () => App.player._doSkip('forward'),
+ onVolumeStart: () => (video.muted ? 0 : video.volume),
+ onVolumeDrag: (value) => {
+ video.volume = value;
+ video.muted = value === 0;
+ flash(`${Math.round(value * 100)}%`);
+ },
+ onVolumeEnd: () => wakeHud(),
+ onDismissDrag: (dy) => {
+ const clamped = Math.max(0, dy);
+ cp.container.style.transform = `translateY(${clamped}px)`;
+ cp.container.style.opacity = String(Math.max(0.4, 1 - clamped / 400));
+ },
+ onDismissEnd: (dy) => {
+ cp.container.style.transform = '';
+ cp.container.style.opacity = '';
+ if (dy > DISMISS_THRESHOLD_PX) {
+ App.player.close();
}
- return;
}
- if (video.requestFullscreen) {
- video.requestFullscreen().catch(() => {});
+ });
+ addCleanup(destroy);
+ }
+
+ // ---------------------------------------------------------------------
+ // Keyboard shortcuts (desktop)
+ // ---------------------------------------------------------------------
+ function bindKeyboard(video) {
+ const onKeyDown = (event) => {
+ if (!cp.container || !cp.container.classList.contains('open')) return;
+ switch (event.key) {
+ case ' ':
+ case 'k':
+ case 'K':
+ event.preventDefault();
+ if (video.paused) video.play().catch(() => {}); else video.pause();
+ break;
+ case 'ArrowLeft':
+ App.player._doSkip('back');
+ break;
+ case 'ArrowRight':
+ App.player._doSkip('forward');
+ break;
+ case 'ArrowUp':
+ event.preventDefault();
+ video.volume = Math.min(1, video.volume + 0.1);
+ video.muted = false;
+ break;
+ case 'ArrowDown':
+ event.preventDefault();
+ video.volume = Math.max(0, video.volume - 0.1);
+ break;
+ case 'm':
+ case 'M':
+ video.muted = !video.muted;
+ break;
+ case 'Escape':
+ App.player.close();
+ break;
+ default:
+ return;
+ }
+ wakeHud();
+ };
+ document.addEventListener('keydown', onKeyDown);
+ addCleanup(() => document.removeEventListener('keydown', onKeyDown));
+ }
+
+ // ---------------------------------------------------------------------
+ // Close button + browser/system back
+ // ---------------------------------------------------------------------
+ function bindClose() {
+ const closeBtn = q('.cp-close-btn');
+ if (closeBtn) {
+ const onClick = (event) => { event.stopPropagation(); App.player.close(); };
+ closeBtn.addEventListener('click', onClick);
+ addCleanup(() => closeBtn.removeEventListener('click', onClick));
+ }
+ const onPopState = () => {
+ if (cp.container && cp.container.classList.contains('open')) {
+ cp.historyPushed = false; // the pushed state was just consumed by the browser
+ App.player.close({ fromPopState: true });
}
};
+ window.addEventListener('popstate', onPopState);
+ addCleanup(() => window.removeEventListener('popstate', onPopState));
+ // Only push once per "session": open() tears down and rebinds on
+ // reentrancy (see open()) without closing first, so a second open()
+ // while already open must not stack a second history entry that
+ // close()'s single history.back() could never fully unwind.
+ if (!cp.historyPushed) {
+ history.pushState({ customPlayerOpen: true }, '', location.href);
+ cp.historyPushed = true;
+ }
+ }
- const failPlayback = (message) => {
+ // ---------------------------------------------------------------------
+ // Buffering + error UI
+ // ---------------------------------------------------------------------
+ function bindBufferingIndicator(video) {
+ const spinner = q('.cp-spinner');
+ if (!spinner) return;
+ const show = () => { spinner.classList.add('is-visible'); };
+ const hide = () => { spinner.classList.remove('is-visible'); };
+ video.addEventListener('waiting', show);
+ video.addEventListener('playing', hide);
+ video.addEventListener('canplay', hide);
+ video.addEventListener('pause', hide);
+ addCleanup(() => {
+ video.removeEventListener('waiting', show);
+ video.removeEventListener('playing', hide);
+ video.removeEventListener('canplay', hide);
+ video.removeEventListener('pause', hide);
+ });
+ }
+
+ function showBuffering(show) {
+ const spinner = q('.cp-spinner');
+ if (spinner) spinner.classList.toggle('is-visible', show);
+ }
+
+ function showError(message, onRetry) {
+ showBuffering(false);
+ const errorEl = q('.cp-error');
+ const textEl = q('.cp-error-text');
+ const retryBtn = q('.cp-retry-btn');
+ if (!errorEl) return;
+ if (textEl) textEl.textContent = message || 'Playback failed.';
+ errorEl.hidden = false;
+ if (retryBtn) {
+ retryBtn.onclick = (event) => {
+ event.stopPropagation();
+ errorEl.hidden = true;
+ onRetry();
+ };
+ }
+ }
+
+ function hideError() {
+ const errorEl = q('.cp-error');
+ if (errorEl) errorEl.hidden = true;
+ }
+
+ // ---------------------------------------------------------------------
+ // Source resolution + HLS/native fallback chain
+ // ---------------------------------------------------------------------
+ function resolveSources(videoData) {
+ if (cp.formatOverride) {
+ const source = App.videos.resolveSourceForFormat(videoData, cp.formatOverride);
+ return source ? [source] : [];
+ }
+ if (App.videos && typeof App.videos.resolveStreamSources === 'function') {
+ return App.videos.resolveStreamSources(videoData);
+ }
+ return [];
+ }
+
+ function playSources(videoData, opts) {
+ const video = cp.video;
+ const token = ++cp.attemptToken;
+ const resumeAt = (opts && opts.resumeAt) || 0;
+ // Captured once per call rather than read from the shared `cp`
+ // object later: if open() is ever re-entered for a different video
+ // before this attempt settles, cp.originEl will have moved on to the
+ // new card, and a stale callback reading it live would either mark
+ // the wrong card loaded or (via the token guard below) never clear
+ // this card's spinner at all.
+ const originEl = (opts && opts.originEl) || null;
+ const sources = resolveSources(videoData);
+ const clearLoading = () => {
+ if (originEl) originEl.classList.remove('is-loading');
+ };
+ if (!sources.length) {
clearLoading();
- if (App.ui && App.ui.showError) {
- App.ui.showError(message);
- }
- App.player.close();
- };
+ showError('Unable to play this stream.', () => playSources(videoData, opts));
+ return;
+ }
+
+ const directProven = (url) => !!(App.videos && App.videos.isDirectProven && App.videos.isDirectProven(url));
+ const plan = [];
+ sources.forEach((resolved) => {
+ if (directProven(resolved.url)) plan.push({ resolved, direct: true });
+ plan.push({ resolved, direct: false });
+ });
- // Attempts to play a single source. On a fatal failure it advances to
- // the next candidate, or reports an error once the list is exhausted.
const attempt = async (index) => {
- const entry = playbackPlan[index];
+ if (token !== cp.attemptToken) return;
+ const entry = plan[index];
const resolved = entry.resolved;
- const hasNext = index + 1 < playbackPlan.length;
+ const hasNext = index + 1 < plan.length;
let playbackStarted = false;
let settled = false;
- // Advances to the next source (or fails) exactly once per attempt,
- // guarding against overlapping error callbacks.
const advanceOrFail = (message) => {
- if (settled) return;
+ if (settled || token !== cp.attemptToken) return;
settled = true;
- if (hasNext) {
- attempt(index + 1);
- } else {
- failPlayback(message);
+ if (hasNext) attempt(index + 1);
+ else {
+ clearLoading();
+ showError(message, () => playSources(videoData, opts));
}
};
- // Proven-direct entries hit the upstream URL straight from the
- // browser; everything else is wrapped in the backend stream proxy.
- let streamUrl;
- if (entry.direct) {
- streamUrl = resolved.url;
- } else {
- streamUrl = App.videos.buildStreamUrlFromSource(resolved);
- }
+ let streamUrl = entry.direct ? resolved.url : App.videos.buildStreamUrlFromSource(resolved);
let isHls = /\.m3u8($|\?)/i.test(resolved.url);
let isDirectMedia = /\.(mp4|m4v|m4s|webm|ts|mov)($|\?)/i.test(resolved.url);
- // Live cam streams resolve (server-side) to HLS; treat them as HLS up
- // front so we skip the content-type HEAD probe and go straight to it.
- if (resolved.isLive) {
- isHls = true;
- isDirectMedia = false;
- }
+ if (resolved.isLive) { isHls = true; isDirectMedia = false; }
- // Drop the previous attempt's error handler and player instance
- // before rebinding so stale callbacks don't double-advance.
video.onerror = null;
if (state.hlsPlayer) {
state.hlsPlayer.stopLoad();
@@ -177,8 +637,6 @@ App.player = App.player || {};
state.hlsPlayer.destroy();
state.hlsPlayer = null;
}
-
- // Reset the video element before re-binding a new source.
video.pause();
video.removeAttribute('src');
video.load();
@@ -186,14 +644,12 @@ App.player = App.player || {};
if (!isHls && !entry.direct) {
try {
const headResp = await fetch(streamUrl, { method: 'HEAD' });
+ if (token !== cp.attemptToken) return;
const contentType = headResp.headers.get('Content-Type') || '';
- if (contentType.includes('application/vnd.apple.mpegurl')) {
- isHls = true;
- } else if (contentType.startsWith('video/') || contentType.startsWith('audio/')) {
- isDirectMedia = true;
- }
+ if (contentType.includes('application/vnd.apple.mpegurl')) isHls = true;
+ else if (contentType.startsWith('video/') || contentType.startsWith('audio/')) isDirectMedia = true;
} catch (err) {
- console.warn('Failed to detect stream type', err);
+ // Best-effort sniff only.
}
}
@@ -201,27 +657,20 @@ App.player = App.player || {};
if (playbackStarted) return;
playbackStarted = true;
clearLoading();
- const playPromise = video.play();
- if (playPromise && typeof playPromise.catch === 'function') {
- playPromise.catch(() => {});
- }
- if (state.playerMode === 'mobile') {
- if (video.readyState >= 1) {
- requestFullscreen();
- } else {
- video.addEventListener('loadedmetadata', requestFullscreen, { once: true });
- }
+ hideError();
+ showBuffering(false);
+ if (resumeAt > 0) {
+ const seek = () => { try { video.currentTime = resumeAt; } catch (err) { /* ignore */ } };
+ if (video.readyState >= 1) seek();
+ else video.addEventListener('loadedmetadata', seek, { once: true });
}
+ const p = video.play();
+ if (p && p.catch) p.catch(() => {});
};
- // Confirmed direct media (mp4/webm/…) never needs hls.js; everything
- // else might, so pull it in now that the type has been sniffed.
if (!window.Hls && (isHls || !isDirectMedia)) {
- try {
- await App.ensureHls();
- } catch (err) {
- // Fall back to native playback below.
- }
+ try { await App.ensureHls(); } catch (err) { /* fall back to native below */ }
+ if (token !== cp.attemptToken) return;
}
const canUseHls = !!(window.Hls && window.Hls.isSupported());
@@ -245,21 +694,16 @@ App.player = App.player || {};
state.hlsPlayer = new window.Hls();
state.hlsPlayer.loadSource(streamUrl);
state.hlsPlayer.attachMedia(video);
- state.hlsPlayer.on(window.Hls.Events.MANIFEST_PARSED, function() {
- startPlayback();
- });
+ state.hlsPlayer.on(window.Hls.Events.MANIFEST_PARSED, () => startPlayback());
startPlayback();
- state.hlsPlayer.on(window.Hls.Events.ERROR, function(event, data) {
+ state.hlsPlayer.on(window.Hls.Events.ERROR, (event, data) => {
if (data && data.fatal) {
const shouldFallback = allowFallback && !nativeTried && !isHls;
if (state.hlsPlayer) {
state.hlsPlayer.destroy();
state.hlsPlayer = null;
}
- if (shouldFallback) {
- startNative();
- return;
- }
+ if (shouldFallback) { startNative(); return; }
advanceOrFail('Unable to play this stream.');
}
});
@@ -268,16 +712,8 @@ App.player = App.player || {};
if (prefersHls) {
if (!startHls(true)) {
- if (video.canPlayType('application/vnd.apple.mpegurl')) {
- startNative();
- } else if (hasNext) {
- advanceOrFail('HLS is not supported in this browser.');
- return;
- } else {
- console.error("HLS not supported in this browser.");
- failPlayback('HLS is not supported in this browser.');
- return;
- }
+ if (video.canPlayType('application/vnd.apple.mpegurl')) startNative();
+ else advanceOrFail('HLS is not supported in this browser.');
}
} else {
startNative();
@@ -291,77 +727,123 @@ App.player = App.player || {};
};
};
+ showBuffering(true);
attempt(0);
+ }
- if (state.playerMode === 'modal') {
- // Ambient backdrop: a blurred copy of the poster fills the letterbox
- // behind the contained video for a richer, less sterile player.
- const modalContent = modal.querySelector('.modal-content');
- if (modalContent) {
- let poster = (source && (source.thumb || (source.meta && (source.meta.thumbnail || source.meta.thumb)))) || '';
- if (!poster && originEl) {
- const img = originEl.querySelector('img');
- if (img) poster = img.currentSrc || img.src || '';
- }
- if (poster) modalContent.style.setProperty('--poster', `url("${poster.replace(/"/g, '%22')}")`);
- else modalContent.style.removeProperty('--poster');
+ // ---------------------------------------------------------------------
+ // Public API
+ // ---------------------------------------------------------------------
+ App.player.open = function(source, opts) {
+ // Reentrant call (a second video opened before the first settled):
+ // tear down the previous session's video/listeners in place, but
+ // don't pop the history entry bindClose() already pushed for it --
+ // it's reused below instead of stacking a second one that close()'s
+ // single history.back() could never fully unwind. Also clears the
+ // abandoned session's own loading spinner, since its card would
+ // otherwise never hear about the takeover.
+ const reopening = !!(cp.container && cp.container.classList.contains('open'));
+ if (reopening) {
+ cp.attemptToken++;
+ if (state.hlsPlayer) {
+ state.hlsPlayer.destroy();
+ state.hlsPlayer = null;
}
- const reveal = () => { modal.style.display = 'flex'; };
- const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
- if (!reduce && document.startViewTransition) {
- document.startViewTransition(reveal);
- } else {
- reveal();
+ if (cp.video) {
+ cp.video.onerror = null;
+ cp.video.pause();
}
- document.body.style.overflow = 'hidden';
- } else {
- modal.style.display = 'none';
- document.body.style.overflow = 'auto';
- if (!state.onFullscreenChange) {
- state.onFullscreenChange = () => {
- if (state.playerMode === 'mobile' && !document.fullscreenElement) {
- App.player.close();
- }
- };
- }
- document.addEventListener('fullscreenchange', state.onFullscreenChange);
- if (!state.onWebkitEndFullscreen) {
- state.onWebkitEndFullscreen = () => {
- if (state.playerMode === 'mobile') {
- App.player.close();
- }
- };
- }
- video.addEventListener('webkitendfullscreen', state.onWebkitEndFullscreen);
+ clearIdleTimer();
+ if (cp.originEl) cp.originEl.classList.remove('is-loading');
}
+ runCleanups();
+
+ cp.originEl = opts && opts.originEl ? opts.originEl : null;
+ if (cp.originEl) cp.originEl.classList.add('is-loading');
+
+ cp.container = buildContainer();
+ cp.video = q('.cp-video');
+ cp.formatOverride = null;
+
+ const isLive = !!(source && typeof source === 'object' &&
+ (source.isLive || (source.meta && source.meta.isLive)));
+ cp.container.classList.toggle('is-live', isLive);
+
+ const titleText = q('.cp-title-text');
+ if (titleText) {
+ titleText.textContent = (source && (source.title || (source.meta && source.meta.title))) || '';
+ requestAnimationFrame(measureTitle);
+ }
+
+ // Ambient backdrop: a blurred copy of the poster fills any letterbox
+ // bars behind the contained video.
+ const surface = q('.cp-surface');
+ if (surface) {
+ let poster = (source && (source.thumb || (source.meta && (source.meta.thumbnail || source.meta.thumb)))) || '';
+ if (!poster && cp.originEl) {
+ const img = cp.originEl.querySelector('img');
+ if (img) poster = img.currentSrc || img.src || '';
+ }
+ if (poster) surface.style.setProperty('--poster', `url("${poster.replace(/"/g, '%22')}")`);
+ else surface.style.removeProperty('--poster');
+ }
+
+ bindFavorite(source);
+ bindTimeline(cp.video);
+ bindTransport(cp.video);
+ addCleanup(App.customPlayer.bindFormatMenu(q('.cp-format-btn'), q('.cp-format-menu'), source, (fmt) => {
+ cp.formatOverride = fmt;
+ const resumeAt = cp.video.currentTime || 0;
+ playSources(source, { resumeAt, originEl: cp.originEl });
+ }));
+ bindGestures(cp.video);
+ bindKeyboard(cp.video);
+ bindClose();
+ bindBufferingIndicator(cp.video);
+
+ cp.container.classList.add('open');
+ cp.container.setAttribute('aria-hidden', 'false');
+ document.body.style.overflow = 'hidden';
+ wakeHud();
+
+ playSources(source, { originEl: cp.originEl });
};
- App.player.close = function() {
- const modal = document.getElementById('video-modal');
- const video = document.getElementById('player');
- if (!modal || !video) return;
+ App.player.close = function(opts) {
+ if (!cp.container || !cp.container.classList.contains('open')) return;
+ cp.attemptToken++; // void any in-flight attempt()/HEAD-probe callbacks
if (state.hlsPlayer) {
state.hlsPlayer.destroy();
state.hlsPlayer = null;
}
- if (document.fullscreenElement && document.exitFullscreen) {
- document.exitFullscreen().catch(() => {});
+ if (cp.video) {
+ cp.video.onerror = null;
+ cp.video.pause();
+ cp.video.removeAttribute('src');
+ cp.video.load();
}
- if (state.onFullscreenChange) {
- document.removeEventListener('fullscreenchange', state.onFullscreenChange);
- }
- if (state.onWebkitEndFullscreen) {
- video.removeEventListener('webkitendfullscreen', state.onWebkitEndFullscreen);
- }
- video.onerror = null;
- video.pause();
- video.src = '';
- modal.style.display = 'none';
+ clearIdleTimer();
+ runCleanups();
+
+ cp.container.classList.remove('open', 'cp-hud-idle', 'is-live');
+ cp.container.style.transform = '';
+ cp.container.style.opacity = '';
+ cp.container.setAttribute('aria-hidden', 'true');
document.body.style.overflow = 'auto';
- if (state.playerHome && video.parentElement !== state.playerHome) {
- state.playerHome.appendChild(video);
+
+ if (cp.historyPushed && !(opts && opts.fromPopState)) {
+ cp.historyPushed = false;
+ history.back();
+ } else {
+ cp.historyPushed = false;
}
- state.playerMode = 'modal';
+
+ if (cp.originEl) {
+ cp.originEl.classList.remove('is-loading');
+ cp.originEl = null;
+ }
+ cp.data = null;
+ cp.formatOverride = null;
};
})();
diff --git a/frontend/js/state.js b/frontend/js/state.js
index a09fe2d..0b082b6 100644
--- a/frontend/js/state.js
+++ b/frontend/js/state.js
@@ -10,10 +10,6 @@ App.state = {
hlsPlayer: null,
currentLoadController: null,
errorToastTimer: null,
- playerMode: 'modal',
- playerHome: null,
- onFullscreenChange: null,
- onWebkitEndFullscreen: null,
loadedVideos: [],
feedOpen: false,
feedMuted: true,
diff --git a/frontend/js/ui.js b/frontend/js/ui.js
index 4cef70f..5474ef6 100644
--- a/frontend/js/ui.js
+++ b/frontend/js/ui.js
@@ -612,7 +612,6 @@ App.ui = App.ui || {};
App.ui.bindGlobalHandlers = function() {
window.toggleDrawer = App.ui.toggleDrawer;
window.closeDrawers = App.ui.closeDrawers;
- window.closePlayer = App.player.close;
window.handleSearch = App.videos.handleSearch;
const modeToggleBtn = document.getElementById('mode-toggle-btn');
diff --git a/frontend/js/version.js b/frontend/js/version.js
index dd69874..c121704 100644
--- a/frontend/js/version.js
+++ b/frontend/js/version.js
@@ -49,15 +49,16 @@ App.version = App.version || {};
return changed;
}
- // A reload is "safe" when the user isn't mid-playback: no open video modal,
- // no active reels feed, and no playing