You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
524 lines
15 KiB
524 lines
15 KiB
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", |
|
}); |
|
}, |
|
}; |
|
}
|
|
|