mirror of
https://github.com/movie-web/movie-web.git
synced 2024-06-03 02:48:46 +02:00
525 lines
15 KiB
TypeScript
525 lines
15 KiB
TypeScript
import fscreen from "fscreen";
|
|
import Hls, { Level } from "hls.js";
|
|
|
|
import {
|
|
RULE_IDS,
|
|
isExtensionActiveCached,
|
|
setDomainRule,
|
|
} from "@/backend/extension/messaging";
|
|
import {
|
|
DisplayInterface,
|
|
DisplayInterfaceEvents,
|
|
} from "@/components/player/display/displayInterface";
|
|
import { handleBuffered } from "@/components/player/utils/handleBuffered";
|
|
import { getMediaErrorDetails } from "@/components/player/utils/mediaErrorDetails";
|
|
import { useLanguageStore } from "@/stores/language";
|
|
import {
|
|
LoadableSource,
|
|
SourceQuality,
|
|
getPreferredQuality,
|
|
} from "@/stores/player/utils/qualities";
|
|
import { processCdnLink } from "@/utils/cdn";
|
|
import {
|
|
canChangeVolume,
|
|
canFullscreen,
|
|
canFullscreenAnyElement,
|
|
canPictureInPicture,
|
|
canPlayHlsNatively,
|
|
canWebkitFullscreen,
|
|
canWebkitPictureInPicture,
|
|
} from "@/utils/detectFeatures";
|
|
import { makeEmitter } from "@/utils/events";
|
|
|
|
const levelConversionMap: Record<number, SourceQuality> = {
|
|
360: "360",
|
|
1080: "1080",
|
|
720: "720",
|
|
480: "480",
|
|
};
|
|
|
|
function hlsLevelToQuality(level?: Level): SourceQuality | null {
|
|
return levelConversionMap[level?.height ?? 0] ?? null;
|
|
}
|
|
|
|
function qualityToHlsLevel(quality: SourceQuality): number | null {
|
|
const found = Object.entries(levelConversionMap).find(
|
|
(entry) => entry[1] === quality,
|
|
);
|
|
return found ? +found[0] : null;
|
|
}
|
|
|
|
function hlsLevelsToQualities(levels: Level[]): SourceQuality[] {
|
|
return levels
|
|
.map((v) => hlsLevelToQuality(v))
|
|
.filter((v): v is SourceQuality => !!v);
|
|
}
|
|
|
|
export function makeVideoElementDisplayInterface(): DisplayInterface {
|
|
const { emit, on, off } = makeEmitter<DisplayInterfaceEvents>();
|
|
let source: LoadableSource | null = null;
|
|
let hls: Hls | null = null;
|
|
let videoElement: HTMLVideoElement | null = null;
|
|
let containerElement: HTMLElement | null = null;
|
|
let isFullscreen = false;
|
|
let isPausedBeforeSeeking = false;
|
|
let isSeeking = false;
|
|
let startAt = 0;
|
|
let automaticQuality = false;
|
|
let preferenceQuality: SourceQuality | null = null;
|
|
let lastVolume = 1;
|
|
|
|
const languagePromises = new Map<
|
|
string,
|
|
(value: void | PromiseLike<void>) => void
|
|
>();
|
|
|
|
function reportLevels() {
|
|
if (!hls) return;
|
|
const levels = hls.levels;
|
|
const convertedLevels = levels
|
|
.map((v) => hlsLevelToQuality(v))
|
|
.filter((v): v is SourceQuality => !!v);
|
|
emit("qualities", convertedLevels);
|
|
}
|
|
|
|
function reportAudioTracks() {
|
|
if (!hls) return;
|
|
const currentLanguage = useLanguageStore.getState().language;
|
|
const audioTracks = hls.audioTracks;
|
|
const languageTrack = audioTracks.find((v) => v.lang === currentLanguage);
|
|
if (languageTrack) {
|
|
hls.audioTrack = audioTracks.indexOf(languageTrack);
|
|
}
|
|
const currentTrack = audioTracks?.[hls.audioTrack ?? 0];
|
|
if (!currentTrack) return;
|
|
emit("changedaudiotrack", {
|
|
id: currentTrack.id.toString(),
|
|
label: currentTrack.name,
|
|
language: currentTrack.lang ?? "unknown",
|
|
});
|
|
emit(
|
|
"audiotracks",
|
|
hls.audioTracks.map((v) => ({
|
|
id: v.id.toString(),
|
|
label: v.name,
|
|
language: v.lang ?? "unknown",
|
|
})),
|
|
);
|
|
}
|
|
|
|
function setupQualityForHls() {
|
|
if (videoElement && canPlayHlsNatively(videoElement)) {
|
|
return; // nothing to change
|
|
}
|
|
|
|
if (!hls) return;
|
|
if (!automaticQuality) {
|
|
const qualities = hlsLevelsToQualities(hls.levels);
|
|
const availableQuality = getPreferredQuality(qualities, {
|
|
lastChosenQuality: preferenceQuality,
|
|
automaticQuality,
|
|
});
|
|
if (availableQuality) {
|
|
const levelIndex = hls.levels.findIndex(
|
|
(v) => v.height === qualityToHlsLevel(availableQuality),
|
|
);
|
|
if (levelIndex !== -1) {
|
|
hls.currentLevel = levelIndex;
|
|
hls.loadLevel = levelIndex;
|
|
}
|
|
}
|
|
} else {
|
|
hls.currentLevel = -1;
|
|
hls.loadLevel = -1;
|
|
}
|
|
const quality = hlsLevelToQuality(hls.levels[hls.currentLevel]);
|
|
emit("changedquality", quality);
|
|
}
|
|
|
|
function setupSource(vid: HTMLVideoElement, src: LoadableSource) {
|
|
hls = null;
|
|
if (src.type === "hls") {
|
|
if (canPlayHlsNatively(vid)) {
|
|
vid.src = processCdnLink(src.url);
|
|
vid.currentTime = startAt;
|
|
return;
|
|
}
|
|
|
|
if (!Hls.isSupported()) throw new Error("HLS not supported");
|
|
if (!hls) {
|
|
hls = new Hls({
|
|
maxBufferSize: 500 * 1000 * 1000, // 500 mb of buffering, should load more fragments at once
|
|
fragLoadPolicy: {
|
|
default: {
|
|
maxLoadTimeMs: 30 * 1000, // allow it load extra long, fragments are slow if requested for the first time on an origin
|
|
maxTimeToFirstByteMs: 30 * 1000,
|
|
errorRetry: {
|
|
maxNumRetry: 2,
|
|
retryDelayMs: 1000,
|
|
maxRetryDelayMs: 8000,
|
|
},
|
|
timeoutRetry: {
|
|
maxNumRetry: 3,
|
|
maxRetryDelayMs: 0,
|
|
retryDelayMs: 0,
|
|
},
|
|
},
|
|
},
|
|
renderTextTracksNatively: false,
|
|
});
|
|
hls.on(Hls.Events.ERROR, (event, data) => {
|
|
console.error("HLS error", data);
|
|
if (data.fatal && src?.url === data.frag?.baseurl) {
|
|
emit("error", {
|
|
message: data.error.message,
|
|
stackTrace: data.error.stack,
|
|
errorName: data.error.name,
|
|
type: "hls",
|
|
});
|
|
}
|
|
});
|
|
hls.on(Hls.Events.MANIFEST_LOADED, () => {
|
|
if (!hls) return;
|
|
reportLevels();
|
|
setupQualityForHls();
|
|
reportAudioTracks();
|
|
|
|
if (isExtensionActiveCached()) {
|
|
hls.on(Hls.Events.LEVEL_LOADED, async (_, data) => {
|
|
const chunkUrlsDomains = data.details.fragments.map(
|
|
(v) => new URL(v.url).hostname,
|
|
);
|
|
const chunkUrls = [...new Set(chunkUrlsDomains)];
|
|
|
|
await setDomainRule({
|
|
ruleId: RULE_IDS.SET_DOMAINS_HLS,
|
|
targetDomains: chunkUrls,
|
|
requestHeaders: {
|
|
...src.preferredHeaders,
|
|
...src.headers,
|
|
},
|
|
});
|
|
});
|
|
hls.on(Hls.Events.AUDIO_TRACK_LOADED, async (_, data) => {
|
|
const chunkUrlsDomains = data.details.fragments.map(
|
|
(v) => new URL(v.url).hostname,
|
|
);
|
|
const chunkUrls = [...new Set(chunkUrlsDomains)];
|
|
|
|
await setDomainRule({
|
|
ruleId: RULE_IDS.SET_DOMAINS_HLS_AUDIO,
|
|
targetDomains: chunkUrls,
|
|
requestHeaders: {
|
|
...src.preferredHeaders,
|
|
...src.headers,
|
|
},
|
|
});
|
|
});
|
|
}
|
|
});
|
|
hls.on(Hls.Events.LEVEL_SWITCHED, () => {
|
|
if (!hls) return;
|
|
const quality = hlsLevelToQuality(hls.levels[hls.currentLevel]);
|
|
emit("changedquality", quality);
|
|
});
|
|
hls.on(Hls.Events.SUBTITLE_TRACK_LOADED, () => {
|
|
for (const [lang, resolve] of languagePromises) {
|
|
const track = hls?.subtitleTracks.find((t) => t.lang === lang);
|
|
if (track) {
|
|
resolve();
|
|
languagePromises.delete(lang);
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
hls.attachMedia(vid);
|
|
hls.loadSource(processCdnLink(src.url));
|
|
vid.currentTime = startAt;
|
|
return;
|
|
}
|
|
|
|
vid.src = processCdnLink(src.url);
|
|
vid.currentTime = startAt;
|
|
}
|
|
|
|
function setSource() {
|
|
if (!videoElement || !source) return;
|
|
setupSource(videoElement, source);
|
|
|
|
videoElement.addEventListener("play", () => {
|
|
emit("play", undefined);
|
|
emit("loading", false);
|
|
});
|
|
videoElement.addEventListener("error", () => {
|
|
const err = videoElement?.error ?? null;
|
|
const errorDetails = getMediaErrorDetails(err);
|
|
emit("error", {
|
|
errorName: errorDetails.name,
|
|
key: errorDetails.key,
|
|
type: "htmlvideo",
|
|
});
|
|
});
|
|
videoElement.addEventListener("playing", () => emit("play", undefined));
|
|
videoElement.addEventListener("pause", () => emit("pause", undefined));
|
|
videoElement.addEventListener("canplay", () => emit("loading", false));
|
|
videoElement.addEventListener("waiting", () => emit("loading", true));
|
|
videoElement.addEventListener("volumechange", () =>
|
|
emit("volumechange", videoElement?.muted ? 0 : videoElement?.volume ?? 0),
|
|
);
|
|
videoElement.addEventListener("timeupdate", () =>
|
|
emit("time", videoElement?.currentTime ?? 0),
|
|
);
|
|
videoElement.addEventListener("loadedmetadata", () => {
|
|
if (
|
|
source?.type === "hls" &&
|
|
videoElement &&
|
|
canPlayHlsNatively(videoElement)
|
|
) {
|
|
emit("qualities", ["unknown"]);
|
|
emit("changedquality", "unknown");
|
|
}
|
|
emit("duration", videoElement?.duration ?? 0);
|
|
});
|
|
videoElement.addEventListener("progress", () => {
|
|
if (videoElement)
|
|
emit(
|
|
"buffered",
|
|
handleBuffered(videoElement.currentTime, videoElement.buffered),
|
|
);
|
|
});
|
|
videoElement.addEventListener("webkitendfullscreen", () => {
|
|
isFullscreen = false;
|
|
emit("fullscreen", isFullscreen);
|
|
if (!isFullscreen) emit("needstrack", false);
|
|
});
|
|
videoElement.addEventListener(
|
|
"webkitplaybacktargetavailabilitychanged",
|
|
(e: any) => {
|
|
if (e.availability === "available") {
|
|
emit("canairplay", true);
|
|
}
|
|
},
|
|
);
|
|
videoElement.addEventListener("ratechange", () => {
|
|
if (videoElement) emit("playbackrate", videoElement.playbackRate);
|
|
});
|
|
}
|
|
|
|
function unloadSource() {
|
|
if (videoElement) {
|
|
videoElement.removeAttribute("src");
|
|
videoElement.load();
|
|
}
|
|
if (hls) {
|
|
hls.destroy();
|
|
hls = null;
|
|
}
|
|
}
|
|
|
|
function destroyVideoElement() {
|
|
unloadSource();
|
|
if (videoElement) {
|
|
videoElement = null;
|
|
}
|
|
}
|
|
|
|
function fullscreenChange() {
|
|
isFullscreen =
|
|
!!document.fullscreenElement || // other browsers
|
|
!!(document as any).webkitFullscreenElement; // safari
|
|
emit("fullscreen", isFullscreen);
|
|
if (!isFullscreen) emit("needstrack", false);
|
|
}
|
|
fscreen.addEventListener("fullscreenchange", fullscreenChange);
|
|
|
|
return {
|
|
on,
|
|
off,
|
|
getType() {
|
|
return "web";
|
|
},
|
|
destroy: () => {
|
|
destroyVideoElement();
|
|
fscreen.removeEventListener("fullscreenchange", fullscreenChange);
|
|
},
|
|
load(ops) {
|
|
if (!ops.source) unloadSource();
|
|
automaticQuality = ops.automaticQuality;
|
|
preferenceQuality = ops.preferredQuality;
|
|
source = ops.source;
|
|
emit("loading", true);
|
|
startAt = ops.startAt;
|
|
setSource();
|
|
},
|
|
changeQuality(newAutomaticQuality, newPreferredQuality) {
|
|
if (source?.type !== "hls") return;
|
|
automaticQuality = newAutomaticQuality;
|
|
preferenceQuality = newPreferredQuality;
|
|
setupQualityForHls();
|
|
},
|
|
|
|
processVideoElement(video) {
|
|
destroyVideoElement();
|
|
videoElement = video;
|
|
setSource();
|
|
this.setVolume(lastVolume);
|
|
},
|
|
processContainerElement(container) {
|
|
containerElement = container;
|
|
},
|
|
setMeta() {},
|
|
setCaption() {},
|
|
|
|
pause() {
|
|
videoElement?.pause();
|
|
},
|
|
play() {
|
|
videoElement?.play();
|
|
},
|
|
setSeeking(active) {
|
|
if (active === isSeeking) return;
|
|
isSeeking = active;
|
|
|
|
// if it was playing when starting to seek, play again
|
|
if (!active) {
|
|
if (!isPausedBeforeSeeking) this.play();
|
|
return;
|
|
}
|
|
|
|
isPausedBeforeSeeking = videoElement?.paused ?? true;
|
|
this.pause();
|
|
},
|
|
setTime(t) {
|
|
if (!videoElement) return;
|
|
// clamp time between 0 and max duration
|
|
let time = Math.min(t, videoElement.duration);
|
|
time = Math.max(0, time);
|
|
|
|
if (Number.isNaN(time)) return;
|
|
emit("time", time);
|
|
videoElement.currentTime = time;
|
|
},
|
|
async setVolume(v) {
|
|
// clamp time between 0 and 1
|
|
let volume = Math.min(v, 1);
|
|
volume = Math.max(0, volume);
|
|
|
|
// actually set
|
|
lastVolume = v;
|
|
if (!videoElement) return;
|
|
videoElement.muted = volume === 0; // Muted attribute is always supported
|
|
|
|
// update state
|
|
const isChangeable = await canChangeVolume();
|
|
if (isChangeable) {
|
|
videoElement.volume = volume;
|
|
} else {
|
|
// For browsers where it can't be changed
|
|
emit("volumechange", volume === 0 ? 0 : 1);
|
|
}
|
|
},
|
|
toggleFullscreen() {
|
|
if (isFullscreen) {
|
|
isFullscreen = false;
|
|
emit("fullscreen", isFullscreen);
|
|
emit("needstrack", false);
|
|
if (!fscreen.fullscreenElement) return;
|
|
fscreen.exitFullscreen();
|
|
return;
|
|
}
|
|
|
|
// enter fullscreen
|
|
isFullscreen = true;
|
|
emit("fullscreen", isFullscreen);
|
|
if (!canFullscreen() || fscreen.fullscreenElement) return;
|
|
if (canFullscreenAnyElement()) {
|
|
if (containerElement) fscreen.requestFullscreen(containerElement);
|
|
return;
|
|
}
|
|
if (canWebkitFullscreen()) {
|
|
if (videoElement) {
|
|
emit("needstrack", true);
|
|
(videoElement as any).webkitEnterFullscreen();
|
|
}
|
|
}
|
|
},
|
|
togglePictureInPicture() {
|
|
if (!videoElement) return;
|
|
if (canWebkitPictureInPicture()) {
|
|
const webkitPlayer = videoElement as any;
|
|
webkitPlayer.webkitSetPresentationMode(
|
|
webkitPlayer.webkitPresentationMode === "picture-in-picture"
|
|
? "inline"
|
|
: "picture-in-picture",
|
|
);
|
|
}
|
|
if (canPictureInPicture()) {
|
|
if (videoElement !== document.pictureInPictureElement) {
|
|
videoElement.requestPictureInPicture();
|
|
} else {
|
|
document.exitPictureInPicture();
|
|
}
|
|
}
|
|
},
|
|
startAirplay() {
|
|
const videoPlayer = videoElement as any;
|
|
if (videoPlayer && videoPlayer.webkitShowPlaybackTargetPicker) {
|
|
videoPlayer.webkitShowPlaybackTargetPicker();
|
|
}
|
|
},
|
|
setPlaybackRate(rate) {
|
|
if (videoElement) videoElement.playbackRate = rate;
|
|
},
|
|
getCaptionList() {
|
|
return (
|
|
hls?.subtitleTracks.map((track) => {
|
|
return {
|
|
id: track.id.toString(),
|
|
language: track.lang ?? "unknown",
|
|
url: track.url,
|
|
needsProxy: false,
|
|
hls: true,
|
|
};
|
|
}) ?? []
|
|
);
|
|
},
|
|
getSubtitleTracks() {
|
|
return hls?.subtitleTracks ?? [];
|
|
},
|
|
async setSubtitlePreference(lang) {
|
|
// default subtitles are already loaded by hls.js
|
|
const track = hls?.subtitleTracks.find((t) => t.lang === lang);
|
|
if (track?.details !== undefined) return Promise.resolve();
|
|
|
|
// need to wait a moment before hls loads the subtitles
|
|
const promise = new Promise<void>((resolve, reject) => {
|
|
languagePromises.set(lang, resolve);
|
|
|
|
// reject after some time, if hls.js fails to load the subtitles
|
|
// for any reason
|
|
setTimeout(() => {
|
|
reject();
|
|
languagePromises.delete(lang);
|
|
}, 5000);
|
|
});
|
|
hls?.setSubtitleOption({ lang });
|
|
return promise;
|
|
},
|
|
changeAudioTrack(track) {
|
|
if (!hls) return;
|
|
const audioTrack = hls?.audioTracks.find(
|
|
(t) => t.id.toString() === track.id,
|
|
);
|
|
if (!audioTrack) return;
|
|
hls.audioTrack = hls.audioTracks.indexOf(audioTrack);
|
|
emit("changedaudiotrack", {
|
|
id: audioTrack.id.toString(),
|
|
label: audioTrack.name,
|
|
language: audioTrack.lang ?? "unknown",
|
|
});
|
|
},
|
|
};
|
|
}
|