1
0
Fork 0
mirror of https://github.com/sussy-code/smov.git synced 2024-12-25 15:27:40 +01:00
smov/src/components/player/display/base.ts

370 lines
11 KiB
TypeScript
Raw Normal View History

import fscreen from "fscreen";
2023-10-19 16:59:55 +02:00
import Hls, { Level } from "hls.js";
2023-09-30 20:57:00 +02:00
import {
DisplayInterface,
DisplayInterfaceEvents,
} from "@/components/player/display/displayInterface";
import { handleBuffered } from "@/components/player/utils/handleBuffered";
import { getMediaErrorDetails } from "@/components/player/utils/mediaErrorDetails";
import {
LoadableSource,
SourceQuality,
getPreferredQuality,
} from "@/stores/player/utils/qualities";
import {
canChangeVolume,
canFullscreen,
canFullscreenAnyElement,
canPictureInPicture,
canWebkitFullscreen,
canWebkitPictureInPicture,
} from "@/utils/detectFeatures";
2023-09-30 20:57:00 +02:00
import { makeEmitter } from "@/utils/events";
2023-10-19 16:59:55 +02:00
const levelConversionMap: Record<number, SourceQuality> = {
360: "360",
1080: "1080",
720: "720",
480: "480",
};
function hlsLevelToQuality(level: Level): SourceQuality | null {
return levelConversionMap[level.height] ?? 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);
}
2023-09-30 20:57:00 +02:00
export function makeVideoElementDisplayInterface(): DisplayInterface {
const { emit, on, off } = makeEmitter<DisplayInterfaceEvents>();
let source: LoadableSource | null = null;
let hls: Hls | null = null;
2023-09-30 20:57:00 +02:00
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;
2023-09-30 20:57:00 +02:00
2023-10-19 16:59:55 +02:00
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 setupQualityForHls() {
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) {
if (src.type === "hls") {
if (!Hls.isSupported()) throw new Error("HLS not supported");
if (!hls) {
2023-10-29 23:34:16 +01:00
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,
},
},
},
});
hls.on(Hls.Events.ERROR, (event, data) => {
console.error("HLS error", data);
if (data.fatal) {
emit("error", {
message: data.error.message,
stackTrace: data.error.stack,
errorName: data.error.name,
type: "hls",
});
}
});
2023-10-19 16:59:55 +02:00
hls.on(Hls.Events.MANIFEST_LOADED, () => {
if (!hls) return;
reportLevels();
setupQualityForHls();
2023-10-19 16:59:55 +02:00
});
hls.on(Hls.Events.LEVEL_SWITCHED, () => {
if (!hls) return;
const quality = hlsLevelToQuality(hls.levels[hls.currentLevel]);
2023-10-19 17:19:57 +02:00
emit("changedquality", quality);
2023-10-19 16:59:55 +02:00
});
}
hls.attachMedia(vid);
hls.loadSource(src.url);
vid.currentTime = startAt;
return;
}
vid.src = src.url;
vid.currentTime = startAt;
}
2023-09-30 20:57:00 +02:00
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,
message: errorDetails.message,
type: "htmlvideo",
});
});
videoElement.addEventListener("playing", () => emit("play", undefined));
2023-09-30 20:57:00 +02:00
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", () => {
emit("duration", videoElement?.duration ?? 0);
});
videoElement.addEventListener("progress", () => {
if (videoElement)
emit(
"buffered",
handleBuffered(videoElement.currentTime, videoElement.buffered)
);
});
2023-10-18 17:14:59 +02:00
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);
});
2023-09-30 20:57:00 +02:00
}
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
2023-10-18 17:14:59 +02:00
emit("fullscreen", isFullscreen);
if (!isFullscreen) emit("needstrack", false);
}
fscreen.addEventListener("fullscreenchange", fullscreenChange);
2023-09-30 20:57:00 +02:00
return {
on,
off,
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;
2023-09-30 20:57:00 +02:00
setSource();
},
changeQuality(newAutomaticQuality, newPreferredQuality) {
if (source?.type !== "hls") return;
automaticQuality = newAutomaticQuality;
preferenceQuality = newPreferredQuality;
setupQualityForHls();
},
2023-09-30 20:57:00 +02:00
processVideoElement(video) {
destroyVideoElement();
2023-09-30 20:57:00 +02:00
videoElement = video;
setSource();
this.setVolume(lastVolume);
2023-09-30 20:57:00 +02:00
},
processContainerElement(container) {
containerElement = container;
},
2023-09-30 20:57:00 +02:00
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);
2023-10-18 15:17:46 +02:00
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()) {
2023-10-18 15:17:46 +02:00
if (videoElement) {
emit("needstrack", true);
(videoElement as any).webkitEnterFullscreen();
}
}
},
2023-10-21 19:37:44 +02:00
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;
},
2023-09-30 20:57:00 +02:00
};
}