Layered progressive polish on the warm classic + brass theme: - Card entrance animation on first mount (virtualizer-aware) - Cursor-tracking brass spotlight border on cards - Thumbnail skeleton shimmer until the poster paints - Hover video preview after a short dwell (only when formats resolved) - View Transition + blurred-poster ambient backdrop on player open - Favorite heart pop + expanding ring on add - ⌘K command palette (search, theme, density, reels, source/channel) - Scroll-progress bar + back-to-top FAB - Grid density toggle (comfortable/compact) - Reels HUD: serif title, brass scrubber, muted-state pulse All new motion respects prefers-reduced-motion; no JS/HTML structure changes to the core grid/feed. New glue lives in frontend/js/enhance.js. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
368 lines
15 KiB
JavaScript
368 lines
15 KiB
JavaScript
window.App = window.App || {};
|
|
App.player = App.player || {};
|
|
|
|
(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 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;
|
|
}
|
|
|
|
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);
|
|
} 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];
|
|
}
|
|
if (!sources.length) {
|
|
if (App.ui && App.ui.showError) {
|
|
App.ui.showError('Unable to play this stream.');
|
|
}
|
|
clearLoading();
|
|
return;
|
|
}
|
|
|
|
// 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 });
|
|
}
|
|
playbackPlan.push({ resolved, direct: false });
|
|
});
|
|
|
|
if (useMobileFullscreen) {
|
|
const host = getMobileVideoHost();
|
|
if (video.parentElement !== host) {
|
|
host.appendChild(video);
|
|
}
|
|
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;
|
|
}
|
|
|
|
const requestFullscreen = () => {
|
|
if (state.playerMode !== 'mobile') return;
|
|
if (typeof video.webkitEnterFullscreen === 'function') {
|
|
try {
|
|
video.webkitEnterFullscreen();
|
|
} catch (err) {
|
|
// Ignore if fullscreen is not allowed.
|
|
}
|
|
return;
|
|
}
|
|
if (video.requestFullscreen) {
|
|
video.requestFullscreen().catch(() => {});
|
|
}
|
|
};
|
|
|
|
const failPlayback = (message) => {
|
|
clearLoading();
|
|
if (App.ui && App.ui.showError) {
|
|
App.ui.showError(message);
|
|
}
|
|
App.player.close();
|
|
};
|
|
|
|
// 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];
|
|
const resolved = entry.resolved;
|
|
const hasNext = index + 1 < playbackPlan.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;
|
|
settled = true;
|
|
if (hasNext) {
|
|
attempt(index + 1);
|
|
} else {
|
|
failPlayback(message);
|
|
}
|
|
};
|
|
|
|
// 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 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;
|
|
}
|
|
|
|
// 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();
|
|
state.hlsPlayer.detachMedia();
|
|
state.hlsPlayer.destroy();
|
|
state.hlsPlayer = null;
|
|
}
|
|
|
|
// Reset the video element before re-binding a new source.
|
|
video.pause();
|
|
video.removeAttribute('src');
|
|
video.load();
|
|
|
|
if (!isHls && !entry.direct) {
|
|
try {
|
|
const headResp = await fetch(streamUrl, { method: 'HEAD' });
|
|
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) {
|
|
console.warn('Failed to detect stream type', err);
|
|
}
|
|
}
|
|
|
|
const startPlayback = () => {
|
|
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 });
|
|
}
|
|
}
|
|
};
|
|
|
|
// 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.
|
|
}
|
|
}
|
|
|
|
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, function() {
|
|
startPlayback();
|
|
});
|
|
startPlayback();
|
|
state.hlsPlayer.on(window.Hls.Events.ERROR, function(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 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;
|
|
}
|
|
}
|
|
} else {
|
|
startNative();
|
|
}
|
|
|
|
video.onerror = () => {
|
|
if (!usingHls && canUseHls && !hlsTried && !isDirectMedia) {
|
|
if (startHls(true)) return;
|
|
}
|
|
advanceOrFail('Video failed to load.');
|
|
};
|
|
};
|
|
|
|
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');
|
|
}
|
|
const reveal = () => { modal.style.display = 'flex'; };
|
|
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
if (!reduce && document.startViewTransition) {
|
|
document.startViewTransition(reveal);
|
|
} else {
|
|
reveal();
|
|
}
|
|
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);
|
|
}
|
|
};
|
|
|
|
App.player.close = function() {
|
|
const modal = document.getElementById('video-modal');
|
|
const video = document.getElementById('player');
|
|
if (!modal || !video) return;
|
|
|
|
if (state.hlsPlayer) {
|
|
state.hlsPlayer.destroy();
|
|
state.hlsPlayer = null;
|
|
}
|
|
if (document.fullscreenElement && document.exitFullscreen) {
|
|
document.exitFullscreen().catch(() => {});
|
|
}
|
|
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';
|
|
document.body.style.overflow = 'auto';
|
|
if (state.playerHome && video.parentElement !== state.playerHome) {
|
|
state.playerHome.appendChild(video);
|
|
}
|
|
state.playerMode = 'modal';
|
|
};
|
|
})();
|