fundementals for video player rewrite

This commit is contained in:
mrjvs 2023-09-30 20:57:00 +02:00
parent 0b4c47bbd4
commit a813efe5ba
10 changed files with 168 additions and 46 deletions

View File

@ -9,8 +9,23 @@ These parts can be used to build any shape of a video player.
# internal parts
These parts are internally used, they aren't exported. Do not use them outside of player internals.
- `/display` - display interface, abstraction on how to actually play the content (e.g Video element, HLS player, Standard video element, etc)
- `/internals` - Internal components that are always rendered on every player.
- `/utils` - miscellaneous logic
- `/hooks` - hooks only used for video player
- `~/src/stores/player` - state for the video player. Should only be used by internal parts
### `/display`
The display interface, abstraction on how to actually play the content (e.g Video element, chrome casting, etc)
- It must be completely seperate from any react code
- It must not interact with state, pass async data back with events
### `/internals`
Internal components that are always rendered on every player.
- Only components that are always present on the player instance, they must never unmount
### `/utils`
miscellaneous logic, put anything that is unique to the video player internals.
### `/hooks`
Hooks only used for video player.
- only exception is usePlayer, as its used outside of the player to control the player
### `~/src/stores/player`
State for the video player.
- Only parts related to the video player may utilize the state

View File

@ -1,3 +1,17 @@
import { usePlayerStore } from "@/stores/player/store";
export function Pause() {
return <button type="button" />;
const display = usePlayerStore((s) => s.display);
const { isPaused } = usePlayerStore((s) => s.mediaPlaying);
const toggle = () => {
if (isPaused) display?.play();
else display?.pause();
};
return (
<button type="button" onClick={toggle}>
play/pause
</button>
);
}

View File

@ -1 +1,45 @@
export {};
import {
DisplayInterface,
DisplayInterfaceEvents,
} from "@/components/player/display/displayInterface";
import { Source } from "@/components/player/hooks/usePlayer";
import { makeEmitter } from "@/utils/events";
export function makeVideoElementDisplayInterface(): DisplayInterface {
const { emit, on, off } = makeEmitter<DisplayInterfaceEvents>();
let source: Source | null = null;
let videoElement: HTMLVideoElement | null = null;
function setSource() {
if (!videoElement || !source) return;
videoElement.src = source.url;
videoElement.addEventListener("play", () => emit("play", undefined));
videoElement.addEventListener("pause", () => emit("pause", undefined));
}
return {
on,
off,
// no need to destroy anything
destroy: () => {},
load(newSource) {
source = newSource;
setSource();
},
processVideoElement(video) {
videoElement = video;
setSource();
},
pause() {
videoElement?.pause();
},
play() {
videoElement?.play();
},
};
}

View File

@ -10,4 +10,6 @@ export interface DisplayInterface extends Listener<DisplayInterfaceEvents> {
play(): void;
pause(): void;
load(source: Source): void;
processVideoElement(video: HTMLVideoElement): void;
destroy(): void;
}

View File

