From 20685577abcf63fb3607f2f4e9c14f2b052a2411 Mon Sep 17 00:00:00 2001 From: Jelle van Snik Date: Mon, 23 Jan 2023 22:38:05 +0100 Subject: [PATCH] skip buttons, better popout handling Co-authored-by: Jip Frijlink --- src/components/Icon.tsx | 4 ++ src/components/video/DecoratedVideoPlayer.tsx | 4 +- .../video/controls/BackdropControl.tsx | 3 ++ .../video/controls/ProgressControl.tsx | 1 + .../video/controls/SeriesSelectionControl.tsx | 30 +++-------- src/components/video/controls/SkipTime.tsx | 46 ++++++++++++++++ src/components/video/controls/TimeControl.tsx | 24 +++++++-- src/components/video/hooks/controlVideo.ts | 10 ++++ src/components/video/hooks/useVideoPlayer.ts | 2 + src/components/video/parts/VideoPopout.tsx | 54 +++++++++++++++++++ src/hooks/useProgressBar.ts | 26 +++++++-- 11 files changed, 172 insertions(+), 32 deletions(-) create mode 100644 src/components/video/controls/SkipTime.tsx create mode 100644 src/components/video/parts/VideoPopout.tsx diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index 92455784..d03a9efc 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -28,6 +28,8 @@ export enum Icons { EDIT = "edit", AIRPLAY = "airplay", EPISODES = "episodes", + SKIP_FORWARD = "skip_forward", + SKIP_BACKWARD = "skip_backward", } export interface IconProps { @@ -63,6 +65,8 @@ const iconList: Record = { bookmark_outline: ``, airplay: ``, episodes: ``, + skip_forward: ``, + skip_backward: ``, }; export const Icon = memo((props: IconProps) => { diff --git a/src/components/video/DecoratedVideoPlayer.tsx b/src/components/video/DecoratedVideoPlayer.tsx index a7b7601d..b86ec2ff 100644 --- a/src/components/video/DecoratedVideoPlayer.tsx +++ b/src/components/video/DecoratedVideoPlayer.tsx @@ -11,6 +11,7 @@ import { PauseControl } from "./controls/PauseControl"; import { ProgressControl } from "./controls/ProgressControl"; import { SeriesSelectionControl } from "./controls/SeriesSelectionControl"; import { ShowTitleControl } from "./controls/ShowTitleControl"; +import { SkipTime } from "./controls/SkipTime"; import { TimeControl } from "./controls/TimeControl"; import { VolumeControl } from "./controls/VolumeControl"; import { VideoPlayerError } from "./parts/VideoPlayerError"; @@ -41,8 +42,9 @@ function LeftSideControls() { onMouseEnter={handleMouseEnter} > - + + diff --git a/src/components/video/controls/BackdropControl.tsx b/src/components/video/controls/BackdropControl.tsx index 2fba50f3..af9a9967 100644 --- a/src/components/video/controls/BackdropControl.tsx +++ b/src/components/video/controls/BackdropControl.tsx @@ -29,6 +29,8 @@ export function BackdropControl(props: BackdropControlProps) { (e: React.MouseEvent) => { if (!clickareaRef.current || clickareaRef.current !== e.target) return; + if (videoState.popout !== null) return; + if (videoState.isPlaying) videoState.pause(); else videoState.play(); }, @@ -49,6 +51,7 @@ export function BackdropControl(props: BackdropControlProps) { const currentValue = moved || videoState.isPaused; if (currentValue !== lastBackdropValue.current) { lastBackdropValue.current = currentValue; + if (!currentValue) videoState.closePopout(); props.onBackdropChange?.(currentValue); } }, [videoState, moved, props]); diff --git a/src/components/video/controls/ProgressControl.tsx b/src/components/video/controls/ProgressControl.tsx index eaeed9ee..0f1c496b 100644 --- a/src/components/video/controls/ProgressControl.tsx +++ b/src/components/video/controls/ProgressControl.tsx @@ -45,6 +45,7 @@ export function ProgressControl() { ref={ref} className="-my-3 flex h-8 items-center" onMouseDown={dragMouseDown} + onTouchStart={dragMouseDown} >
-
-
- {props.children} -
-
-
- ); -} - function PopupSection(props: { children?: React.ReactNode; className?: string; @@ -185,22 +171,22 @@ function PopupEpisodeSelect() { export function SeriesSelectionControl(props: Props) { const { videoState } = useVideoPlayerState(); - const [open, setOpen] = useState(false); if (!videoState.seasonData.isSeries) return null; return (
- {open ? ( - - - - ) : null} + + + setOpen((s) => !s)} + onClick={() => videoState.openPopout("episodes")} />
diff --git a/src/components/video/controls/SkipTime.tsx b/src/components/video/controls/SkipTime.tsx new file mode 100644 index 00000000..59c2923f --- /dev/null +++ b/src/components/video/controls/SkipTime.tsx @@ -0,0 +1,46 @@ +import { useVideoPlayerState } from "../VideoContext"; + +function durationExceedsHour(secs: number): boolean { + return secs > 60 * 60; +} + +function formatSeconds(secs: number, showHours = false): string { + if (Number.isNaN(secs)) { + if (showHours) return "0:00:00"; + return "0:00"; + } + + let time = secs; + const seconds = Math.floor(time % 60); + + time /= 60; + const minutes = Math.floor(time % 60); + + time /= 60; + const hours = Math.floor(time); + + const paddedSecs = seconds.toString().padStart(2, "0"); + const paddedMins = minutes.toString().padStart(2, "0"); + + if (!showHours) return [minutes, paddedSecs].join(":"); + return [hours, paddedMins, paddedSecs].join(":"); +} + +interface Props { + className?: string; +} + +export function SkipTime(props: Props) { + const { videoState } = useVideoPlayerState(); + const hasHours = durationExceedsHour(videoState.duration); + const time = formatSeconds(videoState.time, hasHours); + const duration = formatSeconds(videoState.duration, hasHours); + + return ( +
+

+ {time} / {duration} +

+
+ ); +} diff --git a/src/components/video/controls/TimeControl.tsx b/src/components/video/controls/TimeControl.tsx index 1adea300..1012b560 100644 --- a/src/components/video/controls/TimeControl.tsx +++ b/src/components/video/controls/TimeControl.tsx @@ -1,3 +1,5 @@ +import { Icon, Icons } from "@/components/Icon"; +import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton"; import { useVideoPlayerState } from "../VideoContext"; function durationExceedsHour(secs: number): boolean { @@ -32,14 +34,26 @@ interface Props { export function TimeControl(props: Props) { const { videoState } = useVideoPlayerState(); - const hasHours = durationExceedsHour(videoState.duration); - const time = formatSeconds(videoState.time, hasHours); - const duration = formatSeconds(videoState.duration, hasHours); + + const skipForward = () => { + videoState.setTime(videoState.time + 10); + }; + + const skipBackward = () => { + videoState.setTime(videoState.time - 10); + }; return (
-

- {time} / {duration} +

+ +

); diff --git a/src/components/video/hooks/controlVideo.ts b/src/components/video/hooks/controlVideo.ts index 57d7c130..7a34543c 100644 --- a/src/components/video/hooks/controlVideo.ts +++ b/src/components/video/hooks/controlVideo.ts @@ -42,6 +42,8 @@ export interface PlayerControls { setShowData(data: ShowData): void; setCurrentEpisode(sId: string, eId: string): void; startAirplay(): void; + openPopout(id: string): void; + closePopout(): void; } export const initialControls: PlayerControls = { @@ -57,6 +59,8 @@ export const initialControls: PlayerControls = { setShowData: () => null, startAirplay: () => null, setCurrentEpisode: () => null, + openPopout: () => null, + closePopout: () => null, }; export function populateControls( @@ -129,6 +133,12 @@ export function populateControls( setLeftControlsHover(hovering) { update((s) => ({ ...s, leftControlHovering: hovering })); }, + openPopout(id: string) { + update((s) => ({ ...s, popout: id })); + }, + closePopout() { + update((s) => ({ ...s, popout: null })); + }, setShowData(data) { update((s) => ({ ...s, seasonData: data })); }, diff --git a/src/components/video/hooks/useVideoPlayer.ts b/src/components/video/hooks/useVideoPlayer.ts index b1099d08..5ba7dbac 100644 --- a/src/components/video/hooks/useVideoPlayer.ts +++ b/src/components/video/hooks/useVideoPlayer.ts @@ -23,6 +23,7 @@ export type PlayerState = { hasInitialized: boolean; leftControlHovering: boolean; hasPlayedOnce: boolean; + popout: string | null; seasonData: { isSeries: boolean; current?: { @@ -61,6 +62,7 @@ export const initialPlayerState: PlayerContext = { leftControlHovering: false, hasPlayedOnce: false, error: null, + popout: null, seasonData: { isSeries: false, }, diff --git a/src/components/video/parts/VideoPopout.tsx b/src/components/video/parts/VideoPopout.tsx new file mode 100644 index 00000000..6ad49302 --- /dev/null +++ b/src/components/video/parts/VideoPopout.tsx @@ -0,0 +1,54 @@ +import { useEffect, useRef } from "react"; +import { useVideoPlayerState } from "../VideoContext"; + +interface Props { + children?: React.ReactNode; + id?: string; + className?: string; +} + +export function VideoPopout(props: Props) { + const { videoState } = useVideoPlayerState(); + const popoutRef = useRef(null); + const isOpen = videoState.popout === props.id; + + 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); + } + window.addEventListener("click", windowClick); + popoutEl?.addEventListener("click", popoutClick); + return () => { + window.removeEventListener("click", windowClick); + popoutEl?.removeEventListener("click", popoutClick); + }; + }, [isOpen, videoState]); + + if (!isOpen) return null; + + return ( +
+
+
+ {props.children} +
+
+
+ ); +} diff --git a/src/hooks/useProgressBar.ts b/src/hooks/useProgressBar.ts index 7bb7070b..ad069709 100644 --- a/src/hooks/useProgressBar.ts +++ b/src/hooks/useProgressBar.ts @@ -1,5 +1,9 @@ import React, { RefObject, useCallback, useEffect, useState } from "react"; +type ActivityEvent = + | React.MouseEvent + | React.TouchEvent; + export function makePercentageString(num: number) { return `${num.toFixed(2)}%`; } @@ -8,6 +12,18 @@ export function makePercentage(num: number) { return Number(Math.max(0, Math.min(num, 100)).toFixed(2)); } +function isClickEvent( + evt: React.MouseEvent | React.TouchEvent +): evt is React.MouseEvent { + return evt.type === "mousedown"; +} + +const getEventX = ( + evt: React.MouseEvent | React.TouchEvent +) => { + return isClickEvent(evt) ? evt.pageX : evt.touches[0].pageX; +}; + export function useProgressBar( barRef: RefObject, commit: (percentage: number) => void, @@ -25,19 +41,20 @@ export function useProgressBar( if (commitImmediately) commit(pos); } - function mouseUp(ev: MouseEvent) { + function mouseUp(ev: MouseEvent | TouchEvent) { if (!mouseDown) return; setMouseDown(false); document.body.removeAttribute("data-no-select"); if (!barRef.current) return; const rect = barRef.current.getBoundingClientRect(); - const pos = (ev.pageX - rect.left) / barRef.current.offsetWidth; + const pos = (getEventX(ev) - rect.left) / barRef.current.offsetWidth; commit(pos); } document.addEventListener("mousemove", mouseMove); document.addEventListener("mouseup", mouseUp); + document.addEventListener("touchend", mouseUp); return () => { document.removeEventListener("mousemove", mouseMove); @@ -46,13 +63,14 @@ export function useProgressBar( }, [mouseDown, barRef, commit, commitImmediately]); const dragMouseDown = useCallback( - (ev: React.MouseEvent) => { + (ev: React.MouseEvent | React.TouchEvent) => { setMouseDown(true); document.body.setAttribute("data-no-select", "true"); if (!barRef.current) return; const rect = barRef.current.getBoundingClientRect(); - const pos = ((ev.pageX - rect.left) / barRef.current.offsetWidth) * 100; + const pos = + ((getEventX(ev) - rect.left) / barRef.current.offsetWidth) * 100; setProgress(pos); }, [setProgress, barRef]