mirror of
https://github.com/movie-web/movie-web.git
synced 2024-11-13 08:35:08 +01:00
volume control + progress listener
This commit is contained in:
parent
bb14d63a9c
commit
c3b409631e
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -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
|
||||
|
@ -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}>
|
||||
|
@ -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>
|
||||
|
92
src/video/components/actions/VolumeAction.tsx
Normal file
92
src/video/components/actions/VolumeAction.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
25
src/video/components/hooks/volumeStore.ts
Normal file
25
src/video/components/hooks/volumeStore.ts
Normal 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,
|
||||
});
|
||||
}
|
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -40,6 +40,9 @@ export function useControls(
|
||||
enterFullscreen() {
|
||||
state.stateProvider?.enterFullscreen();
|
||||
},
|
||||
setVolume(volume) {
|
||||
state.stateProvider?.setVolume(volume);
|
||||
},
|
||||
|
||||
// other controls
|
||||
setLeftControlsHover(hovering) {
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -14,6 +14,7 @@ export type VideoPlayerStateController = {
|
||||
setSeeking(active: boolean): void;
|
||||
exitFullscreen(): void;
|
||||
enterFullscreen(): void;
|
||||
setVolume(volume: number): void;
|
||||
};
|
||||
|
||||
export type VideoPlayerStateProvider = VideoPlayerStateController & {
|
||||
|
@ -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
|
||||
);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user