18 changed files with 510 additions and 10 deletions
@ -0,0 +1,14 @@ |
|||||||
|
import { Spinner } from "@/components/layout/Spinner"; |
||||||
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks"; |
||||||
|
import { useMediaPlaying } from "@/video/state/logic/mediaplaying"; |
||||||
|
|
||||||
|
export function LoadingAction() { |
||||||
|
const descriptor = useVideoPlayerDescriptor(); |
||||||
|
const mediaPlaying = useMediaPlaying(descriptor); |
||||||
|
|
||||||
|
const isLoading = mediaPlaying.isFirstLoading || mediaPlaying.isLoading; |
||||||
|
|
||||||
|
if (!isLoading) return null; |
||||||
|
|
||||||
|
return <Spinner />; |
||||||
|
} |
@ -0,0 +1,32 @@ |
|||||||
|
import { Icon, Icons } from "@/components/Icon"; |
||||||
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks"; |
||||||
|
import { useControls } from "@/video/state/logic/controls"; |
||||||
|
import { useMediaPlaying } from "@/video/state/logic/mediaplaying"; |
||||||
|
import { useCallback } from "react"; |
||||||
|
|
||||||
|
export function MiddlePauseAction() { |
||||||
|
const descriptor = useVideoPlayerDescriptor(); |
||||||
|
const controls = useControls(descriptor); |
||||||
|
const mediaPlaying = useMediaPlaying(descriptor); |
||||||
|
|
||||||
|
const handleClick = useCallback(() => { |
||||||
|
if (mediaPlaying?.isPlaying) controls.pause(); |
||||||
|
else controls.play(); |
||||||
|
}, [controls, mediaPlaying]); |
||||||
|
|
||||||
|
if (mediaPlaying.hasPlayedOnce) return null; |
||||||
|
if (mediaPlaying.isPlaying) return null; |
||||||
|
if (mediaPlaying.isFirstLoading) return null; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div |
||||||
|
onClick={handleClick} |
||||||
|
className="group pointer-events-auto flex h-16 w-16 items-center justify-center rounded-full bg-denim-400 text-white transition-[background-color,transform] hover:scale-125 hover:bg-denim-500 active:scale-100" |
||||||
|
> |
||||||
|
<Icon |
||||||
|
icon={Icons.PLAY} |
||||||
|
className="text-2xl transition-transform group-hover:scale-125" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,81 @@ |
|||||||
|
import { |
||||||
|
makePercentage, |
||||||
|
makePercentageString, |
||||||
|
useProgressBar, |
||||||
|
} from "@/hooks/useProgressBar"; |
||||||
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks"; |
||||||
|
import { useControls } from "@/video/state/logic/controls"; |
||||||
|
import { useProgress } from "@/video/state/logic/progress"; |
||||||
|
import { useCallback, useEffect, useRef } from "react"; |
||||||
|
|
||||||
|
export function ProgressAction() { |
||||||
|
const descriptor = useVideoPlayerDescriptor(); |
||||||
|
const controls = useControls(descriptor); |
||||||
|
const videoTime = useProgress(descriptor); |
||||||
|
const ref = useRef<HTMLDivElement>(null); |
||||||
|
const dragRef = useRef<boolean>(false); |
||||||
|
|
||||||
|
const commitTime = useCallback( |
||||||
|
(percentage) => { |
||||||
|
controls.setTime(percentage * videoTime.duration); |
||||||
|
}, |
||||||
|
[controls, videoTime] |
||||||
|
); |
||||||
|
const { dragging, dragPercentage, dragMouseDown } = useProgressBar( |
||||||
|
ref, |
||||||
|
commitTime |
||||||
|
); |
||||||
|
|
||||||
|
// TODO make dragging update timer
|
||||||
|
useEffect(() => { |
||||||
|
if (dragRef.current === dragging) return; |
||||||
|
dragRef.current = dragging; |
||||||
|
controls.setSeeking(dragging); |
||||||
|
}, [dragRef, dragging, controls]); |
||||||
|
|
||||||
|
let watchProgress = makePercentageString( |
||||||
|
makePercentage((videoTime.time / videoTime.duration) * 100) |
||||||
|
); |
||||||
|
if (dragging) |
||||||
|
watchProgress = makePercentageString(makePercentage(dragPercentage)); |
||||||
|
|
||||||
|
const bufferProgress = makePercentageString( |
||||||
|
makePercentage((videoTime.buffered / videoTime.duration) * 100) |
||||||
|
); |
||||||
|
|
||||||
|
return ( |
||||||
|
<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" |
||||||
|
onMouseDown={dragMouseDown} |
||||||
|
onTouchStart={dragMouseDown} |
||||||
|
> |
||||||
|
<div |
||||||
|
className={`relative h-1 flex-1 rounded-full bg-gray-500 bg-opacity-50 transition-[height] duration-100 group-hover:h-2 ${ |
||||||
|
dragging ? "!h-2" : "" |
||||||
|
}`}
|
||||||
|
> |
||||||
|
<div |
||||||
|
className="absolute inset-y-0 left-0 flex items-center justify-end rounded-full bg-gray-300 bg-opacity-20" |
||||||
|
style={{ |
||||||
|
width: bufferProgress, |
||||||
|
}} |
||||||
|
/> |
||||||
|
<div |
||||||
|
className="absolute inset-y-0 left-0 flex items-center justify-end rounded-full bg-bink-600" |
||||||
|
style={{ |
||||||
|
width: watchProgress, |
||||||
|
}} |
||||||
|
> |
||||||
|
<div |
||||||
|
className={`absolute h-1 w-1 translate-x-1/2 rounded-full bg-white opacity-0 transition-[transform,opacity] group-hover:scale-[400%] group-hover:opacity-100 ${ |
||||||
|
dragging ? "!scale-[400%] !opacity-100" : "" |
||||||
|
}`}
|
||||||
|
/> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,48 @@ |
|||||||
|
import { Icons } from "@/components/Icon"; |
||||||
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks"; |
||||||
|
import { useControls } from "@/video/state/logic/controls"; |
||||||
|
import { useProgress } from "@/video/state/logic/progress"; |
||||||
|
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton"; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
className?: string; |
||||||
|
} |
||||||
|
|
||||||
|
export function SkipTimeBackwardAction() { |
||||||
|
const descriptor = useVideoPlayerDescriptor(); |
||||||
|
const controls = useControls(descriptor); |
||||||
|
const videoTime = useProgress(descriptor); |
||||||
|
|
||||||
|
const skipBackward = () => { |
||||||
|
controls.setTime(videoTime.time - 10); |
||||||
|
}; |
||||||
|
|
||||||
|
return ( |
||||||
|
<VideoPlayerIconButton icon={Icons.SKIP_BACKWARD} onClick={skipBackward} /> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function SkipTimeForwardAction() { |
||||||
|
const descriptor = useVideoPlayerDescriptor(); |
||||||
|
const controls = useControls(descriptor); |
||||||
|
const videoTime = useProgress(descriptor); |
||||||
|
|
||||||
|
const skipForward = () => { |
||||||
|
controls.setTime(videoTime.time + 10); |
||||||
|
}; |
||||||
|
|
||||||
|
return ( |
||||||
|
<VideoPlayerIconButton icon={Icons.SKIP_FORWARD} onClick={skipForward} /> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function SkipTimeAction(props: Props) { |
||||||
|
return ( |
||||||
|
<div className={props.className}> |
||||||
|
<div className="flex select-none items-center text-white"> |
||||||
|
<SkipTimeBackwardAction /> |
||||||
|
<SkipTimeForwardAction /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,50 @@ |
|||||||
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks"; |
||||||
|
import { useProgress } from "@/video/state/logic/progress"; |
||||||
|
|
||||||
|
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 [paddedMins, paddedSecs].join(":"); |
||||||
|
return [hours, paddedMins, paddedSecs].join(":"); |
||||||
|
} |
||||||
|
|
||||||
|
interface Props { |
||||||
|
className?: string; |
||||||
|
noDuration?: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
export function TimeAction(props: Props) { |
||||||
|
const descriptor = useVideoPlayerDescriptor(); |
||||||
|
const videoTime = useProgress(descriptor); |
||||||
|
|
||||||
|
const hasHours = durationExceedsHour(videoTime.duration); |
||||||
|
const time = formatSeconds(videoTime.time, hasHours); |
||||||
|
const duration = formatSeconds(videoTime.duration, hasHours); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className={props.className}> |
||||||
|
<p className="select-none text-white"> |
||||||
|
{time} {props.noDuration ? "" : `/ ${duration}`} |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,24 @@ |
|||||||
|
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams"; |
||||||
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks"; |
||||||
|
import { useControls } from "@/video/state/logic/controls"; |
||||||
|
import { useEffect, useRef } from "react"; |
||||||
|
|
||||||
|
interface SourceControllerProps { |
||||||
|
source: string; |
||||||
|
type: MWStreamType; |
||||||
|
quality: MWStreamQuality; |
||||||
|
} |
||||||
|
|
||||||
|
export function SourceController(props: SourceControllerProps) { |
||||||
|
const descriptor = useVideoPlayerDescriptor(); |
||||||
|
const controls = useControls(descriptor); |
||||||
|
const didInitialize = useRef<boolean>(false); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (didInitialize.current) return; |
||||||
|
controls.setSource(props); |
||||||
|
didInitialize.current = true; |
||||||
|
}, [props, controls]); |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
@ -0,0 +1,45 @@ |
|||||||
|
import { useEffect, useState } from "react"; |
||||||
|
import { getPlayerState } from "../cache"; |
||||||
|
import { listenEvent, sendEvent, unlistenEvent } from "../events"; |
||||||
|
import { VideoPlayerState } from "../types"; |
||||||
|
|
||||||
|
export type VideoProgressEvent = { |
||||||
|
time: number; |
||||||
|
duration: number; |
||||||
|
buffered: number; |
||||||
|
}; |
||||||
|
|
||||||
|
function getProgressFromState(state: VideoPlayerState): VideoProgressEvent { |
||||||
|
return { |
||||||
|
time: state.time, |
||||||
|
duration: state.duration, |
||||||
|
buffered: state.buffered, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
export function updateProgress(descriptor: string, state: VideoPlayerState) { |
||||||
|
sendEvent<VideoProgressEvent>( |
||||||
|
descriptor, |
||||||
|
"progress", |
||||||
|
getProgressFromState(state) |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function useProgress(descriptor: string): VideoProgressEvent { |
||||||
|
const state = getPlayerState(descriptor); |
||||||
|
const [data, setData] = useState<VideoProgressEvent>( |
||||||
|
getProgressFromState(state) |
||||||
|
); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
function update(payload: CustomEvent<VideoProgressEvent>) { |
||||||
|
setData(payload.detail); |
||||||
|
} |
||||||
|
listenEvent(descriptor, "progress", update); |
||||||
|
return () => { |
||||||
|
unlistenEvent(descriptor, "progress", update); |
||||||
|
}; |
||||||
|
}, [descriptor]); |
||||||
|
|
||||||
|
return data; |
||||||
|
} |
@ -0,0 +1,40 @@ |
|||||||
|
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams"; |
||||||
|
import { useEffect, useState } from "react"; |
||||||
|
import { getPlayerState } from "../cache"; |
||||||
|
import { listenEvent, sendEvent, unlistenEvent } from "../events"; |
||||||
|
import { VideoPlayerState } from "../types"; |
||||||
|
|
||||||
|
export type VideoSourceEvent = { |
||||||
|
source: null | { |
||||||
|
quality: MWStreamQuality; |
||||||
|
url: string; |
||||||
|
type: MWStreamType; |
||||||
|
}; |
||||||
|
}; |
||||||
|
|
||||||
|
function getSourceFromState(state: VideoPlayerState): VideoSourceEvent { |
||||||
|
return { |
||||||
|
source: state.source ? { ...state.source } : null, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
export function updateSource(descriptor: string, state: VideoPlayerState) { |
||||||
|
sendEvent<VideoSourceEvent>(descriptor, "source", getSourceFromState(state)); |
||||||
|
} |
||||||
|
|
||||||
|
export function useSource(descriptor: string): VideoSourceEvent { |
||||||
|
const state = getPlayerState(descriptor); |
||||||
|
const [data, setData] = useState<VideoSourceEvent>(getSourceFromState(state)); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
function update(payload: CustomEvent<VideoSourceEvent>) { |
||||||
|
setData(payload.detail); |
||||||
|
} |
||||||
|
listenEvent(descriptor, "source", update); |
||||||
|
return () => { |
||||||
|
unlistenEvent(descriptor, "source", update); |
||||||
|
}; |
||||||
|
}, [descriptor]); |
||||||
|
|
||||||
|
return data; |
||||||
|
} |
Loading…
Reference in new issue