mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-24 04:11:52 +01:00
quality display control, source selection beginning, mobile player UI, keyboard shortcuts
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com> Co-authored-by: James Hawkins <jhawki2005@gmail.com>
This commit is contained in:
parent
701b3db798
commit
8c9d905a91
@ -18,7 +18,7 @@
|
||||
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Because watching movies legally is boring"
|
||||
|
@ -30,6 +30,7 @@ export enum Icons {
|
||||
EPISODES = "episodes",
|
||||
SKIP_FORWARD = "skip_forward",
|
||||
SKIP_BACKWARD = "skip_backward",
|
||||
FILE = "file",
|
||||
}
|
||||
|
||||
export interface IconProps {
|
||||
@ -67,6 +68,7 @@ const iconList: Record<Icons, string> = {
|
||||
episodes: `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M3 4C1.34315 4 0 5.34314 0 7V13.9496C0 15.6065 1.34315 16.9496 3 16.9496H5.86645V14.9496H3C2.44772 14.9496 2 14.5019 2 13.9496V7C2 6.44771 2.44771 6 3 6H16.0327C16.585 6 17.0327 6.44772 17.0327 7V9.86645H19.0327V7C19.0327 5.34315 17.6896 4 16.0327 4H3Z" fill="currentColor"/><rect x="5.89929" y="10.5444" width="17" height="10" rx="2" stroke="currentColor" stroke-width="2"/></svg>`,
|
||||
skip_forward: `<svg width="26" height="24" viewBox="0 0 26 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M11.3333 12.3333L16 7.66667M16 7.66667L11.3333 3M16 7.66667H6.66667C5.42899 7.66667 4.242 8.15833 3.36684 9.0335C2.49167 9.90867 2 11.0957 2 12.3333C2 13.571 2.49167 14.758 3.36684 15.6332C4.242 16.5083 5.42899 17 6.66667 17H9" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" /><path d="M16.5043 14.2727V23H14.6591V16.0241H14.608L12.6094 17.277V15.6406L14.7699 14.2727H16.5043ZM22.0004 23.1918C21.2674 23.1889 20.6367 23.0085 20.1083 22.6506C19.5827 22.2926 19.1779 21.7741 18.8938 21.0952C18.6126 20.4162 18.4734 19.5994 18.4762 18.6449C18.4762 17.6932 18.6168 16.8821 18.8981 16.2116C19.1822 15.5412 19.587 15.0312 20.1126 14.6818C20.641 14.3295 21.2702 14.1534 22.0004 14.1534C22.7305 14.1534 23.3583 14.3295 23.8839 14.6818C24.4123 15.0341 24.8185 15.5455 25.1026 16.2159C25.3867 16.8835 25.5273 17.6932 25.5245 18.6449C25.5245 19.6023 25.3825 20.4205 25.0984 21.0994C24.8171 21.7784 24.4137 22.2969 23.8881 22.6548C23.3626 23.0128 22.7333 23.1918 22.0004 23.1918ZM22.0004 21.6619C22.5004 21.6619 22.8995 21.4105 23.1978 20.9077C23.4961 20.4048 23.6438 19.6506 23.641 18.6449C23.641 17.983 23.5728 17.4318 23.4364 16.9915C23.3029 16.5511 23.1126 16.2202 22.8654 15.9986C22.6211 15.777 22.3327 15.6662 22.0004 15.6662C21.5032 15.6662 21.1055 15.9148 20.8072 16.4119C20.5089 16.9091 20.3583 17.6534 20.3555 18.6449C20.3555 19.3153 20.4222 19.875 20.5558 20.3239C20.6921 20.7699 20.8839 21.1051 21.131 21.3295C21.3782 21.5511 21.668 21.6619 22.0004 21.6619Z" fill="currentColor" /></svg>`,
|
||||
skip_backward: `<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M13.6667 12.3333L9 7.66667M9 7.66667L13.6667 3M9 7.66667H18.3333C19.571 7.66667 20.758 8.15833 21.6332 9.0335C22.5083 9.90867 23 11.0957 23 12.3333C23 13.571 22.5083 14.758 21.6332 15.6332C20.758 16.5083 19.571 17 18.3333 17H16" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.50426 14.2727V23H2.65909V16.0241H2.60795L0.609375 17.277V15.6406L2.76989 14.2727H4.50426ZM10.0004 23.1918C9.2674 23.1889 8.63672 23.0085 8.10831 22.6506C7.58274 22.2926 7.17791 21.7741 6.89382 21.0952C6.61257 20.4162 6.47337 19.5994 6.47621 18.6449C6.47621 17.6932 6.61683 16.8821 6.89808 16.2116C7.18217 15.5412 7.587 15.0312 8.11257 14.6818C8.64098 14.3295 9.27024 14.1534 10.0004 14.1534C10.7305 14.1534 11.3583 14.3295 11.8839 14.6818C12.4123 15.0341 12.8185 15.5455 13.1026 16.2159C13.3867 16.8835 13.5273 17.6932 13.5245 18.6449C13.5245 19.6023 13.3825 20.4205 13.0984 21.0994C12.8171 21.7784 12.4137 22.2969 11.8881 22.6548C11.3626 23.0128 10.7333 23.1918 10.0004 23.1918ZM10.0004 21.6619C10.5004 21.6619 10.8995 21.4105 11.1978 20.9077C11.4961 20.4048 11.6438 19.6506 11.641 18.6449C11.641 17.983 11.5728 17.4318 11.4364 16.9915C11.3029 16.5511 11.1126 16.2202 10.8654 15.9986C10.6211 15.777 10.3327 15.6662 10.0004 15.6662C9.5032 15.6662 9.10547 15.9148 8.80717 16.4119C8.50888 16.9091 8.35831 17.6534 8.35547 18.6449C8.35547 19.3153 8.42223 19.875 8.55575 20.3239C8.69212 20.7699 8.88388 21.1051 9.13104 21.3295C9.3782 21.5511 9.66797 21.6619 10.0004 21.6619Z" fill="currentColor"/></svg>`,
|
||||
file: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-file"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>`,
|
||||
};
|
||||
|
||||
export const Icon = memo((props: IconProps) => {
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { CSSTransition } from "react-transition-group";
|
||||
import { AirplayControl } from "./controls/AirplayControl";
|
||||
@ -7,21 +8,24 @@ import { ChromeCastControl } from "./controls/ChromeCastControl";
|
||||
import { FullscreenControl } from "./controls/FullscreenControl";
|
||||
import { LoadingControl } from "./controls/LoadingControl";
|
||||
import { MiddlePauseControl } from "./controls/MiddlePauseControl";
|
||||
import { MobileCenterControl } from "./controls/MobileCenterControl";
|
||||
import { PageTitleControl } from "./controls/PageTitleControl";
|
||||
import { PauseControl } from "./controls/PauseControl";
|
||||
import { ProgressControl } from "./controls/ProgressControl";
|
||||
import { QualityDisplayControl } from "./controls/QualityDisplayControl";
|
||||
import { SeriesSelectionControl } from "./controls/SeriesSelectionControl";
|
||||
import { ShowTitleControl } from "./controls/ShowTitleControl";
|
||||
import { SkipTime } from "./controls/SkipTime";
|
||||
import { SourceSelectionControl } from "./controls/SourceSelectionControl";
|
||||
import { TimeControl } from "./controls/TimeControl";
|
||||
import { VolumeControl } from "./controls/VolumeControl";
|
||||
import { PageTitleControl } from "./controls/PageTitleControl";
|
||||
import { VideoPlayerError } from "./parts/VideoPlayerError";
|
||||
import { VideoPlayerHeader } from "./parts/VideoPlayerHeader";
|
||||
import { useVideoPlayerState } from "./VideoContext";
|
||||
import { VideoPlayer, VideoPlayerProps } from "./VideoPlayer";
|
||||
|
||||
interface DecoratedVideoPlayerProps {
|
||||
media?: MWMediaMeta;
|
||||
media?: DetailedMeta;
|
||||
onGoBack?: () => void;
|
||||
}
|
||||
|
||||
@ -56,8 +60,10 @@ export function DecoratedVideoPlayer(
|
||||
props: VideoPlayerProps & DecoratedVideoPlayerProps
|
||||
) {
|
||||
const top = useRef<HTMLDivElement>(null);
|
||||
const center = useRef<HTMLDivElement>(null);
|
||||
const bottom = useRef<HTMLDivElement>(null);
|
||||
const [show, setShow] = useState(false);
|
||||
const { isMobile } = useIsMobile();
|
||||
|
||||
const onBackdropChange = useCallback(
|
||||
(showing: boolean) => {
|
||||
@ -68,8 +74,8 @@ export function DecoratedVideoPlayer(
|
||||
|
||||
return (
|
||||
<VideoPlayer autoPlay={props.autoPlay}>
|
||||
<PageTitleControl media={props.media} />
|
||||
<VideoPlayerError media={props.media} onGoBack={props.onGoBack}>
|
||||
<PageTitleControl media={props.media?.meta} />
|
||||
<VideoPlayerError media={props.media?.meta} onGoBack={props.onGoBack}>
|
||||
<BackdropControl onBackdropChange={onBackdropChange}>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<LoadingControl />
|
||||
@ -77,34 +83,29 @@ export function DecoratedVideoPlayer(
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<MiddlePauseControl />
|
||||
</div>
|
||||
<CSSTransition
|
||||
nodeRef={bottom}
|
||||
in={show}
|
||||
timeout={200}
|
||||
classNames={{
|
||||
exit: "transition-[transform,opacity] translate-y-0 duration-200 opacity-100",
|
||||
exitActive: "!translate-y-4 !opacity-0",
|
||||
exitDone: "hidden",
|
||||
enter:
|
||||
"transition-[transform,opacity] translate-y-4 duration-200 opacity-0",
|
||||
enterActive: "!translate-y-0 !opacity-100",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={bottom}
|
||||
className="pointer-events-auto absolute inset-x-0 bottom-0 flex flex-col px-4 pb-2 [margin-bottom:env(safe-area-inset-bottom)]"
|
||||
{isMobile ? (
|
||||
<CSSTransition
|
||||
nodeRef={center}
|
||||
in={show}
|
||||
timeout={200}
|
||||
classNames={{
|
||||
exit: "transition-[transform,opacity] duration-200 opacity-100",
|
||||
exitActive: "!opacity-0",
|
||||
exitDone: "hidden",
|
||||
enter: "transition-[transform,opacity] duration-200 opacity-0",
|
||||
enterActive: "!opacity-100",
|
||||
}}
|
||||
>
|
||||
<ProgressControl />
|
||||
<div className="flex items-center">
|
||||
<LeftSideControls />
|
||||
<div className="flex-1" />
|
||||
<SeriesSelectionControl />
|
||||
<AirplayControl />
|
||||
<ChromeCastControl />
|
||||
<FullscreenControl />
|
||||
<div
|
||||
ref={center}
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<MobileCenterControl />
|
||||
</div>
|
||||
</div>
|
||||
</CSSTransition>
|
||||
</CSSTransition>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<CSSTransition
|
||||
nodeRef={top}
|
||||
in={show}
|
||||
@ -122,7 +123,57 @@ export function DecoratedVideoPlayer(
|
||||
ref={top}
|
||||
className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col py-6 px-8 pb-2"
|
||||
>
|
||||
<VideoPlayerHeader media={props.media} onClick={props.onGoBack} />
|
||||
<VideoPlayerHeader
|
||||
media={props.media?.meta}
|
||||
onClick={props.onGoBack}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</div>
|
||||
</CSSTransition>
|
||||
<CSSTransition
|
||||
nodeRef={bottom}
|
||||
in={show}
|
||||
timeout={200}
|
||||
classNames={{
|
||||
exit: "transition-[transform,opacity] translate-y-0 duration-200 opacity-100",
|
||||
exitActive: "!translate-y-4 !opacity-0",
|
||||
exitDone: "hidden",
|
||||
enter:
|
||||
"transition-[transform,opacity] translate-y-4 duration-200 opacity-0",
|
||||
enterActive: "!translate-y-0 !opacity-100",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={bottom}
|
||||
className="pointer-events-auto absolute inset-x-0 bottom-0 flex flex-col px-4 pb-2 [margin-bottom:env(safe-area-inset-bottom)]"
|
||||
>
|
||||
<div className="flex w-full items-center space-x-3">
|
||||
{isMobile && <SkipTime noDuration />}
|
||||
<ProgressControl />
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{isMobile ? (
|
||||
<div className="grid w-full grid-cols-[56px,1fr,56px] items-center">
|
||||
<div />
|
||||
<div className="flex items-center justify-center">
|
||||
<SeriesSelectionControl />
|
||||
<SourceSelectionControl media={props.media} />
|
||||
</div>
|
||||
<FullscreenControl />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<LeftSideControls />
|
||||
<div className="flex-1" />
|
||||
<QualityDisplayControl />
|
||||
<SeriesSelectionControl />
|
||||
<SourceSelectionControl media={props.media} />
|
||||
<AirplayControl />
|
||||
<ChromeCastControl />
|
||||
<FullscreenControl />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CSSTransition>
|
||||
</BackdropControl>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { MWStreamType } from "@/backend/helpers/streams";
|
||||
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
|
||||
import React, {
|
||||
createContext,
|
||||
MutableRefObject,
|
||||
@ -15,16 +15,23 @@ import {
|
||||
interface VideoPlayerContextType {
|
||||
source: string | null;
|
||||
sourceType: MWStreamType;
|
||||
quality: MWStreamQuality;
|
||||
state: PlayerContext;
|
||||
}
|
||||
const initial: VideoPlayerContextType = {
|
||||
source: null,
|
||||
sourceType: MWStreamType.MP4,
|
||||
quality: MWStreamQuality.QUNKNOWN,
|
||||
state: initialPlayerState,
|
||||
};
|
||||
|
||||
type VideoPlayerContextAction =
|
||||
| { type: "SET_SOURCE"; url: string; sourceType: MWStreamType }
|
||||
| {
|
||||
type: "SET_SOURCE";
|
||||
url: string;
|
||||
sourceType: MWStreamType;
|
||||
quality: MWStreamQuality;
|
||||
}
|
||||
| {
|
||||
type: "UPDATE_PLAYER";
|
||||
state: PlayerContext;
|
||||
@ -38,6 +45,7 @@ function videoPlayerContextReducer(
|
||||
if (action.type === "SET_SOURCE") {
|
||||
video.source = action.url;
|
||||
video.sourceType = action.sourceType;
|
||||
video.quality = action.quality;
|
||||
return video;
|
||||
}
|
||||
if (action.type === "UPDATE_PLAYER") {
|
||||
|
@ -1,7 +1,12 @@
|
||||
import { useGoBack } from "@/hooks/useGoBack";
|
||||
import { useVolumeControl } from "@/hooks/useVolumeToggle";
|
||||
import { forwardRef, useContext, useEffect, useRef } from "react";
|
||||
import { VideoErrorBoundary } from "./parts/VideoErrorBoundary";
|
||||
import { VideoPlayerContext, VideoPlayerContextProvider } from "./VideoContext";
|
||||
import {
|
||||
useVideoPlayerState,
|
||||
VideoPlayerContext,
|
||||
VideoPlayerContextProvider,
|
||||
} from "./VideoContext";
|
||||
|
||||
export interface VideoPlayerProps {
|
||||
autoPlay?: boolean;
|
||||
@ -13,15 +18,83 @@ const VideoPlayerInternals = forwardRef<
|
||||
{ autoPlay: boolean }
|
||||
>((props, ref) => {
|
||||
const video = useContext(VideoPlayerContext);
|
||||
const didInitialize = useRef<true | null>(null);
|
||||
const didInitialize = useRef<{ source: string | null } | null>(null);
|
||||
const { videoState } = useVideoPlayerState();
|
||||
const { toggleVolume } = useVolumeControl();
|
||||
|
||||
useEffect(() => {
|
||||
if (didInitialize.current) return;
|
||||
const value = { source: video.source };
|
||||
const hasChanged = value.source !== didInitialize.current?.source;
|
||||
if (!hasChanged) return;
|
||||
if (!video.state.hasInitialized || !video.source) return;
|
||||
video.state.initPlayer(video.source, video.sourceType);
|
||||
didInitialize.current = true;
|
||||
didInitialize.current = value;
|
||||
}, [didInitialize, video]);
|
||||
|
||||
useEffect(() => {
|
||||
let isRolling = false;
|
||||
const onKeyDown = (evt: KeyboardEvent) => {
|
||||
if (!videoState.isFocused) return;
|
||||
if (!ref || !(ref as any)?.current) return;
|
||||
const el = (ref as any).current as HTMLVideoElement;
|
||||
|
||||
switch (evt.key.toLowerCase()) {
|
||||
// Toggle fullscreen
|
||||
case "f":
|
||||
if (videoState.isFullscreen) {
|
||||
videoState.exitFullscreen();
|
||||
} else {
|
||||
videoState.enterFullscreen();
|
||||
}
|
||||
break;
|
||||
|
||||
// Skip backwards
|
||||
case "arrowleft":
|
||||
videoState.setTime(videoState.time - 5);
|
||||
break;
|
||||
|
||||
// Skip forward
|
||||
case "arrowright":
|
||||
videoState.setTime(videoState.time + 5);
|
||||
break;
|
||||
|
||||
// Pause / play
|
||||
case " ":
|
||||
if (videoState.isPaused) {
|
||||
videoState.play();
|
||||
} else {
|
||||
videoState.pause();
|
||||
}
|
||||
break;
|
||||
|
||||
// Mute
|
||||
case "m":
|
||||
toggleVolume();
|
||||
break;
|
||||
|
||||
// Do a barrel Roll!
|
||||
case "r":
|
||||
if (isRolling) return;
|
||||
isRolling = true;
|
||||
el.classList.add("roll");
|
||||
setTimeout(() => {
|
||||
isRolling = false;
|
||||
el.classList.remove("roll");
|
||||
}, 1000);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [videoState, toggleVolume, ref]);
|
||||
|
||||
// muted attribute is required for safari, as they cant change the volume itself
|
||||
return (
|
||||
<video
|
||||
@ -44,7 +117,8 @@ export function VideoPlayer(props: VideoPlayerProps) {
|
||||
return (
|
||||
<VideoPlayerContextProvider player={playerRef} wrapper={playerWrapperRef}>
|
||||
<div
|
||||
className="relative h-full w-full select-none overflow-hidden bg-black"
|
||||
// Fite me 1v1
|
||||
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]"
|
||||
ref={playerWrapperRef}
|
||||
>
|
||||
<VideoErrorBoundary onGoBack={goBack}>
|
||||
|
20
src/components/video/controls/MobileCenterControl.tsx
Normal file
20
src/components/video/controls/MobileCenterControl.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { useVideoPlayerState } from "../VideoContext";
|
||||
import { PauseControl } from "./PauseControl";
|
||||
import { SkipTimeBackward, SkipTimeForward } from "./TimeControl";
|
||||
|
||||
export function MobileCenterControl() {
|
||||
const { videoState } = useVideoPlayerState();
|
||||
|
||||
const isLoading = videoState.isFirstLoading || videoState.isLoading;
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-8">
|
||||
<SkipTimeBackward />
|
||||
<PauseControl
|
||||
iconSize="text-5xl"
|
||||
className={isLoading ? "pointer-events-none opacity-0" : ""}
|
||||
/>
|
||||
<SkipTimeForward />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -5,6 +5,7 @@ import { useVideoPlayerState } from "../VideoContext";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
iconSize?: string;
|
||||
}
|
||||
|
||||
export function PauseControl(props: Props) {
|
||||
@ -20,6 +21,7 @@ export function PauseControl(props: Props) {
|
||||
|
||||
return (
|
||||
<VideoPlayerIconButton
|
||||
iconSize={props.iconSize}
|
||||
className={props.className}
|
||||
icon={icon}
|
||||
onClick={handleClick}
|
||||
|
@ -40,7 +40,7 @@ export function ProgressControl() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="group pointer-events-auto cursor-pointer rounded-full px-2">
|
||||
<div className="group pointer-events-auto w-full cursor-pointer rounded-full px-2">
|
||||
<div
|
||||
ref={ref}
|
||||
className="-my-3 flex h-8 items-center"
|
||||
|
14
src/components/video/controls/QualityDisplayControl.tsx
Normal file
14
src/components/video/controls/QualityDisplayControl.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { useContext } from "react";
|
||||
import { VideoPlayerContext } from "../VideoContext";
|
||||
|
||||
export function QualityDisplayControl() {
|
||||
const videoPlayerContext = useContext(VideoPlayerContext);
|
||||
|
||||
return (
|
||||
<div className="rounded-md bg-denim-300 py-1 px-2 transition-colors">
|
||||
<p className="text-center text-xs font-bold text-slate-300 transition-colors">
|
||||
{videoPlayerContext.quality}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -28,6 +28,7 @@ function formatSeconds(secs: number, showHours = false): string {
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
noDuration?: boolean;
|
||||
}
|
||||
|
||||
export function SkipTime(props: Props) {
|
||||
@ -39,7 +40,7 @@ export function SkipTime(props: Props) {
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<p className="select-none text-white">
|
||||
{time} / {duration}
|
||||
{time} {props.noDuration ? "" : `/ ${duration}`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,10 +1,11 @@
|
||||
import { MWStreamType } from "@/backend/helpers/streams";
|
||||
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
|
||||
import { useContext, useEffect, useRef } from "react";
|
||||
import { VideoPlayerDispatchContext } from "../VideoContext";
|
||||
|
||||
interface SourceControlProps {
|
||||
source: string;
|
||||
type: MWStreamType;
|
||||
quality: MWStreamQuality;
|
||||
}
|
||||
|
||||
export function SourceControl(props: SourceControlProps) {
|
||||
@ -17,6 +18,7 @@ export function SourceControl(props: SourceControlProps) {
|
||||
type: "SET_SOURCE",
|
||||
url: props.source,
|
||||
sourceType: props.type,
|
||||
quality: props.quality,
|
||||
});
|
||||
didInitialize.current = true;
|
||||
}, [props, dispatch]);
|
||||
|
185
src/components/video/controls/SourceSelectionControl.tsx
Normal file
185
src/components/video/controls/SourceSelectionControl.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useCallback, useContext, useMemo, useState } from "react";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { getProviders } from "@/backend/helpers/register";
|
||||
import { useLoading } from "@/hooks/useLoading";
|
||||
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
||||
import { MWMediaType } from "@/backend/metadata/types";
|
||||
import { MWProviderScrapeResult } from "@/backend/helpers/provider";
|
||||
import { runProvider } from "@/backend/helpers/run";
|
||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||
import { Loading } from "@/components/layout/Loading";
|
||||
import {
|
||||
useVideoPlayerState,
|
||||
VideoPlayerDispatchContext,
|
||||
} from "../VideoContext";
|
||||
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
|
||||
import { VideoPopout } from "../parts/VideoPopout";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
media?: DetailedMeta;
|
||||
}
|
||||
|
||||
function PopoutSourceSelect(props: { media: DetailedMeta }) {
|
||||
const dispatch = useContext(VideoPlayerDispatchContext);
|
||||
const providers = useMemo(
|
||||
() => getProviders().filter((v) => v.type.includes(props.media.meta.type)),
|
||||
[props]
|
||||
);
|
||||
const { episode, season } = useParams<{ episode: string; season: string }>();
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const selectedProvider = useMemo(
|
||||
() => providers.find((v) => v.id === selected),
|
||||
[selected, providers]
|
||||
);
|
||||
|
||||
const [scrapeData, setScrapeData] = useState<MWProviderScrapeResult | null>(
|
||||
null
|
||||
);
|
||||
const [scrapeProvider, loadingProvider, errorProvider] = useLoading(
|
||||
async (providerId: string) => {
|
||||
const theProvider = providers.find((v) => v.id === providerId);
|
||||
if (!theProvider) throw new Error("Invalid provider");
|
||||
return runProvider(theProvider, {
|
||||
media: props.media,
|
||||
progress: () => {},
|
||||
type: props.media.meta.type,
|
||||
episode: (props.media.meta.type === MWMediaType.SERIES
|
||||
? episode
|
||||
: undefined) as any,
|
||||
season: (props.media.meta.type === MWMediaType.SERIES
|
||||
? season
|
||||
: undefined) as any,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// TODO add embed support
|
||||
// TODO restore startAt when changing source
|
||||
// TODO auto choose when only one option
|
||||
// TODO close when selecting item
|
||||
// TODO show currently selected provider
|
||||
// TODO clear error state when switching
|
||||
// const [scrapeEmbed, embedLoading, embedError] = useLoading(
|
||||
// async (embed: MWEmbed) => {
|
||||
// if (!embed.type) throw new Error("Invalid embed type");
|
||||
// const theScraper = getEmbedScraperByType(embed.type);
|
||||
// if (!theScraper) throw new Error("Invalid scraper");
|
||||
// return runEmbedScraper(theScraper, {
|
||||
// progress: () => {},
|
||||
// url: embed.url,
|
||||
// });
|
||||
// }
|
||||
// );
|
||||
|
||||
const selectProvider = useCallback(
|
||||
(id: string) => {
|
||||
scrapeProvider(id).then((v) => {
|
||||
if (!v) throw new Error("No scrape result");
|
||||
setScrapeData(v);
|
||||
});
|
||||
setSelected(id);
|
||||
},
|
||||
[setSelected, scrapeProvider]
|
||||
);
|
||||
|
||||
if (!selectedProvider)
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center space-x-3 border-b border-denim-500 p-4 font-bold text-white">
|
||||
<span>Select video source</span>
|
||||
</div>
|
||||
<div className="overflow-y-auto p-4">
|
||||
<div className="space-y-1">
|
||||
{providers.map((e) => (
|
||||
<div
|
||||
className="text-denim-800 -mx-2 flex items-center space-x-1 rounded p-2 text-white hover:bg-denim-600"
|
||||
onClick={() => selectProvider(e.id)}
|
||||
key={e.id}
|
||||
>
|
||||
{e.displayName}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center space-x-3 border-b border-denim-500 p-4 font-bold text-white">
|
||||
<button
|
||||
className="-m-1.5 rounded p-1.5 hover:bg-denim-600"
|
||||
onClick={() => setSelected(null)}
|
||||
type="button"
|
||||
>
|
||||
<Icon icon={Icons.CHEVRON_LEFT} />
|
||||
</button>
|
||||
<span>{selectedProvider.displayName}</span>
|
||||
</div>
|
||||
<div className="overflow-y-auto p-4 text-white">
|
||||
{loadingProvider ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Loading />
|
||||
</div>
|
||||
) : errorProvider ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="flex flex-col flex-wrap items-center text-slate-400">
|
||||
<IconPatch
|
||||
icon={Icons.EYE_SLASH}
|
||||
className="text-xl text-bink-600"
|
||||
/>
|
||||
<p className="mt-6 w-full text-center">
|
||||
Something went wrong loading streams.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : scrapeData ? (
|
||||
<div>
|
||||
{scrapeData.stream ? (
|
||||
<div
|
||||
className="text-denim-800 -mx-2 flex items-center space-x-1 rounded p-2 text-white hover:bg-denim-600"
|
||||
onClick={() =>
|
||||
scrapeData.stream &&
|
||||
dispatch({
|
||||
url: scrapeData.stream.streamUrl,
|
||||
quality: scrapeData.stream.quality,
|
||||
sourceType: scrapeData.stream.type,
|
||||
type: "SET_SOURCE",
|
||||
})
|
||||
}
|
||||
>
|
||||
{selectedProvider.displayName}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SourceSelectionControl(props: Props) {
|
||||
const { videoState } = useVideoPlayerState();
|
||||
|
||||
if (!props.media) return null;
|
||||
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<div className="relative">
|
||||
<VideoPopout
|
||||
id="source"
|
||||
className="grid grid-rows-[auto,minmax(0,1fr)]"
|
||||
>
|
||||
<PopoutSourceSelect media={props.media} />
|
||||
</VideoPopout>
|
||||
<VideoPlayerIconButton
|
||||
icon={Icons.FILE}
|
||||
text="Video source"
|
||||
onClick={() => videoState.openPopout("source")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -6,29 +6,37 @@ interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TimeControl(props: Props) {
|
||||
export function SkipTimeBackward() {
|
||||
const { videoState } = useVideoPlayerState();
|
||||
|
||||
const skipForward = () => {
|
||||
videoState.setTime(videoState.time + 10);
|
||||
};
|
||||
|
||||
const skipBackward = () => {
|
||||
videoState.setTime(videoState.time - 10);
|
||||
};
|
||||
|
||||
return (
|
||||
<VideoPlayerIconButton icon={Icons.SKIP_BACKWARD} onClick={skipBackward} />
|
||||
);
|
||||
}
|
||||
|
||||
export function SkipTimeForward() {
|
||||
const { videoState } = useVideoPlayerState();
|
||||
|
||||
const skipForward = () => {
|
||||
videoState.setTime(videoState.time + 10);
|
||||
};
|
||||
|
||||
return (
|
||||
<VideoPlayerIconButton icon={Icons.SKIP_FORWARD} onClick={skipForward} />
|
||||
);
|
||||
}
|
||||
|
||||
export function TimeControl(props: Props) {
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<p className="flex select-none items-center text-white">
|
||||
<VideoPlayerIconButton
|
||||
icon={Icons.SKIP_BACKWARD}
|
||||
onClick={skipBackward}
|
||||
/>
|
||||
<VideoPlayerIconButton
|
||||
icon={Icons.SKIP_FORWARD}
|
||||
onClick={skipForward}
|
||||
/>
|
||||
</p>
|
||||
<div className="flex select-none items-center text-white">
|
||||
<SkipTimeBackward />
|
||||
<SkipTimeForward />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ import {
|
||||
makePercentageString,
|
||||
useProgressBar,
|
||||
} from "@/hooks/useProgressBar";
|
||||
import { useVolumeControl } from "@/hooks/useVolumeToggle";
|
||||
import { canChangeVolume } from "@/utils/detectFeatures";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useVideoPlayerState } from "../VideoContext";
|
||||
@ -15,7 +16,7 @@ interface Props {
|
||||
export function VolumeControl(props: Props) {
|
||||
const { videoState } = useVideoPlayerState();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [storedVolume, setStoredVolume] = useState(1);
|
||||
const { setStoredVolume, toggleVolume } = useVolumeControl();
|
||||
const [hoveredOnce, setHoveredOnce] = useState(false);
|
||||
|
||||
const commitVolume = useCallback(
|
||||
@ -36,13 +37,8 @@ export function VolumeControl(props: Props) {
|
||||
}, [videoState, setHoveredOnce]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (videoState.volume > 0) {
|
||||
videoState.setVolume(0);
|
||||
setStoredVolume(videoState.volume);
|
||||
} else {
|
||||
videoState.setVolume(storedVolume > 0 ? storedVolume : 1);
|
||||
}
|
||||
}, [videoState, setStoredVolume, storedVolume]);
|
||||
toggleVolume();
|
||||
}, [toggleVolume]);
|
||||
|
||||
const handleMouseEnter = useCallback(async () => {
|
||||
if (await canChangeVolume()) setHoveredOnce(true);
|
||||
|
@ -24,6 +24,7 @@ export type PlayerState = {
|
||||
leftControlHovering: boolean;
|
||||
hasPlayedOnce: boolean;
|
||||
popout: string | null;
|
||||
isFocused: boolean;
|
||||
seasonData: {
|
||||
isSeries: boolean;
|
||||
current?: {
|
||||
@ -50,6 +51,7 @@ export const initialPlayerState: PlayerContext = {
|
||||
isPlaying: false,
|
||||
isPaused: true,
|
||||
isFullscreen: false,
|
||||
isFocused: false,
|
||||
isLoading: false,
|
||||
isSeeking: false,
|
||||
isFirstLoading: true,
|
||||
@ -177,7 +179,19 @@ function registerListeners(player: HTMLVideoElement, update: SetPlayer) {
|
||||
}));
|
||||
}
|
||||
};
|
||||
const isFocused = (evt: any) => {
|
||||
update((s) => ({
|
||||
...s,
|
||||
isFocused: evt.type !== "mouseleave",
|
||||
}));
|
||||
};
|
||||
|
||||
const playerWrapper = player.closest(".is-video-player");
|
||||
if (!playerWrapper) return;
|
||||
|
||||
playerWrapper.addEventListener("click", isFocused);
|
||||
playerWrapper.addEventListener("mouseenter", isFocused);
|
||||
playerWrapper.addEventListener("mouseleave", isFocused);
|
||||
player.addEventListener("pause", pause);
|
||||
player.addEventListener("playing", playing);
|
||||
player.addEventListener("seeking", seeking);
|
||||
@ -196,6 +210,9 @@ function registerListeners(player: HTMLVideoElement, update: SetPlayer) {
|
||||
);
|
||||
|
||||
return () => {
|
||||
playerWrapper.removeEventListener("click", isFocused);
|
||||
playerWrapper.removeEventListener("mouseenter", isFocused);
|
||||
playerWrapper.removeEventListener("mouseleave", isFocused);
|
||||
player.removeEventListener("pause", pause);
|
||||
player.removeEventListener("playing", playing);
|
||||
player.removeEventListener("seeking", seeking);
|
||||
|
@ -6,10 +6,13 @@ import {
|
||||
getIfBookmarkedFromPortable,
|
||||
useBookmarkContext,
|
||||
} from "@/state/bookmark";
|
||||
import { AirplayControl } from "../controls/AirplayControl";
|
||||
import { ChromeCastControl } from "../controls/ChromeCastControl";
|
||||
|
||||
interface VideoPlayerHeaderProps {
|
||||
media?: MWMediaMeta;
|
||||
onClick?: () => void;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
export function VideoPlayerHeader(props: VideoPlayerHeaderProps) {
|
||||
@ -40,7 +43,7 @@ export function VideoPlayerHeader(props: VideoPlayerHeaderProps) {
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
{props.media ? (
|
||||
{props.media && (
|
||||
<IconPatch
|
||||
clickable
|
||||
transparent
|
||||
@ -50,9 +53,16 @@ export function VideoPlayerHeader(props: VideoPlayerHeaderProps) {
|
||||
props.media && setItemBookmark(props.media, !isBookmarked)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
<BrandPill />
|
||||
{props.isMobile ? (
|
||||
<>
|
||||
<AirplayControl />
|
||||
<ChromeCastControl />
|
||||
</>
|
||||
) : (
|
||||
<BrandPill />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ export interface VideoPlayerIconButtonProps {
|
||||
icon: Icons;
|
||||
text?: string;
|
||||
className?: string;
|
||||
iconSize?: string;
|
||||
}
|
||||
|
||||
export function VideoPlayerIconButton(props: VideoPlayerIconButtonProps) {
|
||||
@ -17,7 +18,7 @@ export function VideoPlayerIconButton(props: VideoPlayerIconButtonProps) {
|
||||
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="text-2xl" />
|
||||
<Icon icon={props.icon} className={props.iconSize ?? "text-2xl"} />
|
||||
{props.text ? <span className="ml-2">{props.text}</span> : null}
|
||||
</div>
|
||||
</button>
|
||||
|
@ -8,6 +8,7 @@ interface Props {
|
||||
}
|
||||
|
||||
// TODO store popout in router history so you can press back to yeet
|
||||
// TODO add transition
|
||||
export function VideoPopout(props: Props) {
|
||||
const { videoState } = useVideoPlayerState();
|
||||
const popoutRef = useRef<HTMLDivElement>(null);
|
||||
@ -16,38 +17,42 @@ export function VideoPopout(props: Props) {
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const popoutEl = popoutRef.current;
|
||||
let hasTriggered = false;
|
||||
function windowClick() {
|
||||
setTimeout(() => {
|
||||
if (hasTriggered) return;
|
||||
videoState.closePopout();
|
||||
hasTriggered = false;
|
||||
}, 10);
|
||||
}
|
||||
function popoutClick() {
|
||||
hasTriggered = true;
|
||||
setTimeout(() => {
|
||||
hasTriggered = false;
|
||||
}, 100);
|
||||
function windowClick(e: MouseEvent) {
|
||||
const rect = popoutEl?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
if (
|
||||
e.pageX >= rect.x &&
|
||||
e.pageX <= rect.x + rect.width &&
|
||||
e.pageY >= rect.y &&
|
||||
e.pageY <= rect.y + rect.height
|
||||
) {
|
||||
// inside bounding box of popout
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
videoState.closePopout();
|
||||
}
|
||||
|
||||
window.addEventListener("click", windowClick);
|
||||
popoutEl?.addEventListener("click", popoutClick);
|
||||
return () => {
|
||||
window.removeEventListener("click", windowClick);
|
||||
popoutEl?.removeEventListener("click", popoutClick);
|
||||
};
|
||||
}, [isOpen, videoState]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="is-popout absolute inset-x-0 h-0">
|
||||
<div
|
||||
className={[
|
||||
"is-popout absolute inset-x-0 h-0",
|
||||
!isOpen ? "hidden" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="absolute bottom-10 right-0 h-96 w-72 rounded-lg bg-denim-400">
|
||||
<div
|
||||
ref={popoutRef}
|
||||
className={["h-full w-full", props.className].join(" ")}
|
||||
>
|
||||
{props.children}
|
||||
{isOpen ? props.children : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
28
src/hooks/useIsMobile.ts
Normal file
28
src/hooks/useIsMobile.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const isMobileCurrent = useRef<boolean | null>(false);
|
||||
|
||||
useEffect(() => {
|
||||
function onResize() {
|
||||
const value = window.innerWidth < 1024;
|
||||
const isChanged = isMobileCurrent.current !== value;
|
||||
if (!isChanged) return;
|
||||
|
||||
isMobileCurrent.current = value;
|
||||
setIsMobile(value);
|
||||
}
|
||||
|
||||
onResize();
|
||||
window.addEventListener("resize", onResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isMobile,
|
||||
};
|
||||
}
|
@ -17,7 +17,11 @@ export function makePercentage(num: number) {
|
||||
function isClickEvent(
|
||||
evt: ActivityEvent
|
||||
): evt is React.MouseEvent<HTMLElement> | MouseEvent {
|
||||
return evt.type === "mousedown" || evt.type === "mouseup";
|
||||
return (
|
||||
evt.type === "mousedown" ||
|
||||
evt.type === "mouseup" ||
|
||||
evt.type === "mousemove"
|
||||
);
|
||||
}
|
||||
|
||||
const getEventX = (evt: ActivityEvent) => {
|
||||
|
22
src/hooks/useVolumeToggle.ts
Normal file
22
src/hooks/useVolumeToggle.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { useVideoPlayerState } from "@/components/video/VideoContext";
|
||||
import { useState } from "react";
|
||||
|
||||
export function useVolumeControl() {
|
||||
const [storedVolume, setStoredVolume] = useState(1);
|
||||
const { videoState } = useVideoPlayerState();
|
||||
|
||||
const toggleVolume = () => {
|
||||
if (videoState.volume > 0) {
|
||||
setStoredVolume(videoState.volume);
|
||||
videoState.setVolume(0);
|
||||
} else {
|
||||
videoState.setVolume(storedVolume > 0 ? storedVolume : 1);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
storedVolume,
|
||||
setStoredVolume,
|
||||
toggleVolume,
|
||||
};
|
||||
}
|
@ -18,12 +18,9 @@ if (key) {
|
||||
|
||||
// TODO video todos:
|
||||
// - captions
|
||||
// - mobile UI
|
||||
// - chrome cast support
|
||||
// - source selection
|
||||
// - safari fullscreen will make video overlap player controls
|
||||
// - safari progress bar is fucked (video doesnt change time but video.currentTime does change)
|
||||
// - safari progress bar cannot be dragged
|
||||
|
||||
// TODO stuff to test:
|
||||
// - browser: firefox, chrome, edge, safari desktop
|
||||
@ -35,12 +32,11 @@ if (key) {
|
||||
|
||||
// TODO backend system:
|
||||
// - caption support
|
||||
// - move over old providers to new system
|
||||
// - implement jons providers/embedscrapers
|
||||
// - AFTER all that: rank providers/embedscrapers
|
||||
|
||||
// TODO general todos:
|
||||
// - localize everything (fix loading screen text (series vs movies))
|
||||
// - localize everything (fix loading screen text (series vs movies)) (and have EN file instead)
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
|
@ -23,3 +23,16 @@ html[data-full], html[data-full] body {
|
||||
body[data-no-select] {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.roll {
|
||||
animation: roll 1s;
|
||||
}
|
||||
|
||||
@keyframes roll {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
@ -113,10 +113,11 @@ export function MediaViewPlayer(props: MediaViewPlayerProps) {
|
||||
<Helmet>
|
||||
<html data-full="true" />
|
||||
</Helmet>
|
||||
<DecoratedVideoPlayer media={props.meta.meta} onGoBack={goBack} autoPlay>
|
||||
<DecoratedVideoPlayer media={props.meta} onGoBack={goBack} autoPlay>
|
||||
<SourceControl
|
||||
source={props.stream.streamUrl}
|
||||
type={props.stream.type}
|
||||
quality={props.stream.quality}
|
||||
/>
|
||||
<ProgressListenerControl
|
||||
startAt={firstStartTime.current}
|
||||
|
Loading…
Reference in New Issue
Block a user