181 lines
5.2 KiB
JavaScript
181 lines
5.2 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();
|
|
if (typeof opts.resumeAt === 'number' && opts.resumeAt > 10) st.time = opts.resumeAt;
|
|
|
|
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(st.time > 10 ? st.time : 0);
|
|
}));
|
|
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(st.time > 10 ? st.time : 0);
|
|
}),
|
|
{ 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 };
|
|
})();
|