Files
jacuzzi/frontend/js/player.js
2026-07-06 15:32:31 +00:00

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;
};
})();