mirror of
https://github.com/movie-web/movie-web.git
synced 2024-06-02 21:38:47 +02:00
346 lines
11 KiB
TypeScript
346 lines
11 KiB
TypeScript
import Hls from "hls.js";
|
|
import fscreen from "fscreen";
|
|
import {
|
|
canChangeVolume,
|
|
canFullscreen,
|
|
canFullscreenAnyElement,
|
|
canWebkitFullscreen,
|
|
} from "@/utils/detectFeatures";
|
|
import { MWStreamType } from "@/backend/helpers/streams";
|
|
import { updateInterface } from "@/video/state/logic/interface";
|
|
import { updateSource } from "@/video/state/logic/source";
|
|
import {
|
|
getStoredVolume,
|
|
setStoredVolume,
|
|
} from "@/video/components/hooks/volumeStore";
|
|
import { updateError } from "@/video/state/logic/error";
|
|
import { updateMisc } from "@/video/state/logic/misc";
|
|
import { resetStateForSource } from "@/video/state/providers/helpers";
|
|
import { revokeCaptionBlob } from "@/backend/helpers/captions";
|
|
import { getPlayerState } from "../cache";
|
|
import { updateMediaPlaying } from "../logic/mediaplaying";
|
|
import { VideoPlayerStateProvider } from "./providerTypes";
|
|
import { updateProgress } from "../logic/progress";
|
|
import { handleBuffered } from "./utils";
|
|
|
|
function errorMessage(err: MediaError) {
|
|
switch (err.code) {
|
|
case MediaError.MEDIA_ERR_ABORTED:
|
|
return {
|
|
code: "ABORTED",
|
|
description: "Video was aborted",
|
|
};
|
|
case MediaError.MEDIA_ERR_NETWORK:
|
|
return {
|
|
code: "NETWORK_ERROR",
|
|
description: "A network error occured, the video failed to stream",
|
|
};
|
|
case MediaError.MEDIA_ERR_DECODE:
|
|
return {
|
|
code: "DECODE_ERROR",
|
|
description: "Video stream could not be decoded",
|
|
};
|
|
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
|
|
return {
|
|
code: "SRC_NOT_SUPPORTED",
|
|
description: "The video type is not supported by your browser",
|
|
};
|
|
default:
|
|
return {
|
|
code: "UNKNOWN_ERROR",
|
|
description: "Unknown media error occured",
|
|
};
|
|
}
|
|
}
|
|
|
|
export function createVideoStateProvider(
|
|
descriptor: string,
|
|
playerEl: HTMLVideoElement
|
|
): VideoPlayerStateProvider {
|
|
const player = playerEl;
|
|
const state = getPlayerState(descriptor);
|
|
|
|
return {
|
|
getId() {
|
|
return "video";
|
|
},
|
|
play() {
|
|
player.play();
|
|
},
|
|
pause() {
|
|
player.pause();
|
|
},
|
|
exitFullscreen() {
|
|
if (!fscreen.fullscreenElement) return;
|
|
fscreen.exitFullscreen();
|
|
},
|
|
enterFullscreen() {
|
|
if (!canFullscreen() || fscreen.fullscreenElement) return;
|
|
if (canFullscreenAnyElement()) {
|
|
if (state.wrapperElement)
|
|
fscreen.requestFullscreen(state.wrapperElement);
|
|
return;
|
|
}
|
|
if (canWebkitFullscreen()) {
|
|
(player as any).webkitEnterFullscreen();
|
|
}
|
|
},
|
|
startAirplay() {
|
|
const videoPlayer = player as any;
|
|
if (videoPlayer.webkitShowPlaybackTargetPicker)
|
|
videoPlayer.webkitShowPlaybackTargetPicker();
|
|
},
|
|
setTime(t) {
|
|
// clamp time between 0 and max duration
|
|
let time = Math.min(t, player.duration);
|
|
time = Math.max(0, time);
|
|
|
|
if (Number.isNaN(time)) return;
|
|
|
|
// update state
|
|
player.currentTime = time;
|
|
state.progress.time = time;
|
|
updateProgress(descriptor, state);
|
|
},
|
|
setSeeking(active) {
|
|
state.mediaPlaying.isSeeking = active;
|
|
state.mediaPlaying.isDragSeeking = active;
|
|
updateMediaPlaying(descriptor, state);
|
|
|
|
// if it was playing when starting to seek, play again
|
|
if (!active) {
|
|
if (!state.pausedWhenSeeking) this.play();
|
|
return;
|
|
}
|
|
|
|
// when seeking we pause the video
|
|
// this variables isnt reactive, just used so the state can be remembered next unseek
|
|
state.pausedWhenSeeking = state.mediaPlaying.isPaused;
|
|
this.pause();
|
|
},
|
|
async setVolume(v) {
|
|
// clamp time between 0 and 1
|
|
let volume = Math.min(v, 1);
|
|
volume = Math.max(0, volume);
|
|
|
|
// update state
|
|
if (await canChangeVolume()) player.volume = volume;
|
|
state.mediaPlaying.volume = volume;
|
|
updateMediaPlaying(descriptor, state);
|
|
|
|
// update localstorage
|
|
setStoredVolume(volume);
|
|
},
|
|
setSource(source) {
|
|
if (!source) {
|
|
resetStateForSource(descriptor, state);
|
|
player.removeAttribute("src");
|
|
player.load();
|
|
state.source = null;
|
|
updateSource(descriptor, state);
|
|
return;
|
|
}
|
|
|
|
// reset before assign new one so the old HLS instance gets destroyed
|
|
resetStateForSource(descriptor, state);
|
|
|
|
if (source?.type === MWStreamType.HLS) {
|
|
if (player.canPlayType("application/vnd.apple.mpegurl")) {
|
|
// HLS supported natively by browser
|
|
player.src = source.source;
|
|
} else {
|
|
// HLS through HLS.js
|
|
if (!Hls.isSupported()) {
|
|
state.error = {
|
|
name: `Not supported`,
|
|
description: "Your browser does not support HLS video",
|
|
};
|
|
updateError(descriptor, state);
|
|
return;
|
|
}
|
|
|
|
const hls = new Hls({ enableWorker: false });
|
|
state.hlsInstance = hls;
|
|
|
|
hls.on(Hls.Events.ERROR, (event, data) => {
|
|
if (data.fatal) {
|
|
state.error = {
|
|
name: `error ${data.details}`,
|
|
description: data.error?.message ?? "Something went wrong",
|
|
};
|
|
updateError(descriptor, state);
|
|
}
|
|
console.error("HLS error", data);
|
|
});
|
|
|
|
hls.attachMedia(player);
|
|
hls.loadSource(source.source);
|
|
}
|
|
} else if (source.type === MWStreamType.MP4) {
|
|
// standard MP4 stream
|
|
player.src = source.source;
|
|
}
|
|
|
|
// update state
|
|
state.source = {
|
|
quality: source.quality,
|
|
type: source.type,
|
|
url: source.source,
|
|
caption: null,
|
|
};
|
|
updateSource(descriptor, state);
|
|
},
|
|
setCaption(id, url) {
|
|
if (state.source) {
|
|
revokeCaptionBlob(state.source.caption?.url);
|
|
state.source.caption = {
|
|
id,
|
|
url,
|
|
};
|
|
updateSource(descriptor, state);
|
|
}
|
|
},
|
|
clearCaption() {
|
|
if (state.source) {
|
|
revokeCaptionBlob(state.source.caption?.url);
|
|
state.source.caption = null;
|
|
updateSource(descriptor, state);
|
|
}
|
|
},
|
|
providerStart() {
|
|
this.setVolume(getStoredVolume());
|
|
|
|
const pause = () => {
|
|
state.mediaPlaying.isPaused = true;
|
|
state.mediaPlaying.isPlaying = false;
|
|
updateMediaPlaying(descriptor, state);
|
|
};
|
|
const playing = () => {
|
|
state.mediaPlaying.isPaused = false;
|
|
state.mediaPlaying.isPlaying = true;
|
|
state.mediaPlaying.isLoading = false;
|
|
state.mediaPlaying.hasPlayedOnce = true;
|
|
updateMediaPlaying(descriptor, state);
|
|
};
|
|
const waiting = () => {
|
|
state.mediaPlaying.isLoading = true;
|
|
updateMediaPlaying(descriptor, state);
|
|
};
|
|
const seeking = () => {
|
|
state.mediaPlaying.isSeeking = true;
|
|
updateMediaPlaying(descriptor, state);
|
|
};
|
|
const seeked = () => {
|
|
state.mediaPlaying.isSeeking = false;
|
|
updateMediaPlaying(descriptor, state);
|
|
};
|
|
const loadedmetadata = () => {
|
|
state.progress.duration = player.duration;
|
|
updateProgress(descriptor, state);
|
|
};
|
|
const timeupdate = () => {
|
|
state.progress.duration = player.duration;
|
|
state.progress.time = player.currentTime;
|
|
updateProgress(descriptor, state);
|
|
};
|
|
const progress = () => {
|
|
state.progress.buffered = handleBuffered(
|
|
player.currentTime,
|
|
player.buffered
|
|
);
|
|
updateProgress(descriptor, state);
|
|
};
|
|
const canplay = () => {
|
|
state.mediaPlaying.isFirstLoading = false;
|
|
state.mediaPlaying.isLoading = false;
|
|
updateMediaPlaying(descriptor, state);
|
|
};
|
|
const fullscreenchange = () => {
|
|
state.interface.isFullscreen = !!document.fullscreenElement;
|
|
updateInterface(descriptor, state);
|
|
};
|
|
const volumechange = async () => {
|
|
if (await canChangeVolume()) {
|
|
state.mediaPlaying.volume = player.volume;
|
|
updateMediaPlaying(descriptor, state);
|
|
}
|
|
};
|
|
const isFocused = (evt: any) => {
|
|
state.interface.isFocused = evt.type !== "mouseleave";
|
|
updateInterface(descriptor, state);
|
|
};
|
|
const canAirplay = (e: any) => {
|
|
if (e.availability === "available") {
|
|
state.canAirplay = true;
|
|
updateMisc(descriptor, state);
|
|
}
|
|
};
|
|
const error = () => {
|
|
if (player.error) {
|
|
const err = errorMessage(player.error);
|
|
console.error("Native video player threw error", player.error);
|
|
state.error = {
|
|
description: err.description,
|
|
name: `Error ${err.code}`,
|
|
};
|
|
this.pause(); // stop video from playing
|
|
} else {
|
|
state.error = null;
|
|
}
|
|
updateError(descriptor, state);
|
|
};
|
|
|
|
state.wrapperElement?.addEventListener("click", isFocused);
|
|
state.wrapperElement?.addEventListener("mouseenter", isFocused);
|
|
state.wrapperElement?.addEventListener("mouseleave", isFocused);
|
|
player.addEventListener("volumechange", volumechange);
|
|
player.addEventListener("pause", pause);
|
|
player.addEventListener("playing", playing);
|
|
player.addEventListener("seeking", seeking);
|
|
player.addEventListener("seeked", seeked);
|
|
player.addEventListener("progress", progress);
|
|
player.addEventListener("waiting", waiting);
|
|
player.addEventListener("timeupdate", timeupdate);
|
|
player.addEventListener("loadedmetadata", loadedmetadata);
|
|
player.addEventListener("canplay", canplay);
|
|
fscreen.addEventListener("fullscreenchange", fullscreenchange);
|
|
player.addEventListener("error", error);
|
|
player.addEventListener(
|
|
"webkitplaybacktargetavailabilitychanged",
|
|
canAirplay
|
|
);
|
|
|
|
if (state.source)
|
|
this.setSource({
|
|
quality: state.source.quality,
|
|
source: state.source.url,
|
|
type: state.source.type,
|
|
});
|
|
|
|
return {
|
|
destroy: () => {
|
|
player.removeEventListener("pause", pause);
|
|
player.removeEventListener("playing", playing);
|
|
player.removeEventListener("seeking", seeking);
|
|
player.removeEventListener("volumechange", volumechange);
|
|
player.removeEventListener("seeked", seeked);
|
|
player.removeEventListener("timeupdate", timeupdate);
|
|
player.removeEventListener("loadedmetadata", loadedmetadata);
|
|
player.removeEventListener("progress", progress);
|
|
player.removeEventListener("waiting", waiting);
|
|
player.removeEventListener("error", error);
|
|
player.removeEventListener("canplay", canplay);
|
|
fscreen.removeEventListener("fullscreenchange", fullscreenchange);
|
|
state.wrapperElement?.removeEventListener("click", isFocused);
|
|
state.wrapperElement?.removeEventListener("mouseenter", isFocused);
|
|
state.wrapperElement?.removeEventListener("mouseleave", isFocused);
|
|
player.removeEventListener(
|
|
"webkitplaybacktargetavailabilitychanged",
|
|
canAirplay
|
|
);
|
|
},
|
|
};
|
|
},
|
|
};
|
|
}
|