volume control + progress listener

This commit is contained in:
Jelle van Snik 2023-02-04 18:24:06 +01:00
parent bb14d63a9c
commit c3b409631e
15 changed files with 276 additions and 24 deletions

View File

@ -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);
}
};

View File

@ -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

View File

@ -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() {
>
<PauseAction />
<SkipTimeAction />
{/* <VolumeControl className="mr-2" /> */}
<VolumeAction className="mr-2" />
<TimeAction />
</div>
<ShowTitleAction />
@ -73,10 +74,9 @@ export function VideoPlayer(props: Props) {
[setShow]
);
// TODO autoplay
// TODO safe area only if full screen or fill screen
return (
<VideoPlayerBase>
<VideoPlayerBase autoPlay={props.autoPlay}>
<PageTitleAction />
<VideoPlayerError onGoBack={props.onGoBack}>
<BackdropAction onBackdropChange={onBackdropChange}>

View File

@ -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]"
>
<VideoElementInternal />
<VideoElementInternal autoPlay={props.autoPlay} />
<WrapperRegisterInternal wrapper={ref.current} />
<div className="absolute inset-0">{props.children}</div>
</div>

View File

@ -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<HTMLDivElement>(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 (
<div className={props.className}>
<div
className="pointer-events-auto flex cursor-pointer items-center"
onMouseEnter={handleMouseEnter}
>
<div className="px-4 text-2xl text-white" onClick={handleClick}>
<Icon icon={percentage > 0 ? Icons.VOLUME : Icons.VOLUME_X} />
</div>
<div
className={`linear -ml-2 w-0 overflow-hidden transition-[width,opacity] duration-300 ${
hoveredOnce || dragging ? "!w-24 opacity-100" : "w-4 opacity-0"
}`}
>
<div
ref={ref}
className="flex h-10 w-20 items-center px-2"
onMouseDown={dragMouseDown}
onTouchStart={dragMouseDown}
>
<div className="relative h-1 flex-1 rounded-full bg-gray-500 bg-opacity-50">
<div
className="absolute inset-y-0 left-0 flex items-center justify-end rounded-full bg-bink-500"
style={{
width: percentageString,
}}
>
<div className="absolute h-3 w-3 translate-x-1/2 rounded-full bg-white" />
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -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<true | null>(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;
}

View File

@ -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,
});
}

View File

@ -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<HTMLVideoElement>(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 <video ref={ref} playsInline className="h-full w-full" />;
return (
<video
ref={ref}
autoPlay={props.autoPlay}
muted={mediaPlaying.volume === 0}
playsInline
className="h-full w-full"
/>
);
}

View File

@ -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,

View File

@ -40,6 +40,9 @@ export function useControls(
enterFullscreen() {
state.stateProvider?.enterFullscreen();
},
setVolume(volume) {
state.stateProvider?.setVolume(volume);
},
// other controls
setLeftControlsHover(hovering) {

View File

@ -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,
};
}

View File

@ -14,6 +14,7 @@ export type VideoPlayerStateController = {
setSeeking(active: boolean): void;
exitFullscreen(): void;
enterFullscreen(): void;
setVolume(volume: number): void;
};
export type VideoPlayerStateProvider = VideoPlayerStateController & {

View File

@ -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
);
},
};
},

View File

@ -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

View File

@ -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) {
<Helmet>
<html data-full="true" />
</Helmet>
<VideoPlayer onGoBack={goBack}>
<VideoPlayer autoPlay onGoBack={goBack}>
<MetaController meta={props.meta.meta} />
<SourceController
source={props.stream.streamUrl}
type={props.stream.type}
quality={props.stream.quality}
/>
{/* <ProgressListenerControl
<ProgressListenerController
startAt={firstStartTime.current}
onProgress={updateProgress}
/> */}
/>
{/* {props.selected.type === MWMediaType.SERIES &&
props.meta.meta.type === MWMediaType.SERIES ? (
<ShowControl