865 lines
37 KiB
JavaScript
865 lines
37 KiB
JavaScript
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;
|
|
|
|
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);
|
|
}
|
|
container.className = 'custom-player';
|
|
container.innerHTML = `
|
|
<div class="cp-surface">
|
|
<video class="cp-video" playsinline webkit-playsinline></video>
|
|
<div class="cp-flash" aria-hidden="true"></div>
|
|
<div class="cp-spinner" aria-hidden="true"><div class="cp-spinner-ring"></div></div>
|
|
<div class="cp-error" hidden>
|
|
<p class="cp-error-text"></p>
|
|
<div class="cp-error-actions">
|
|
<button class="cp-retry-btn" type="button">Retry</button>
|
|
<a class="cp-open-btn" type="button" target="_blank" rel="noopener noreferrer" hidden>Open in new tab</a>
|
|
</div>
|
|
</div>
|
|
<button class="cp-replay-btn" type="button" aria-label="Replay" hidden>
|
|
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/arrow-path.svg" alt="Replay">
|
|
</button>
|
|
</div>
|
|
<div class="cp-hud">
|
|
<div class="cp-top-bar">
|
|
<button class="cp-close-btn" type="button" aria-label="Close">✕</button>
|
|
<h4 class="cp-title"><span class="cp-title-text"></span></h4>
|
|
<button class="cp-fav-btn favorite-btn" type="button"></button>
|
|
</div>
|
|
<div class="cp-bottom-bar">
|
|
<div class="cp-timeline" role="slider" aria-label="Seek">
|
|
<div class="cp-timeline-track">
|
|
<div class="cp-timeline-buffered"></div>
|
|
<div class="cp-timeline-fill"></div>
|
|
<div class="cp-timeline-handle"></div>
|
|
</div>
|
|
</div>
|
|
<div class="cp-time-row">
|
|
<span class="cp-time cp-time-current">0:00</span>
|
|
<div class="cp-transport">
|
|
<button class="cp-skip-back-btn" type="button" aria-label="Skip back">
|
|
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/backward.svg" alt="">
|
|
<span class="cp-skip-amount">5</span>
|
|
</button>
|
|
<button class="cp-play-btn" type="button" aria-label="Pause">
|
|
<img class="icon-svg cp-play-icon" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/pause.svg" alt="">
|
|
</button>
|
|
<button class="cp-skip-fwd-btn" type="button" aria-label="Skip forward">
|
|
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/forward.svg" alt="">
|
|
<span class="cp-skip-amount">5</span>
|
|
</button>
|
|
</div>
|
|
<span class="cp-time cp-time-duration">0:00</span>
|
|
<div class="cp-secondary-controls">
|
|
<button class="cp-mute-btn" type="button" aria-label="Mute">
|
|
<img class="icon-svg cp-mute-icon" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/speaker-wave.svg" alt="">
|
|
</button>
|
|
<input class="cp-volume-range" type="range" min="0" max="1" step="0.05" value="1" aria-label="Volume">
|
|
<button class="cp-format-btn" type="button" aria-label="Quality" hidden>HD</button>
|
|
<button class="cp-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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="cp-format-menu" hidden></div>
|
|
`;
|
|
return container;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// 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 {
|
|
wrap.classList.remove('has-marquee');
|
|
text.style.removeProperty('--marquee-distance');
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// 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)}%`;
|
|
}
|
|
|
|
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);
|
|
}
|
|
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);
|
|
});
|
|
|
|
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';
|
|
}
|
|
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 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();
|
|
}
|
|
}
|
|
});
|
|
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;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// 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, sourceUrl) {
|
|
showBuffering(false);
|
|
const errorEl = q('.cp-error');
|
|
const textEl = q('.cp-error-text');
|
|
const retryBtn = q('.cp-retry-btn');
|
|
const openBtn = q('.cp-open-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();
|
|
};
|
|
}
|
|
if (openBtn) {
|
|
if (sourceUrl) {
|
|
openBtn.href = sourceUrl;
|
|
openBtn.hidden = false;
|
|
} else {
|
|
openBtn.hidden = true;
|
|
openBtn.removeAttribute('href');
|
|
}
|
|
}
|
|
}
|
|
|
|
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');
|
|
};
|
|
const sourceUrl = (videoData && (videoData.url || (videoData.meta && videoData.meta.url))) || '';
|
|
|
|
if (!sources.length) {
|
|
clearLoading();
|
|
showError('Unable to play this stream.', () => playSources(videoData, opts), sourceUrl);
|
|
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 });
|
|
});
|
|
|
|
const attempt = async (index) => {
|
|
if (token !== cp.attemptToken) return;
|
|
const entry = plan[index];
|
|
const resolved = entry.resolved;
|
|
const hasNext = index + 1 < plan.length;
|
|
let playbackStarted = false;
|
|
let settled = false;
|
|
|
|
const advanceOrFail = (message) => {
|
|
if (settled || token !== cp.attemptToken) return;
|
|
settled = true;
|
|
if (hasNext) attempt(index + 1);
|
|
else {
|
|
clearLoading();
|
|
showError(message, () => playSources(videoData, opts), sourceUrl);
|
|
}
|
|
};
|
|
|
|
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);
|
|
if (resolved.isLive) { isHls = true; isDirectMedia = false; }
|
|
|
|
video.onerror = null;
|
|
if (state.hlsPlayer) {
|
|
state.hlsPlayer.stopLoad();
|
|
state.hlsPlayer.detachMedia();
|
|
state.hlsPlayer.destroy();
|
|
state.hlsPlayer = null;
|
|
}
|
|
video.pause();
|
|
video.removeAttribute('src');
|
|
video.load();
|
|
|
|
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;
|
|
} catch (err) {
|
|
// Best-effort sniff only.
|
|
}
|
|
}
|
|
|
|
const startPlayback = () => {
|
|
if (playbackStarted) return;
|
|
playbackStarted = true;
|
|
clearLoading();
|
|
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(() => {});
|
|
};
|
|
|
|
if (!window.Hls && (isHls || !isDirectMedia)) {
|
|
try { await App.ensureHls(); } catch (err) { /* fall back to native below */ }
|
|
if (token !== cp.attemptToken) return;
|
|
}
|
|
|
|
const canUseHls = !!(window.Hls && window.Hls.isSupported());
|
|
const prefersHls = isHls || (canUseHls && !isDirectMedia && !video.canPlayType('application/vnd.apple.mpegurl'));
|
|
let hlsTried = false;
|
|
let nativeTried = false;
|
|
let usingHls = false;
|
|
|
|
const startNative = () => {
|
|
if (nativeTried) return;
|
|
nativeTried = true;
|
|
usingHls = false;
|
|
video.src = streamUrl;
|
|
startPlayback();
|
|
};
|
|
|
|
const startHls = (allowFallback) => {
|
|
if (!canUseHls || hlsTried) return false;
|
|
hlsTried = true;
|
|
usingHls = true;
|
|
state.hlsPlayer = new window.Hls();
|
|
state.hlsPlayer.loadSource(streamUrl);
|
|
state.hlsPlayer.attachMedia(video);
|
|
state.hlsPlayer.on(window.Hls.Events.MANIFEST_PARSED, () => startPlayback());
|
|
startPlayback();
|
|
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; }
|
|
advanceOrFail('Unable to play this stream.');
|
|
}
|
|
});
|
|
return true;
|
|
};
|
|
|
|
if (prefersHls) {
|
|
if (!startHls(true)) {
|
|
if (video.canPlayType('application/vnd.apple.mpegurl')) startNative();
|
|
else advanceOrFail('HLS is not supported in this browser.');
|
|
}
|
|
} else {
|
|
startNative();
|
|
}
|
|
|
|
video.onerror = () => {
|
|
if (!usingHls && canUseHls && !hlsTried && !isDirectMedia) {
|
|
if (startHls(true)) return;
|
|
}
|
|
advanceOrFail('Video failed to load.');
|
|
};
|
|
};
|
|
|
|
showBuffering(true);
|
|
attempt(0);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// 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;
|
|
}
|
|
if (cp.video) {
|
|
cp.video.onerror = null;
|
|
cp.video.pause();
|
|
}
|
|
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(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 (cp.video) {
|
|
cp.video.onerror = null;
|
|
cp.video.pause();
|
|
cp.video.removeAttribute('src');
|
|
cp.video.load();
|
|
}
|
|
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 (cp.historyPushed && !(opts && opts.fromPopState)) {
|
|
cp.historyPushed = false;
|
|
history.back();
|
|
} else {
|
|
cp.historyPushed = false;
|
|
}
|
|
|
|
if (cp.originEl) {
|
|
cp.originEl.classList.remove('is-loading');
|
|
cp.originEl = null;
|
|
}
|
|
cp.data = null;
|
|
cp.formatOverride = null;
|
|
};
|
|
})();
|