@ -9,11 +9,13 @@ export interface Source {
export function usePlayer() {
const setStatus = usePlayerStore((s) => s.setStatus);
const setSource = usePlayerStore((s) => s.setSource);
const status = usePlayerStore((s) => s.status);
const display = usePlayerStore((s) => s.display);
return {
status,
playMedia(source: Source) {
setSource(source.url, source.type);
display?.load(source);
setStatus(playerStatus.PLAYING);
},
};

View File

@ -1,36 +1,46 @@
import { RefObject, useEffect, useRef } from "react";
import { useEffect, useRef } from "react";
import { MWStreamType } from "@/backend/helpers/streams";
import { SourceSliceSource } from "@/stores/player/slices/source";
import { AllSlices } from "@/stores/player/slices/types";
import { makeVideoElementDisplayInterface } from "@/components/player/display/base";
import { playerStatus } from "@/stores/player/slices/source";
import { usePlayerStore } from "@/stores/player/store";
// should this video container show right now?
function useShouldShow(source: SourceSliceSource | null): boolean {
if (!source) return false;
if (source.type !== MWStreamType.MP4) return false;
// initialize display interface
function useDisplayInterface() {
const display = usePlayerStore((s) => s.display);
const setDisplay = usePlayerStore((s) => s.setDisplay);
useEffect(() => {
if (!display) {
setDisplay(makeVideoElementDisplayInterface());
}
}, [display, setDisplay]);
}
function useShouldShowVideoElement() {
const status = usePlayerStore((s) => s.status);
if (status !== playerStatus.PLAYING) return false;
return true;
}
// make video element up to par with the state
function useRestoreVideo(
videoRef: RefObject<HTMLVideoElement>,
player: AllSlices
) {
function VideoElement() {
const videoEl = useRef<HTMLVideoElement>(null);
const display = usePlayerStore((s) => s.display);
// report video element to display interface
useEffect(() => {
const el = videoRef.current;
const src = player.source?.url ?? "";
if (!el) return;
if (el.src !== src) el.src = src;
}, [player.source?.url, videoRef]);
if (display && videoEl.current) {
display.processVideoElement(videoEl.current);
}
}, [display, videoEl]);
return <video autoPlay ref={videoEl} />;
}
export function VideoContainer() {
const videoEl = useRef<HTMLVideoElement>(null);
const player = usePlayerStore();
useRestoreVideo(videoEl, player);
const show = useShouldShow(player.source);
const show = useShouldShowVideoElement();
useDisplayInterface();
if (!show) return null;
return <video autoPlay ref={videoEl} />;
return <VideoElement />;
}

View File

@ -1,9 +1,30 @@
import { MWStreamType } from "@/backend/helpers/streams";
import { Player } from "@/components/player";
import { usePlayer } from "@/components/player/hooks/usePlayer";
import { playerStatus } from "@/stores/player/slices/source";
export function PlayerView() {
const { status, playMedia } = usePlayer();
function scrape() {
playMedia({
type: MWStreamType.MP4,
url: "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
});
}
return (
<Player.Container>
<Player.Pause />
{status === playerStatus.IDLE ? (
<div>
<p>Its now scraping</p>
<button type="button" onClick={scrape}>
Finish scraping
</button>
</div>
) : null}
</Player.Container>
);
}

View File

@ -1,18 +1,5 @@
import { useEffect } from "react";
import { MWStreamType } from "@/backend/helpers/streams";
import { usePlayer } from "@/components/player/hooks/usePlayer";
import { PlayerView } from "@/pages/PlayerView";
export default function VideoTesterView() {
const player = usePlayer();
useEffect(() => {
player.playMedia({
type: MWStreamType.MP4,
url: "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
});
});
return <PlayerView />;
}

View File

@ -1,4 +1,5 @@
import { MWStreamType } from "@/backend/helpers/streams";
import { DisplayInterface } from "@/components/player/display/displayInterface";
import { MakeSlice } from "@/stores/player/slices/types";
import { ValuesOf } from "@/utils/typeguard";
@ -18,13 +19,16 @@ export interface SourceSliceSource {
export interface SourceSlice {
status: PlayerStatus;
source: SourceSliceSource | null;
display: DisplayInterface | null;
setStatus(status: PlayerStatus): void;
setSource(url: string, type: MWStreamType): void;
setDisplay(display: DisplayInterface): void;
}
export const createSourceSlice: MakeSlice<SourceSlice> = (set) => ({
export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
source: null,
status: playerStatus.IDLE,
display: null,
setStatus(status: PlayerStatus) {
set((s) => {
s.status = status;
@ -38,4 +42,26 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set) => ({
};
});
},
setDisplay(newDisplay: DisplayInterface) {
const display = get().display;
if (display) display.destroy();
// make display events update the state
newDisplay.on("pause", () =>
set((s) => {
s.mediaPlaying.isPaused = true;
s.mediaPlaying.isPlaying = false;
})
);
newDisplay.on("play", () =>
set((s) => {
s.mediaPlaying.isPaused = false;
s.mediaPlaying.isPlaying = true;
})
);
set((s) => {
s.display = newDisplay;
});
},
});

View File

@ -10,6 +10,7 @@ export interface Emitter<T extends EventMap> {
export interface Listener<T extends EventMap> {
on<K extends EventKey<T>>(eventName: K, fn: EventReceiver<T[K]>): void;
off<K extends EventKey<T>>(eventName: K, fn: EventReceiver<T[K]>): void;
}
export function makeEmitter<T extends EventMap>(): Emitter<T> {