diff --git a/frontend/js/player.js b/frontend/js/player.js index 7135905..603c382 100644 --- a/frontend/js/player.js +++ b/frontend/js/player.js @@ -46,74 +46,34 @@ App.player = App.player || {}; return; } const useMobileFullscreen = isMobilePlayback() || isTvPlayback(); - let playbackStarted = false; if (!state.playerHome) { state.playerHome = video.parentElement; } - // Normalize stream URL + optional referer forwarding. - 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.referer && resolved.url) { - try { - resolved.referer = `${new URL(resolved.url).origin}/`; - } catch (err) { - resolved.referer = ''; + // 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 (!resolved.url) { + if (!sources.length) { if (App.ui && App.ui.showError) { App.ui.showError('Unable to play this stream.'); } clearLoading(); return; } - const refererParam = resolved.referer ? `&referer=${encodeURIComponent(resolved.referer)}` : ''; - const userAgentParam = resolved.userAgent ? `&User-Agent=${encodeURIComponent(resolved.userAgent)}` : ''; - const liveParam = resolved.isLive ? '&live=1' : ''; - const streamUrl = `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}${userAgentParam}${liveParam}`; - 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; - } - - // Cleanup existing player instance to prevent aborted bindings. - 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) { - 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); - } - } if (useMobileFullscreen) { const host = getMobileVideoHost(); @@ -149,107 +109,172 @@ App.player = App.player || {}; } }; - 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; - } - clearLoading(); - if (App.ui && App.ui.showError) { - App.ui.showError('Unable to play this stream.'); - } - App.player.close(); - } - }); - return true; - }; - - if (prefersHls) { - if (!startHls(true)) { - if (video.canPlayType('application/vnd.apple.mpegurl')) { - startNative(); - } else { - console.error("HLS not supported in this browser."); - if (App.ui && App.ui.showError) { - App.ui.showError('HLS is not supported in this browser.'); - } - clearLoading(); - return; - } - } - } else { - startNative(); - } - - video.onerror = () => { - if (!usingHls && canUseHls && !hlsTried && !isDirectMedia) { - if (startHls(true)) return; - } + const failPlayback = (message) => { clearLoading(); if (App.ui && App.ui.showError) { - App.ui.showError('Video failed to load.'); + 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 resolved = sources[index]; + const hasNext = index + 1 < sources.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); + } + }; + + const refererParam = resolved.referer ? `&referer=${encodeURIComponent(resolved.referer)}` : ''; + const userAgentParam = resolved.userAgent ? `&User-Agent=${encodeURIComponent(resolved.userAgent)}` : ''; + const liveParam = resolved.isLive ? '&live=1' : ''; + const streamUrl = `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}${userAgentParam}${liveParam}`; + 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) { + 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') { modal.style.display = 'flex'; document.body.style.overflow = 'hidden'; diff --git a/frontend/js/videos.js b/frontend/js/videos.js index e34b78d..8d08769 100644 --- a/frontend/js/videos.js +++ b/frontend/js/videos.js @@ -565,18 +565,40 @@ App.videos = App.videos || {}; return 0; }; - App.videos.pickBestFormat = function(formats, preferredHeight) { - if (!Array.isArray(formats) || formats.length === 0) return null; - const candidates = formats.filter((fmt) => fmt && fmt.url); - if (!candidates.length) return null; - const videoCandidates = candidates.filter((fmt) => { - const videoExt = String(fmt.video_ext || '').toLowerCase(); - const vcodec = String(fmt.vcodec || '').toLowerCase(); + const headerValue = function(headers, name) { + if (!headers) return ''; + return headers[name] || headers[name.toLowerCase()] || ''; + }; + + const deriveReferer = function(url) { + if (!url) return ''; + try { + return `${new URL(url).origin}/`; + } catch (err) { + return ''; + } + }; + + // Ranks the playable formats best-first so callers can fall back to the + // next candidate when a URL fails. Quality is the primary key; when several + // formats share the same quality the one that appears later in the source + // list is preferred (start with the last one). When a preferred height is + // set, formats at or below it come first (best of those first), followed by + // anything above it ordered closest-to-preferred first as a last resort. + App.videos.rankFormats = function(formats, preferredHeight) { + if (!Array.isArray(formats) || formats.length === 0) return []; + const candidates = formats + .map((fmt, index) => ({ fmt, index })) + .filter((entry) => entry.fmt && entry.fmt.url); + if (!candidates.length) return []; + const videoCandidates = candidates.filter((entry) => { + const videoExt = String(entry.fmt.video_ext || '').toLowerCase(); + const vcodec = String(entry.fmt.vcodec || '').toLowerCase(); if (videoExt && videoExt !== 'none') return true; if (vcodec && vcodec !== 'none') return true; return false; }); - let pool = videoCandidates.length ? videoCandidates : candidates; + const pool = videoCandidates.length ? videoCandidates : candidates; const score = (fmt) => { const height = App.videos.coerceNumber(fmt.height || fmt.quality); const width = App.videos.coerceNumber(fmt.width); @@ -585,89 +607,98 @@ App.videos = App.videos || {}; const fps = App.videos.coerceNumber(fmt.fps); return [size, bitrate, fps]; }; + // Tie-break on the original index so equal-quality formats start with + // the last one in the source list. + const compare = (a, b, descending) => { + const sa = score(a.fmt); + const sb = score(b.fmt); + for (let i = 0; i < sa.length; i++) { + if (sa[i] !== sb[i]) return descending ? sb[i] - sa[i] : sa[i] - sb[i]; + } + return b.index - a.index; + }; if (preferredHeight) { - const atOrBelow = pool.filter((fmt) => { - const size = score(fmt)[0]; - return size > 0 && size <= preferredHeight; + const atOrBelow = []; + const above = []; + pool.forEach((entry) => { + const size = score(entry.fmt)[0]; + if (size > 0 && size <= preferredHeight) { + atOrBelow.push(entry); + } else { + above.push(entry); + } }); - if (atOrBelow.length) { - pool = atOrBelow; - } else { - // Nothing at or below the preferred quality, fall back to the lowest available. - const lowest = pool.reduce((min, fmt) => { - if (!min) return fmt; - return score(fmt)[0] < score(min)[0] ? fmt : min; - }, null); - return lowest; - } + atOrBelow.sort((a, b) => compare(a, b, true)); + above.sort((a, b) => compare(a, b, false)); + return atOrBelow.concat(above).map((entry) => entry.fmt); } - return pool.reduce((best, fmt) => { - if (!best) return fmt; - const bestScore = score(best); - const curScore = score(fmt); - for (let i = 0; i < curScore.length; i++) { - if (curScore[i] > bestScore[i]) return fmt; - if (curScore[i] < bestScore[i]) return best; - } - return best; - }, null); + return pool.slice().sort((a, b) => compare(a, b, true)).map((entry) => entry.fmt); }; - App.videos.resolveStreamSource = function(videoOrUrl, options) { + App.videos.pickBestFormat = function(formats, preferredHeight) { + const ranked = App.videos.rankFormats(formats, preferredHeight); + return ranked.length ? ranked[0] : null; + }; + + // Resolves an ordered list of stream source candidates (best first). The + // player walks this list and falls back to the next entry when a URL fails. + App.videos.resolveStreamSources = function(videoOrUrl, options) { const applyPreferredQuality = !options || options.applyPreferredQuality !== false; - let sourceUrl = ''; - let referer = ''; - let userAgent = ''; const isLive = !!(videoOrUrl && typeof videoOrUrl === 'object' && (videoOrUrl.isLive || (videoOrUrl.meta && videoOrUrl.meta.isLive))); if (typeof videoOrUrl === 'string') { - sourceUrl = videoOrUrl; - } else if (videoOrUrl && typeof videoOrUrl === 'object') { - const meta = videoOrUrl.meta || videoOrUrl; - sourceUrl = meta.url || videoOrUrl.url || ''; - let preferredHeight = null; - if (applyPreferredQuality) { - const preferredQuality = App.storage.getPreferredQuality(); - preferredHeight = preferredQuality === 'auto' ? null : App.videos.coerceNumber(preferredQuality); - } - const best = App.videos.pickBestFormat(meta.formats, preferredHeight); - if (best && best.url) { - sourceUrl = best.url; - if (best.http_headers && (best.http_headers.Referer || best.http_headers.referer)) { - referer = best.http_headers.Referer || best.http_headers.referer; - } - if (best.http_headers && (best.http_headers['User-Agent'] || best.http_headers['user-agent'])) { - userAgent = best.http_headers['User-Agent'] || best.http_headers['user-agent']; - } - } - if (!referer && meta.http_headers && (meta.http_headers.Referer || meta.http_headers.referer)) { - referer = meta.http_headers.Referer || meta.http_headers.referer; - } - if (!userAgent && meta.http_headers && (meta.http_headers['User-Agent'] || meta.http_headers['user-agent'])) { - userAgent = meta.http_headers['User-Agent'] || meta.http_headers['user-agent']; + return videoOrUrl ? [{ url: videoOrUrl, referer: deriveReferer(videoOrUrl), userAgent: '', isLive }] : []; + } + if (!videoOrUrl || typeof videoOrUrl !== 'object') return []; + + const meta = videoOrUrl.meta || videoOrUrl; + const metaReferer = headerValue(meta.http_headers, 'Referer'); + const metaUserAgent = headerValue(meta.http_headers, 'User-Agent'); + let preferredHeight = null; + if (applyPreferredQuality) { + const preferredQuality = App.storage.getPreferredQuality(); + preferredHeight = preferredQuality === 'auto' ? null : App.videos.coerceNumber(preferredQuality); + } + + const sources = App.videos.rankFormats(meta.formats, preferredHeight).map((fmt) => { + const referer = headerValue(fmt.http_headers, 'Referer') || metaReferer || deriveReferer(fmt.url); + const userAgent = headerValue(fmt.http_headers, 'User-Agent') || metaUserAgent; + return { url: fmt.url, referer, userAgent, isLive }; + }); + + if (!sources.length) { + const fallbackUrl = meta.url || videoOrUrl.url || ''; + if (fallbackUrl) { + sources.push({ + url: fallbackUrl, + referer: metaReferer || deriveReferer(fallbackUrl), + userAgent: metaUserAgent, + isLive + }); } } - if (!referer && sourceUrl) { - try { - referer = `${new URL(sourceUrl).origin}/`; - } catch (err) { - referer = ''; - } - } - return { url: sourceUrl, referer, userAgent, isLive }; + return sources; + }; + + App.videos.resolveStreamSource = function(videoOrUrl, options) { + const sources = App.videos.resolveStreamSources(videoOrUrl, options); + return sources.length ? sources[0] : { url: '', referer: '', userAgent: '', isLive: false }; }; // Builds a proxied stream URL. Extra params other than `url` are forwarded // by the backend as request headers, so use real header names here. - App.videos.buildStreamUrl = function(videoOrUrl, options) { - const resolved = App.videos.resolveStreamSource(videoOrUrl, options); - if (!resolved.url) return ''; + App.videos.buildStreamUrlFromSource = function(resolved) { + if (!resolved || !resolved.url) return ''; const refererParam = resolved.referer ? `&referer=${encodeURIComponent(resolved.referer)}` : ''; const userAgentParam = resolved.userAgent ? `&User-Agent=${encodeURIComponent(resolved.userAgent)}` : ''; const liveParam = resolved.isLive ? '&live=1' : ''; return `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}${userAgentParam}${liveParam}`; }; + App.videos.buildStreamUrl = function(videoOrUrl, options) { + return App.videos.buildStreamUrlFromSource(App.videos.resolveStreamSource(videoOrUrl, options)); + }; + App.videos.downloadVideo = function(video) { if (!video) return; const streamUrl = App.videos.buildStreamUrl(video, { applyPreferredQuality: false });