diff --git a/src/hooks/useVolumeToggle.ts b/src/hooks/useVolumeToggle.ts index b0fad7b6..636b787b 100644 --- a/src/hooks/useVolumeToggle.ts +++ b/src/hooks/useVolumeToggle.ts @@ -1,16 +1,18 @@ -import { useVideoPlayerState } from "@/../__old/VideoContext"; +import { useControls } from "@/video/state/logic/controls"; +import { useMediaPlaying } from "@/video/state/logic/mediaplaying"; import { useState } from "react"; -export function useVolumeControl() { +export function useVolumeControl(descriptor: string) { const [storedVolume, setStoredVolume] = useState(1); - const { videoState } = useVideoPlayerState(); + const controls = useControls(descriptor); + const mediaPlaying = useMediaPlaying(descriptor); const toggleVolume = () => { - if (videoState.volume > 0) { - setStoredVolume(videoState.volume); - videoState.setVolume(0); + if (mediaPlaying.volume > 0) { + setStoredVolume(mediaPlaying.volume); + controls.setVolume(0); } else { - videoState.setVolume(storedVolume > 0 ? storedVolume : 1); + controls.setVolume(storedVolume > 0 ? storedVolume : 1); } }; diff --git a/src/index.tsx b/src/index.tsx index 1a7a7aa0..ec109383 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -19,6 +19,7 @@ if (key) { initializeChromecast(); // TODO video todos: +// - mobile controls start showing when resizing // - captions // - chrome cast support // - safari fullscreen will make video overlap player controls diff --git a/src/video/components/VideoPlayer.tsx b/src/video/components/VideoPlayer.tsx index 67977b93..cb441ce8 100644 --- a/src/video/components/VideoPlayer.tsx +++ b/src/video/components/VideoPlayer.tsx @@ -13,6 +13,7 @@ import { QualityDisplayAction } from "@/video/components/actions/QualityDisplayA import { ShowTitleAction } from "@/video/components/actions/ShowTitleAction"; import { SkipTimeAction } from "@/video/components/actions/SkipTimeAction"; import { TimeAction } from "@/video/components/actions/TimeAction"; +import { VolumeAction } from "@/video/components/actions/VolumeAction"; import { VideoPlayerError } from "@/video/components/parts/VideoPlayerError"; import { VideoPlayerBase, @@ -54,7 +55,7 @@ function LeftSideControls() { > - {/* */} + @@ -73,10 +74,9 @@ export function VideoPlayer(props: Props) { [setShow] ); - // TODO autoplay // TODO safe area only if full screen or fill screen return ( - + diff --git a/src/video/components/VideoPlayerBase.tsx b/src/video/components/VideoPlayerBase.tsx index 2dd6ced0..71da6fd0 100644 --- a/src/video/components/VideoPlayerBase.tsx +++ b/src/video/components/VideoPlayerBase.tsx @@ -5,6 +5,7 @@ import { VideoElementInternal } from "./internal/VideoElementInternal"; export interface VideoPlayerBaseProps { children?: React.ReactNode; + autoPlay?: boolean; } export function VideoPlayerBase(props: VideoPlayerBaseProps) { @@ -19,7 +20,7 @@ export function VideoPlayerBase(props: VideoPlayerBaseProps) { ref={ref} 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]" > - + {props.children} diff --git a/src/video/components/actions/VolumeAction.tsx b/src/video/components/actions/VolumeAction.tsx new file mode 100644 index 00000000..c3aee7c1 --- /dev/null +++ b/src/video/components/actions/VolumeAction.tsx @@ -0,0 +1,92 @@ +import { Icon, Icons } from "@/components/Icon"; +import { + makePercentage, + makePercentageString, + useProgressBar, +} from "@/hooks/useProgressBar"; +import { useVolumeControl } from "@/hooks/useVolumeToggle"; +import { canChangeVolume } from "@/utils/detectFeatures"; +import { useVideoPlayerDescriptor } from "@/video/state/hooks"; +import { useControls } from "@/video/state/logic/controls"; +import { useInterface } from "@/video/state/logic/interface"; +import { useMediaPlaying } from "@/video/state/logic/mediaplaying"; +import { useCallback, useEffect, useRef, useState } from "react"; + +interface Props { + className?: string; +} + +export function VolumeAction(props: Props) { + const descriptor = useVideoPlayerDescriptor(); + const controls = useControls(descriptor); + const mediaPlaying = useMediaPlaying(descriptor); + const videoInterface = useInterface(descriptor); + const { setStoredVolume, toggleVolume } = useVolumeControl(descriptor); + const ref = useRef(null); + const [hoveredOnce, setHoveredOnce] = useState(false); + + const commitVolume = useCallback( + (percentage) => { + controls.setVolume(percentage); + setStoredVolume(percentage); + }, + [controls, setStoredVolume] + ); + const { dragging, dragPercentage, dragMouseDown } = useProgressBar( + ref, + commitVolume, + true + ); + + useEffect(() => { + if (!videoInterface.leftControlHovering) setHoveredOnce(false); + }, [videoInterface]); + + const handleClick = useCallback(() => { + toggleVolume(); + }, [toggleVolume]); + + const handleMouseEnter = useCallback(async () => { + if (await canChangeVolume()) setHoveredOnce(true); + }, [setHoveredOnce]); + + let percentage = makePercentage(mediaPlaying.volume * 100); + if (dragging) percentage = makePercentage(dragPercentage); + const percentageString = makePercentageString(percentage); + + return ( + + + + 0 ? Icons.VOLUME : Icons.VOLUME_X} /> + + + + + + + + + + + + + ); +} diff --git a/src/video/components/controllers/ProgressListenerController.tsx b/src/video/components/controllers/ProgressListenerController.tsx new file mode 100644 index 00000000..27434bd5 --- /dev/null +++ b/src/video/components/controllers/ProgressListenerController.tsx @@ -0,0 +1,47 @@ +import { useEffect, useMemo, useRef } from "react"; +import throttle from "lodash.throttle"; +import { useVideoPlayerDescriptor } from "@/video/state/hooks"; +import { useMediaPlaying } from "@/video/state/logic/mediaplaying"; +import { useProgress } from "@/video/state/logic/progress"; +import { useControls } from "@/video/state/logic/controls"; + +interface Props { + startAt?: number; + onProgress?: (time: number, duration: number) => void; +} + +export function ProgressListenerController(props: Props) { + const descriptor = useVideoPlayerDescriptor(); + const mediaPlaying = useMediaPlaying(descriptor); + const progress = useProgress(descriptor); + const controls = useControls(descriptor); + const didInitialize = useRef(null); + + // time updates (throttled) + const updateTime = useMemo( + () => throttle((a: number, b: number) => props.onProgress?.(a, b), 1000), + [props] + ); + useEffect(() => { + if (!mediaPlaying.isPlaying) return; + if (progress.duration === 0 || progress.time === 0) return; + updateTime(progress.time, progress.duration); + }, [progress, mediaPlaying, updateTime]); + useEffect(() => { + return () => { + updateTime.cancel(); + }; + }, [updateTime]); + + // initialize + useEffect(() => { + if (didInitialize.current) return; + if (mediaPlaying.isFirstLoading || Number.isNaN(progress.duration)) return; + if (props.startAt !== undefined) { + controls.setTime(props.startAt); + } + didInitialize.current = true; + }, [didInitialize, props, progress, mediaPlaying, controls]); + + return null; +} diff --git a/src/video/components/hooks/volumeStore.ts b/src/video/components/hooks/volumeStore.ts new file mode 100644 index 00000000..3b328810 --- /dev/null +++ b/src/video/components/hooks/volumeStore.ts @@ -0,0 +1,25 @@ +import { versionedStoreBuilder } from "@/utils/storage"; + +export const volumeStore = versionedStoreBuilder() + .setKey("mw-volume") + .addVersion({ + version: 0, + create() { + return { + volume: 1, + }; + }, + }) + .build(); + +export function getStoredVolume(): number { + const store = volumeStore.get(); + return store.volume; +} + +export function setStoredVolume(volume: number) { + const store = volumeStore.get(); + store.save({ + volume, + }); +} diff --git a/src/video/components/internal/VideoElementInternal.tsx b/src/video/components/internal/VideoElementInternal.tsx index 7a8f1219..c4e6ae28 100644 --- a/src/video/components/internal/VideoElementInternal.tsx +++ b/src/video/components/internal/VideoElementInternal.tsx @@ -1,10 +1,16 @@ import { useVideoPlayerDescriptor } from "@/video/state/hooks"; +import { useMediaPlaying } from "@/video/state/logic/mediaplaying"; import { setProvider, unsetStateProvider } from "@/video/state/providers/utils"; import { createVideoStateProvider } from "@/video/state/providers/videoStateProvider"; import { useEffect, useRef } from "react"; -export function VideoElementInternal() { +interface Props { + autoPlay?: boolean; +} + +export function VideoElementInternal(props: Props) { const descriptor = useVideoPlayerDescriptor(); + const mediaPlaying = useMediaPlaying(descriptor); const ref = useRef(null); useEffect(() => { @@ -18,7 +24,16 @@ export function VideoElementInternal() { }; }, [descriptor]); - // TODO autoplay and muted + // TODO shortcuts + // this element is remotely controlled by a state provider - return ; + return ( + + ); } diff --git a/src/video/state/init.ts b/src/video/state/init.ts index 67319362..84469785 100644 --- a/src/video/state/init.ts +++ b/src/video/state/init.ts @@ -18,6 +18,7 @@ function initPlayer(): VideoPlayerState { isSeeking: false, isFirstLoading: true, hasPlayedOnce: false, + volume: 0, }, progress: { @@ -30,9 +31,7 @@ function initPlayer(): VideoPlayerState { source: null, error: null, - volume: 0, pausedWhenSeeking: false, - hasInitialized: false, canAirplay: false, stateProvider: null, diff --git a/src/video/state/logic/controls.ts b/src/video/state/logic/controls.ts index 510c4f78..d9378f4d 100644 --- a/src/video/state/logic/controls.ts +++ b/src/video/state/logic/controls.ts @@ -40,6 +40,9 @@ export function useControls( enterFullscreen() { state.stateProvider?.enterFullscreen(); }, + setVolume(volume) { + state.stateProvider?.setVolume(volume); + }, // other controls setLeftControlsHover(hovering) { diff --git a/src/video/state/logic/mediaplaying.ts b/src/video/state/logic/mediaplaying.ts index b25d42e7..354c0c09 100644 --- a/src/video/state/logic/mediaplaying.ts +++ b/src/video/state/logic/mediaplaying.ts @@ -10,6 +10,7 @@ export type VideoMediaPlayingEvent = { isSeeking: boolean; hasPlayedOnce: boolean; isFirstLoading: boolean; + volume: number; }; function getMediaPlayingFromState( @@ -22,6 +23,7 @@ function getMediaPlayingFromState( isPlaying: state.mediaPlaying.isPlaying, isSeeking: state.mediaPlaying.isSeeking, isFirstLoading: state.mediaPlaying.isFirstLoading, + volume: state.mediaPlaying.volume, }; } diff --git a/src/video/state/providers/providerTypes.ts b/src/video/state/providers/providerTypes.ts index 04cf3651..e26e458a 100644 --- a/src/video/state/providers/providerTypes.ts +++ b/src/video/state/providers/providerTypes.ts @@ -14,6 +14,7 @@ export type VideoPlayerStateController = { setSeeking(active: boolean): void; exitFullscreen(): void; enterFullscreen(): void; + setVolume(volume: number): void; }; export type VideoPlayerStateProvider = VideoPlayerStateController & { diff --git a/src/video/state/providers/videoStateProvider.ts b/src/video/state/providers/videoStateProvider.ts index 0746ab62..99a64b4e 100644 --- a/src/video/state/providers/videoStateProvider.ts +++ b/src/video/state/providers/videoStateProvider.ts @@ -1,6 +1,7 @@ import Hls from "hls.js"; import fscreen from "fscreen"; import { + canChangeVolume, canFullscreen, canFullscreenAnyElement, canWebkitFullscreen, @@ -8,6 +9,10 @@ import { 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 { getPlayerState } from "../cache"; import { updateMediaPlaying } from "../logic/mediaplaying"; import { VideoPlayerStateProvider } from "./providerTypes"; @@ -67,6 +72,19 @@ export function createVideoStateProvider( 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) { player.src = ""; @@ -118,7 +136,8 @@ export function createVideoStateProvider( updateSource(descriptor, state); }, providerStart() { - // TODO stored volume + this.setVolume(getStoredVolume()); + const pause = () => { state.mediaPlaying.isPaused = true; state.mediaPlaying.isPlaying = false; @@ -167,7 +186,37 @@ export function createVideoStateProvider( 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; + // TODO dispatch airplay + } + }; + const error = () => { + console.error("Native video player threw error", player.error); + state.error = player.error + ? { + description: player.error.message, + name: `Error ${player.error.code}`, + } + : null; + // TODO dispatch error + }; + 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); @@ -178,18 +227,33 @@ export function createVideoStateProvider( player.addEventListener("loadedmetadata", loadedmetadata); player.addEventListener("canplay", canplay); fscreen.addEventListener("fullscreenchange", fullscreenchange); + player.addEventListener("error", error); + player.addEventListener( + "webkitplaybacktargetavailabilitychanged", + canAirplay + ); + 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 + ); }, }; }, diff --git a/src/video/state/types.ts b/src/video/state/types.ts index 37e0969b..81a7cb46 100644 --- a/src/video/state/types.ts +++ b/src/video/state/types.ts @@ -31,8 +31,9 @@ export type VideoPlayerState = { isPaused: boolean; isSeeking: boolean; // seeking with progress bar isLoading: boolean; // buffering or not - isFirstLoading: boolean; // first buffering of the video, used to show + isFirstLoading: boolean; // first buffering of the video, when set to false the video can start playing hasPlayedOnce: boolean; // has the video played at all? + volume: number; }; // state related to video progress @@ -55,9 +56,7 @@ export type VideoPlayerState = { }; // misc - volume: number; - pausedWhenSeeking: boolean; - hasInitialized: boolean; + pausedWhenSeeking: boolean; // when seeking, used to store if paused when started to seek canAirplay: boolean; // backing fields diff --git a/src/views/media/MediaView.tsx b/src/views/media/MediaView.tsx index 2d6e77ec..a3110af3 100644 --- a/src/views/media/MediaView.tsx +++ b/src/views/media/MediaView.tsx @@ -15,6 +15,7 @@ import { MetaController } from "@/video/components/controllers/MetaController"; import { SourceController } from "@/video/components/controllers/SourceController"; import { Icons } from "@/components/Icon"; import { VideoPlayerHeader } from "@/video/components/parts/VideoPlayerHeader"; +import { ProgressListenerController } from "@/video/components/controllers/ProgressListenerController"; import { useWatchedItem } from "@/state/watched"; import { MediaFetchErrorView } from "./MediaErrorView"; import { MediaScrapeLog } from "./MediaScrapeLog"; @@ -112,17 +113,17 @@ export function MediaViewPlayer(props: MediaViewPlayerProps) { - + - {/* */} + /> {/* {props.selected.type === MWMediaType.SERIES && props.meta.meta.type === MWMediaType.SERIES ? (