diff --git a/src/video/components/actions/LoadingAction.tsx b/src/video/components/actions/LoadingAction.tsx
new file mode 100644
index 00000000..0bbe6072
--- /dev/null
+++ b/src/video/components/actions/LoadingAction.tsx
@@ -0,0 +1,14 @@
+import { Spinner } from "@/components/layout/Spinner";
+import { useVideoPlayerDescriptor } from "@/video/state/hooks";
+import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
+
+export function LoadingAction() {
+ const descriptor = useVideoPlayerDescriptor();
+ const mediaPlaying = useMediaPlaying(descriptor);
+
+ const isLoading = mediaPlaying.isFirstLoading || mediaPlaying.isLoading;
+
+ if (!isLoading) return null;
+
+ return ;
+}
diff --git a/src/video/components/actions/MiddlePauseAction.tsx b/src/video/components/actions/MiddlePauseAction.tsx
new file mode 100644
index 00000000..1ed2fefc
--- /dev/null
+++ b/src/video/components/actions/MiddlePauseAction.tsx
@@ -0,0 +1,32 @@
+import { Icon, Icons } from "@/components/Icon";
+import { useVideoPlayerDescriptor } from "@/video/state/hooks";
+import { useControls } from "@/video/state/logic/controls";
+import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
+import { useCallback } from "react";
+
+export function MiddlePauseAction() {
+ const descriptor = useVideoPlayerDescriptor();
+ const controls = useControls(descriptor);
+ const mediaPlaying = useMediaPlaying(descriptor);
+
+ const handleClick = useCallback(() => {
+ if (mediaPlaying?.isPlaying) controls.pause();
+ else controls.play();
+ }, [controls, mediaPlaying]);
+
+ if (mediaPlaying.hasPlayedOnce) return null;
+ if (mediaPlaying.isPlaying) return null;
+ if (mediaPlaying.isFirstLoading) return null;
+
+ return (
+
+
+
+ );
+}
diff --git a/src/video/components/actions/ProgressAction.tsx b/src/video/components/actions/ProgressAction.tsx
new file mode 100644
index 00000000..096985ae
--- /dev/null
+++ b/src/video/components/actions/ProgressAction.tsx
@@ -0,0 +1,81 @@
+import {
+ makePercentage,
+ makePercentageString,
+ useProgressBar,
+} from "@/hooks/useProgressBar";
+import { useVideoPlayerDescriptor } from "@/video/state/hooks";
+import { useControls } from "@/video/state/logic/controls";
+import { useProgress } from "@/video/state/logic/progress";
+import { useCallback, useEffect, useRef } from "react";
+
+export function ProgressAction() {
+ const descriptor = useVideoPlayerDescriptor();
+ const controls = useControls(descriptor);
+ const videoTime = useProgress(descriptor);
+ const ref = useRef(null);
+ const dragRef = useRef(false);
+
+ const commitTime = useCallback(
+ (percentage) => {
+ controls.setTime(percentage * videoTime.duration);
+ },
+ [controls, videoTime]
+ );
+ const { dragging, dragPercentage, dragMouseDown } = useProgressBar(
+ ref,
+ commitTime
+ );
+
+ // TODO make dragging update timer
+ useEffect(() => {
+ if (dragRef.current === dragging) return;
+ dragRef.current = dragging;
+ controls.setSeeking(dragging);
+ }, [dragRef, dragging, controls]);
+
+ let watchProgress = makePercentageString(
+ makePercentage((videoTime.time / videoTime.duration) * 100)
+ );
+ if (dragging)
+ watchProgress = makePercentageString(makePercentage(dragPercentage));
+
+ const bufferProgress = makePercentageString(
+ makePercentage((videoTime.buffered / videoTime.duration) * 100)
+ );
+
+ return (
+
+ );
+}
diff --git a/src/video/components/actions/SkipTimeAction.tsx b/src/video/components/actions/SkipTimeAction.tsx
new file mode 100644
index 00000000..117d31f0
--- /dev/null
+++ b/src/video/components/actions/SkipTimeAction.tsx
@@ -0,0 +1,48 @@
+import { Icons } from "@/components/Icon";
+import { useVideoPlayerDescriptor } from "@/video/state/hooks";
+import { useControls } from "@/video/state/logic/controls";
+import { useProgress } from "@/video/state/logic/progress";
+import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
+
+interface Props {
+ className?: string;
+}
+
+export function SkipTimeBackwardAction() {
+ const descriptor = useVideoPlayerDescriptor();
+ const controls = useControls(descriptor);
+ const videoTime = useProgress(descriptor);
+
+ const skipBackward = () => {
+ controls.setTime(videoTime.time - 10);
+ };
+
+ return (
+
+ );
+}
+
+export function SkipTimeForwardAction() {
+ const descriptor = useVideoPlayerDescriptor();
+ const controls = useControls(descriptor);
+ const videoTime = useProgress(descriptor);
+
+ const skipForward = () => {
+ controls.setTime(videoTime.time + 10);
+ };
+
+ return (
+
+ );
+}
+
+export function SkipTimeAction(props: Props) {
+ return (
+
+ );
+}
diff --git a/src/video/components/actions/TimeAction.tsx b/src/video/components/actions/TimeAction.tsx
new file mode 100644
index 00000000..cd1caadc
--- /dev/null
+++ b/src/video/components/actions/TimeAction.tsx
@@ -0,0 +1,50 @@
+import { useVideoPlayerDescriptor } from "@/video/state/hooks";
+import { useProgress } from "@/video/state/logic/progress";
+
+function durationExceedsHour(secs: number): boolean {
+ return secs > 60 * 60;
+}
+
+function formatSeconds(secs: number, showHours = false): string {
+ if (Number.isNaN(secs)) {
+ if (showHours) return "0:00:00";
+ return "0:00";
+ }
+
+ let time = secs;
+ const seconds = Math.floor(time % 60);
+
+ time /= 60;
+ const minutes = Math.floor(time % 60);
+
+ time /= 60;
+ const hours = Math.floor(time);
+
+ const paddedSecs = seconds.toString().padStart(2, "0");
+ const paddedMins = minutes.toString().padStart(2, "0");
+
+ if (!showHours) return [paddedMins, paddedSecs].join(":");
+ return [hours, paddedMins, paddedSecs].join(":");
+}
+
+interface Props {
+ className?: string;
+ noDuration?: boolean;
+}
+
+export function TimeAction(props: Props) {
+ const descriptor = useVideoPlayerDescriptor();
+ const videoTime = useProgress(descriptor);
+
+ const hasHours = durationExceedsHour(videoTime.duration);
+ const time = formatSeconds(videoTime.time, hasHours);
+ const duration = formatSeconds(videoTime.duration, hasHours);
+
+ return (
+
+
+ {time} {props.noDuration ? "" : `/ ${duration}`}
+
+
+ );
+}
diff --git a/src/video/components/controllers/SourceController.tsx b/src/video/components/controllers/SourceController.tsx
new file mode 100644
index 00000000..af3f715d
--- /dev/null
+++ b/src/video/components/controllers/SourceController.tsx
@@ -0,0 +1,24 @@
+import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
+import { useVideoPlayerDescriptor } from "@/video/state/hooks";
+import { useControls } from "@/video/state/logic/controls";
+import { useEffect, useRef } from "react";
+
+interface SourceControllerProps {
+ source: string;
+ type: MWStreamType;
+ quality: MWStreamQuality;
+}
+
+export function SourceController(props: SourceControllerProps) {
+ const descriptor = useVideoPlayerDescriptor();
+ const controls = useControls(descriptor);
+ const didInitialize = useRef(false);
+
+ useEffect(() => {
+ if (didInitialize.current) return;
+ controls.setSource(props);
+ didInitialize.current = true;
+ }, [props, controls]);
+
+ return null;
+}
diff --git a/src/video/components/internal/VideoElementInternal.tsx b/src/video/components/internal/VideoElementInternal.tsx
index d084b1ac..7a8f1219 100644
--- a/src/video/components/internal/VideoElementInternal.tsx
+++ b/src/video/components/internal/VideoElementInternal.tsx
@@ -19,12 +19,6 @@ export function VideoElementInternal() {
}, [descriptor]);
// TODO autoplay and muted
- return (
-
- );
+ // this element is remotely controlled by a state provider
+ return ;
}
diff --git a/src/video/state/events.ts b/src/video/state/events.ts
index 6024183f..96660fbc 100644
--- a/src/video/state/events.ts
+++ b/src/video/state/events.ts
@@ -1,4 +1,4 @@
-export type VideoPlayerEvent = "mediaplaying";
+export type VideoPlayerEvent = "mediaplaying" | "source" | "progress";
function createEventString(id: string, event: VideoPlayerEvent): string {
return `_vid:::${id}:::${event}`;
diff --git a/src/video/state/init.ts b/src/video/state/init.ts
index afe672f9..e9f60396 100644
--- a/src/video/state/init.ts
+++ b/src/video/state/init.ts
@@ -25,6 +25,8 @@ function initPlayer(): VideoPlayerState {
isSeries: false,
},
canAirplay: false,
+ stateProvider: null,
+ source: null,
};
}
diff --git a/src/video/state/logic/controls.ts b/src/video/state/logic/controls.ts
index 2bb342bb..b3c69df7 100644
--- a/src/video/state/logic/controls.ts
+++ b/src/video/state/logic/controls.ts
@@ -11,5 +11,14 @@ export function useControls(descriptor: string): VideoPlayerStateController {
play() {
state.stateProvider?.play();
},
+ setSource(source) {
+ state.stateProvider?.setSource(source);
+ },
+ setSeeking(active) {
+ state.stateProvider?.setSeeking(active);
+ },
+ setTime(time) {
+ state.stateProvider?.setTime(time);
+ },
};
}
diff --git a/src/video/state/logic/mediaplaying.ts b/src/video/state/logic/mediaplaying.ts
index a4a14a15..7980f2ed 100644
--- a/src/video/state/logic/mediaplaying.ts
+++ b/src/video/state/logic/mediaplaying.ts
@@ -7,7 +7,9 @@ export type VideoMediaPlayingEvent = {
isPlaying: boolean;
isPaused: boolean;
isLoading: boolean;
+ isSeeking: boolean;
hasPlayedOnce: boolean;
+ isFirstLoading: boolean;
};
function getMediaPlayingFromState(
@@ -18,6 +20,8 @@ function getMediaPlayingFromState(
isLoading: state.isLoading,
isPaused: state.isPaused,
isPlaying: state.isPlaying,
+ isSeeking: state.isSeeking,
+ isFirstLoading: state.isFirstLoading,
};
}
diff --git a/src/video/state/logic/progress.ts b/src/video/state/logic/progress.ts
new file mode 100644
index 00000000..c65b09ab
--- /dev/null
+++ b/src/video/state/logic/progress.ts
@@ -0,0 +1,45 @@
+import { useEffect, useState } from "react";
+import { getPlayerState } from "../cache";
+import { listenEvent, sendEvent, unlistenEvent } from "../events";
+import { VideoPlayerState } from "../types";
+
+export type VideoProgressEvent = {
+ time: number;
+ duration: number;
+ buffered: number;
+};
+
+function getProgressFromState(state: VideoPlayerState): VideoProgressEvent {
+ return {
+ time: state.time,
+ duration: state.duration,
+ buffered: state.buffered,
+ };
+}
+
+export function updateProgress(descriptor: string, state: VideoPlayerState) {
+ sendEvent(
+ descriptor,
+ "progress",
+ getProgressFromState(state)
+ );
+}
+
+export function useProgress(descriptor: string): VideoProgressEvent {
+ const state = getPlayerState(descriptor);
+ const [data, setData] = useState(
+ getProgressFromState(state)
+ );
+
+ useEffect(() => {
+ function update(payload: CustomEvent) {
+ setData(payload.detail);
+ }
+ listenEvent(descriptor, "progress", update);
+ return () => {
+ unlistenEvent(descriptor, "progress", update);
+ };
+ }, [descriptor]);
+
+ return data;
+}
diff --git a/src/video/state/logic/source.ts b/src/video/state/logic/source.ts
new file mode 100644
index 00000000..419eb24f
--- /dev/null
+++ b/src/video/state/logic/source.ts
@@ -0,0 +1,40 @@
+import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
+import { useEffect, useState } from "react";
+import { getPlayerState } from "../cache";
+import { listenEvent, sendEvent, unlistenEvent } from "../events";
+import { VideoPlayerState } from "../types";
+
+export type VideoSourceEvent = {
+ source: null | {
+ quality: MWStreamQuality;
+ url: string;
+ type: MWStreamType;
+ };
+};
+
+function getSourceFromState(state: VideoPlayerState): VideoSourceEvent {
+ return {
+ source: state.source ? { ...state.source } : null,
+ };
+}
+
+export function updateSource(descriptor: string, state: VideoPlayerState) {
+ sendEvent(descriptor, "source", getSourceFromState(state));
+}
+
+export function useSource(descriptor: string): VideoSourceEvent {
+ const state = getPlayerState(descriptor);
+ const [data, setData] = useState(getSourceFromState(state));
+
+ useEffect(() => {
+ function update(payload: CustomEvent) {
+ setData(payload.detail);
+ }
+ listenEvent(descriptor, "source", update);
+ return () => {
+ unlistenEvent(descriptor, "source", update);
+ };
+ }, [descriptor]);
+
+ return data;
+}
diff --git a/src/video/state/providers/providerTypes.ts b/src/video/state/providers/providerTypes.ts
index 99f4c650..3ad5c503 100644
--- a/src/video/state/providers/providerTypes.ts
+++ b/src/video/state/providers/providerTypes.ts
@@ -1,6 +1,17 @@
+import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
+
+type VideoPlayerSource = {
+ source: string;
+ type: MWStreamType;
+ quality: MWStreamQuality;
+} | null;
+
export type VideoPlayerStateController = {
pause: () => void;
play: () => void;
+ setSource: (source: VideoPlayerSource) => void;
+ setTime(time: number): void;
+ setSeeking(active: boolean): void;
};
export type VideoPlayerStateProvider = VideoPlayerStateController & {
diff --git a/src/video/state/providers/utils.ts b/src/video/state/providers/utils.ts
index fc80e9e0..8871961a 100644
--- a/src/video/state/providers/utils.ts
+++ b/src/video/state/providers/utils.ts
@@ -16,3 +16,12 @@ export function unsetStateProvider(descriptor: string) {
const state = getPlayerState(descriptor);
state.stateProvider = null;
}
+
+export function handleBuffered(time: number, buffered: TimeRanges): number {
+ for (let i = 0; i < buffered.length; i += 1) {
+ if (buffered.start(buffered.length - 1 - i) < time) {
+ return buffered.end(buffered.length - 1 - i);
+ }
+ }
+ return 0;
+}
diff --git a/src/video/state/providers/videoStateProvider.ts b/src/video/state/providers/videoStateProvider.ts
index 4d9a8207..4a0f5ed6 100644
--- a/src/video/state/providers/videoStateProvider.ts
+++ b/src/video/state/providers/videoStateProvider.ts
@@ -1,11 +1,16 @@
+import Hls from "hls.js";
+import { MWStreamType } from "@/backend/helpers/streams";
import { getPlayerState } from "../cache";
import { updateMediaPlaying } from "../logic/mediaplaying";
import { VideoPlayerStateProvider } from "./providerTypes";
+import { updateProgress } from "../logic/progress";
+import { handleBuffered } from "./utils";
export function createVideoStateProvider(
descriptor: string,
- player: HTMLVideoElement
+ playerEl: HTMLVideoElement
): VideoPlayerStateProvider {
+ const player = playerEl;
const state = getPlayerState(descriptor);
return {
@@ -15,7 +20,72 @@ export function createVideoStateProvider(
pause() {
player.pause();
},
+ 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.time = time;
+ updateProgress(descriptor, state);
+ },
+ setSeeking(active) {
+ // 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.isPaused;
+ this.pause();
+ },
+ setSource(source) {
+ if (!source) {
+ player.src = "";
+ return;
+ }
+
+ if (source?.type === MWStreamType.HLS) {
+ if (player.canPlayType("application/vnd.apple.mpegurl")) {
+ player.src = source.source;
+ } else {
+ // HLS support
+ if (!Hls.isSupported()) {
+ state.error = {
+ name: `Not supported`,
+ description: "Your browser does not support HLS video",
+ };
+ // TODO dispatch error
+ return;
+ }
+
+ const hls = new Hls({ enableWorker: false });
+
+ hls.on(Hls.Events.ERROR, (event, data) => {
+ if (data.fatal) {
+ state.error = {
+ name: `error ${data.details}`,
+ description: data.error?.message ?? "Something went wrong",
+ };
+ // TODO dispatch error
+ }
+ console.error("HLS error", data);
+ });
+
+ hls.attachMedia(player);
+ hls.loadSource(source.source);
+ }
+ } else if (source.type === MWStreamType.MP4) {
+ player.src = source.source;
+ }
+ },
providerStart() {
+ // TODO stored volume
const pause = () => {
state.isPaused = true;
state.isPlaying = false;
@@ -28,13 +98,56 @@ export function createVideoStateProvider(
state.hasPlayedOnce = true;
updateMediaPlaying(descriptor, state);
};
+ const waiting = () => {
+ state.isLoading = true;
+ updateMediaPlaying(descriptor, state);
+ };
+ const seeking = () => {
+ state.isSeeking = true;
+ updateMediaPlaying(descriptor, state);
+ };
+ const seeked = () => {
+ state.isSeeking = false;
+ updateMediaPlaying(descriptor, state);
+ };
+ const loadedmetadata = () => {
+ state.duration = player.duration;
+ updateProgress(descriptor, state);
+ };
+ const timeupdate = () => {
+ state.duration = player.duration;
+ state.time = player.currentTime;
+ updateProgress(descriptor, state);
+ };
+ const progress = () => {
+ state.buffered = handleBuffered(player.currentTime, player.buffered);
+ updateProgress(descriptor, state);
+ };
+ const canplay = () => {
+ state.isFirstLoading = false;
+ updateMediaPlaying(descriptor, state);
+ };
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);
return {
destroy: () => {
player.removeEventListener("pause", pause);
player.removeEventListener("playing", playing);
+ player.removeEventListener("seeking", seeking);
+ player.removeEventListener("seeked", seeked);
+ player.removeEventListener("timeupdate", timeupdate);
+ player.removeEventListener("loadedmetadata", loadedmetadata);
+ player.removeEventListener("progress", progress);
+ player.removeEventListener("waiting", waiting);
+ player.removeEventListener("canplay", canplay);
},
};
},
diff --git a/src/video/state/types.ts b/src/video/state/types.ts
index 3d2d4dc4..37309734 100644
--- a/src/video/state/types.ts
+++ b/src/video/state/types.ts
@@ -1,3 +1,4 @@
+import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
import { VideoPlayerStateProvider } from "./providers/providerTypes";
export type VideoPlayerState = {
@@ -30,10 +31,16 @@ export type VideoPlayerState = {
episodes?: { id: string; number: number; title: string }[];
}[];
};
+
error: null | {
name: string;
description: string;
};
canAirplay: boolean;
stateProvider: VideoPlayerStateProvider | null;
+ source: null | {
+ quality: MWStreamQuality;
+ url: string;
+ type: MWStreamType;
+ };
};
diff --git a/src/views/TestView.tsx b/src/views/TestView.tsx
index ed77351f..b3eb7551 100644
--- a/src/views/TestView.tsx
+++ b/src/views/TestView.tsx
@@ -4,7 +4,14 @@
// } from "@/hooks/useChromecastAvailable";
// import { useEffect, useRef } from "react";
+import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
+import { LoadingAction } from "@/video/components/actions/LoadingAction";
+import { MiddlePauseAction } from "@/video/components/actions/MiddlePauseAction";
import { PauseAction } from "@/video/components/actions/PauseAction";
+import { ProgressAction } from "@/video/components/actions/ProgressAction";
+import { SkipTimeAction } from "@/video/components/actions/SkipTimeAction";
+import { TimeAction } from "@/video/components/actions/TimeAction";
+import { SourceController } from "@/video/components/controllers/SourceController";
import { VideoPlayerBase } from "@/video/components/VideoPlayerBase";
// function ChromeCastButton() {
@@ -25,6 +32,16 @@ export function TestView() {
return (
+
+
+
+
+
+
);
}