Files
tv/web/js/player.js
T
2026-08-26 18:28:49 -05:00

183 lines
5.3 KiB
JavaScript

/*
PLAYER — plays a stream in a <video> element and supports switching
sources mid-watch without losing your place.
Pipeline per stream:
- hls.js for .m3u8 when available
- native HLS for Safari (canPlayType check)
- direct <video src> for mp4/webm
- everything else (DASH, mkv, magnet, …) → friendly error
Every media + subtitle URL is routed through the API proxy.
On source switch we carry over currentTime, playbackRate, volume, muted
and the playing state, clamped to the new duration.
*/
(function () {
'use strict';
function create(video, handlers = {}) {
const onStatus = handlers.onStatus || (() => {});
const onError = handlers.onError || (() => {});
const onReady = handlers.onReady || (() => {});
let hls = null;
let token = 0; // guards against overlapping loads
let networkRetry = false;
function teardown() {
token++;
if (hls) {
try {
hls.destroy();
} catch (e) {
/* already destroyed */
}
hls = null;
}
for (const track of video.querySelectorAll('track')) track.remove();
video.removeAttribute('src');
try {
video.load();
} catch (e) {
/* ignore */
}
}
function capture() {
const v = video;
return {
time: isFinite(v.currentTime) && v.currentTime > 0 ? v.currentTime : 0,
rate: v.playbackRate || 1,
volume: v.volume,
muted: v.muted,
playing: !v.paused && !v.ended,
};
}
function restore(st) {
const v = video;
v.playbackRate = st.rate;
v.volume = st.volume;
v.muted = st.muted;
const seek = () => {
const d = v.duration;
let t = st.time;
if (isFinite(d) && d > 0) t = Math.min(t, Math.max(0, d - 5));
if (t > 10) v.currentTime = t;
if (st.playing) v.play().catch(() => {});
};
if (isFinite(v.duration) && v.duration > 0) seek();
else v.addEventListener('loadedmetadata', seek, { once: true });
}
function attachSubtitles(stream) {
for (const sub of stream.subtitles || []) {
let resolved;
try {
resolved = new URL(sub.url, stream.url).toString();
} catch (e) {
continue;
}
const track = document.createElement('track');
track.kind = 'subtitles';
track.srclang = sub.language || 'en';
track.label = sub.name || sub.language || 'Subtitles';
track.src = SR.api.proxyUrl(resolved, stream.headers);
video.appendChild(track);
}
}
function load(stream, opts = {}) {
const st = capture();
const explicitResume = typeof opts.resumeAt === 'number' && opts.resumeAt > 10;
if (typeof opts.resumeAt === 'number') st.time = Math.max(0, opts.resumeAt);
const readyAt = () => (explicitResume ? st.time : 0);
teardown();
networkRetry = false;
const myToken = token;
const guarded = (fn) => () => {
if (myToken === token) fn();
};
const url = stream.url || '';
const isHls = stream.type === 'm3u8' || /\.m3u8($|\?)/i.test(url);
const isDash = stream.type === 'dash' || /\.mpd($|\?)/i.test(url);
const isDirect =
stream.type === 'mp4' ||
stream.type === 'webm' ||
/\.(mp4|m4v|webm)(\?|#|$)/i.test(url);
if (isDash) {
onError('DASH streams are not supported yet — pick another source.');
return;
}
if (!isHls && !isDirect) {
onError(`The browser can't play ${stream.type} files directly — pick an MP4 or HLS source.`);
return;
}
attachSubtitles(stream);
const proxied = SR.api.proxyUrl(url, stream.headers);
if (isHls && window.Hls && Hls.isSupported()) {
onStatus('Loading stream…');
const h = new Hls({ enableWorker: true });
hls = h;
h.loadSource(proxied);
h.attachMedia(video);
h.on(Hls.Events.MANIFEST_PARSED, guarded(() => {
restore(st);
onReady(readyAt());
}));
h.on(Hls.Events.ERROR, (evt, data) => {
if (myToken !== token || !data.fatal) return;
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
if (!networkRetry) {
networkRetry = true;
setTimeout(() => {
networkRetry = false;
if (myToken === token && hls === h) h.startLoad();
}, 2000);
}
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
h.recoverMediaError();
} else {
onError('Playback failed: ' + (data.details || 'unknown stream error'));
teardown();
}
});
return;
}
// native HLS (Safari) or direct file
onStatus('Loading stream…');
video.src = proxied;
video.addEventListener(
'loadedmetadata',
guarded(() => {
restore(st);
onReady(readyAt());
}),
{ once: true }
);
video.addEventListener(
'error',
guarded(() => {
onError('Could not load this stream — try another source.');
}),
{ once: true }
);
}
function destroy() {
teardown();
}
return { load, destroy };
}
window.SR = window.SR || {};
SR.player = { create };
})();