mirror of
https://github.com/sussy-code/smov.git
synced 2024-12-30 16:17:41 +01:00
add basic functioning player
This commit is contained in:
parent
6ca3196b75
commit
c5a8065db9
49 changed files with 223 additions and 42 deletions
|
@ -1,12 +1,12 @@
|
|||
import { useGoBack } from "@/hooks/useGoBack";
|
||||
import { useVolumeControl } from "@/hooks/useVolumeToggle";
|
||||
import { forwardRef, useContext, useEffect, useRef } from "react";
|
||||
import { VideoErrorBoundary } from "../../components/video/parts/VideoErrorBoundary";
|
||||
import { VideoErrorBoundary } from "./parts/VideoErrorBoundary";
|
||||
import {
|
||||
useVideoPlayerState,
|
||||
VideoPlayerContext,
|
||||
VideoPlayerContextProvider,
|
||||
} from "../../video/components./../components/video/VideoContext";
|
||||
} from "./VideoContext";
|
||||
|
||||
export interface VideoPlayerProps {
|
||||
autoPlay?: boolean;
|
|
@ -1,4 +1,4 @@
|
|||
import { useVideoPlayerState } from "@/components/video/VideoContext";
|
||||
import { useVideoPlayerState } from "@/../__old/VideoContext";
|
||||
import { useState } from "react";
|
||||
|
||||
export function useVolumeControl() {
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import { VideoPlayerContextProvider } from "../state/hooks";
|
||||
import { VideoElementInternal } from "./internal/VideoElementInternal";
|
||||
|
||||
export interface VideoPlayerProps {
|
||||
export interface VideoPlayerBaseProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function VideoPlayer(props: VideoPlayerProps) {
|
||||
export function VideoPlayerBase(props: VideoPlayerBaseProps) {
|
||||
// TODO error boundary
|
||||
// TODO move error boundary to only decorated, <VideoPlayer /> shouldn't have styling
|
||||
// TODO internal controls
|
||||
|
@ -12,6 +13,7 @@ export function VideoPlayer(props: VideoPlayerProps) {
|
|||
return (
|
||||
<VideoPlayerContextProvider>
|
||||
<div className="is-video-player relative h-full w-full select-none overflow-hidden bg-black [border-left:env(safe-area-inset-left)_solid_transparent] [border-right:env(safe-area-inset-right)_solid_transparent]">
|
||||
<VideoElementInternal />
|
||||
<div className="absolute inset-0">{props.children}</div>
|
||||
</div>
|
||||
</VideoPlayerContextProvider>
|
||||
|
|
34
src/video/components/actions/PauseAction.tsx
Normal file
34
src/video/components/actions/PauseAction.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { 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";
|
||||
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
iconSize?: string;
|
||||
}
|
||||
|
||||
export function PauseAction(props: Props) {
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const mediaPlaying = useMediaPlaying(descriptor);
|
||||
const controls = useControls(descriptor);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (mediaPlaying.isPlaying) controls.pause();
|
||||
else controls.play();
|
||||
}, [mediaPlaying, controls]);
|
||||
|
||||
// TODO add seeking back
|
||||
const icon = mediaPlaying.isPlaying ? Icons.PAUSE : Icons.PLAY;
|
||||
|
||||
return (
|
||||
<VideoPlayerIconButton
|
||||
iconSize={props.iconSize}
|
||||
className={props.className}
|
||||
icon={icon}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
);
|
||||
}
|
30
src/video/components/internal/VideoElementInternal.tsx
Normal file
30
src/video/components/internal/VideoElementInternal.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { setProvider, unsetStateProvider } from "@/video/state/providers/utils";
|
||||
import { createVideoStateProvider } from "@/video/state/providers/videoStateProvider";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function VideoElementInternal() {
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const ref = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const provider = createVideoStateProvider(descriptor, ref.current);
|
||||
setProvider(descriptor, provider);
|
||||
const { destroy } = provider.providerStart();
|
||||
return () => {
|
||||
unsetStateProvider(descriptor);
|
||||
destroy();
|
||||
};
|
||||
}, [descriptor]);
|
||||
|
||||
// TODO autoplay and muted
|
||||
return (
|
||||
<video
|
||||
ref={ref}
|
||||
playsInline
|
||||
className="h-full w-full"
|
||||
src="http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4"
|
||||
/>
|
||||
);
|
||||
}
|
27
src/video/components/parts/VideoPlayerIconButton.tsx
Normal file
27
src/video/components/parts/VideoPlayerIconButton.tsx
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { Icon, Icons } from "@/components/Icon";
|
||||
import React from "react";
|
||||
|
||||
export interface VideoPlayerIconButtonProps {
|
||||
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
icon: Icons;
|
||||
text?: string;
|
||||
className?: string;
|
||||
iconSize?: string;
|
||||
}
|
||||
|
||||
export function VideoPlayerIconButton(props: VideoPlayerIconButtonProps) {
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onClick}
|
||||
className="group pointer-events-auto p-2 text-white transition-transform duration-100 active:scale-110"
|
||||
>
|
||||
<div className="flex items-center justify-center rounded-full bg-white bg-opacity-0 p-2 transition-colors duration-100 group-hover:bg-opacity-20">
|
||||
<Icon icon={props.icon} className={props.iconSize ?? "text-2xl"} />
|
||||
{props.text ? <span className="ml-2">{props.text}</span> : null}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
export type VideoPlayerEvent = "progress";
|
||||
export type VideoPlayerEvent = "mediaplaying";
|
||||
|
||||
function createEventString(id: string, event: VideoPlayerEvent): string {
|
||||
return `_vid:::${id}:::${event}`;
|
||||
|
|
15
src/video/state/logic/controls.ts
Normal file
15
src/video/state/logic/controls.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { getPlayerState } from "../cache";
|
||||
import { VideoPlayerStateController } from "../providers/providerTypes";
|
||||
|
||||
export function useControls(descriptor: string): VideoPlayerStateController {
|
||||
const state = getPlayerState(descriptor);
|
||||
|
||||
return {
|
||||
pause() {
|
||||
state.stateProvider?.pause();
|
||||
},
|
||||
play() {
|
||||
state.stateProvider?.play();
|
||||
},
|
||||
};
|
||||
}
|
52
src/video/state/logic/mediaplaying.ts
Normal file
52
src/video/state/logic/mediaplaying.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { getPlayerState } from "../cache";
|
||||
import { listenEvent, sendEvent, unlistenEvent } from "../events";
|
||||
import { VideoPlayerState } from "../types";
|
||||
|
||||
export type VideoMediaPlayingEvent = {
|
||||
isPlaying: boolean;
|
||||
isPaused: boolean;
|
||||
isLoading: boolean;
|
||||
hasPlayedOnce: boolean;
|
||||
};
|
||||
|
||||
function getMediaPlayingFromState(
|
||||
state: VideoPlayerState
|
||||
): VideoMediaPlayingEvent {
|
||||
return {
|
||||
hasPlayedOnce: state.hasPlayedOnce,
|
||||
isLoading: state.isLoading,
|
||||
isPaused: state.isPaused,
|
||||
isPlaying: state.isPlaying,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateMediaPlaying(
|
||||
descriptor: string,
|
||||
state: VideoPlayerState
|
||||
) {
|
||||
sendEvent<VideoMediaPlayingEvent>(
|
||||
descriptor,
|
||||
"mediaplaying",
|
||||
getMediaPlayingFromState(state)
|
||||
);
|
||||
}
|
||||
|
||||
export function useMediaPlaying(descriptor: string): VideoMediaPlayingEvent {
|
||||
const state = getPlayerState(descriptor);
|
||||
const [data, setData] = useState<VideoMediaPlayingEvent>(
|
||||
getMediaPlayingFromState(state)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
function update(payload: CustomEvent<VideoMediaPlayingEvent>) {
|
||||
setData(payload.detail);
|
||||
}
|
||||
listenEvent(descriptor, "mediaplaying", update);
|
||||
return () => {
|
||||
unlistenEvent(descriptor, "mediaplaying", update);
|
||||
};
|
||||
}, [descriptor]);
|
||||
|
||||
return data;
|
||||
}
|
|
@ -1,6 +1,9 @@
|
|||
export type VideoPlayerStateProvider = {
|
||||
export type VideoPlayerStateController = {
|
||||
pause: () => void;
|
||||
play: () => void;
|
||||
};
|
||||
|
||||
export type VideoPlayerStateProvider = VideoPlayerStateController & {
|
||||
providerStart: () => {
|
||||
destroy: () => void;
|
||||
};
|
||||
|
|
18
src/video/state/providers/utils.ts
Normal file
18
src/video/state/providers/utils.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { getPlayerState } from "../cache";
|
||||
import { VideoPlayerStateProvider } from "./providerTypes";
|
||||
|
||||
export function setProvider(
|
||||
descriptor: string,
|
||||
provider: VideoPlayerStateProvider
|
||||
) {
|
||||
const state = getPlayerState(descriptor);
|
||||
state.stateProvider = provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Note: This only sets the state provider to null. it does not destroy the listener
|
||||
*/
|
||||
export function unsetStateProvider(descriptor: string) {
|
||||
const state = getPlayerState(descriptor);
|
||||
state.stateProvider = null;
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
import { getPlayerState } from "../cache";
|
||||
import { updateMediaPlaying } from "../logic/mediaplaying";
|
||||
import { VideoPlayerStateProvider } from "./providerTypes";
|
||||
|
||||
export function createVideoStateProvider(
|
||||
|
@ -15,16 +16,17 @@ export function createVideoStateProvider(
|
|||
player.pause();
|
||||
},
|
||||
providerStart() {
|
||||
// TODO reactivity through events
|
||||
const pause = () => {
|
||||
state.isPaused = true;
|
||||
state.isPlaying = false;
|
||||
updateMediaPlaying(descriptor, state);
|
||||
};
|
||||
const playing = () => {
|
||||
state.isPaused = false;
|
||||
state.isPlaying = true;
|
||||
state.isLoading = false;
|
||||
state.hasPlayedOnce = true;
|
||||
updateMediaPlaying(descriptor, state);
|
||||
};
|
||||
|
||||
player.addEventListener("pause", pause);
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { VideoPlayerStateProvider } from "./providers/providerTypes";
|
||||
|
||||
export type VideoPlayerState = {
|
||||
isPlaying: boolean;
|
||||
isPaused: boolean;
|
||||
|
@ -33,4 +35,5 @@ export type VideoPlayerState = {
|
|||
description: string;
|
||||
};
|
||||
canAirplay: boolean;
|
||||
stateProvider: VideoPlayerStateProvider | null;
|
||||
};
|
||||
|
|
|
@ -1,35 +1,30 @@
|
|||
import {
|
||||
useChromecast,
|
||||
useChromecastAvailable,
|
||||
} from "@/hooks/useChromecastAvailable";
|
||||
import { useEffect, useRef } from "react";
|
||||
// import {
|
||||
// useChromecast,
|
||||
// useChromecastAvailable,
|
||||
// } from "@/hooks/useChromecastAvailable";
|
||||
// import { useEffect, useRef } from "react";
|
||||
|
||||
function ChromeCastButton() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const available = useChromecastAvailable();
|
||||
import { PauseAction } from "@/video/components/actions/PauseAction";
|
||||
import { VideoPlayerBase } from "@/video/components/VideoPlayerBase";
|
||||
|
||||
useEffect(() => {
|
||||
if (!available) return;
|
||||
const tag = document.createElement("google-cast-launcher");
|
||||
tag.setAttribute("id", "castbutton");
|
||||
ref.current?.appendChild(tag);
|
||||
}, [available]);
|
||||
// function ChromeCastButton() {
|
||||
// const ref = useRef<HTMLDivElement>(null);
|
||||
// const available = useChromecastAvailable();
|
||||
|
||||
return <div ref={ref} />;
|
||||
}
|
||||
// useEffect(() => {
|
||||
// if (!available) return;
|
||||
// const tag = document.createElement("google-cast-launcher");
|
||||
// tag.setAttribute("id", "castbutton");
|
||||
// ref.current?.appendChild(tag);
|
||||
// }, [available]);
|
||||
|
||||
// return <div ref={ref} />;
|
||||
// }
|
||||
|
||||
export function TestView() {
|
||||
const { startCast, stopCast } = useChromecast();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ChromeCastButton />
|
||||
<button type="button" onClick={startCast}>
|
||||
Start casting
|
||||
</button>
|
||||
<button type="button" onClick={stopCast}>
|
||||
StopCasting
|
||||
</button>
|
||||
</div>
|
||||
<VideoPlayerBase>
|
||||
<PauseAction />
|
||||
</VideoPlayerBase>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||
import { ErrorMessage } from "@/components/layout/ErrorBoundary";
|
||||
import { Link } from "@/components/text/Link";
|
||||
import { VideoPlayerHeader } from "@/components/video/parts/VideoPlayerHeader";
|
||||
import { VideoPlayerHeader } from "@/../__old/parts/VideoPlayerHeader";
|
||||
import { useGoBack } from "@/hooks/useGoBack";
|
||||
import { conf } from "@/setup/config";
|
||||
import { Helmet } from "react-helmet";
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
import { useHistory, useParams } from "react-router-dom";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { DecoratedVideoPlayer } from "@/video/components/__old/DecoratedVideoPlayer";
|
||||
import { DecoratedVideoPlayer } from "@/../__old/DecoratedVideoPlayer";
|
||||
import { MWStream } from "@/backend/helpers/streams";
|
||||
import { SelectedMediaData, useScrape } from "@/hooks/useScrape";
|
||||
import { VideoPlayerHeader } from "@/components/video/parts/VideoPlayerHeader";
|
||||
import { VideoPlayerHeader } from "@/../__old/parts/VideoPlayerHeader";
|
||||
import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta";
|
||||
import { decodeJWId } from "@/backend/metadata/justwatch";
|
||||
import { SourceControl } from "@/components/video/controls/SourceControl";
|
||||
import { SourceControl } from "@/../__old/controls/SourceControl";
|
||||
import { Loading } from "@/components/layout/Loading";
|
||||
import { useLoading } from "@/hooks/useLoading";
|
||||
import { MWMediaType } from "@/backend/metadata/types";
|
||||
|
@ -15,8 +15,8 @@ import { useGoBack } from "@/hooks/useGoBack";
|
|||
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { useWatchedItem } from "@/state/watched";
|
||||
import { ProgressListenerControl } from "@/components/video/controls/ProgressListenerControl";
|
||||
import { ShowControl } from "@/components/video/controls/ShowControl";
|
||||
import { ProgressListenerControl } from "@/../__old/controls/ProgressListenerControl";
|
||||
import { ShowControl } from "@/../__old/controls/ShowControl";
|
||||
import { MediaFetchErrorView } from "./MediaErrorView";
|
||||
import { MediaScrapeLog } from "./MediaScrapeLog";
|
||||
import { NotFoundMedia, NotFoundWrapper } from "../notfound/NotFoundView";
|
||||
|
|
|
@ -6,7 +6,7 @@ import { Navigation } from "@/components/layout/Navigation";
|
|||
import { ArrowLink } from "@/components/text/ArrowLink";
|
||||
import { Title } from "@/components/text/Title";
|
||||
import { useGoBack } from "@/hooks/useGoBack";
|
||||
import { VideoPlayerHeader } from "@/components/video/parts/VideoPlayerHeader";
|
||||
import { VideoPlayerHeader } from "@/../__old/parts/VideoPlayerHeader";
|
||||
import { Helmet } from "react-helmet";
|
||||
|
||||
export function NotFoundWrapper(props: {
|
||||
|
|
Loading…
Reference in a new issue