18 changed files with 510 additions and 10 deletions
@ -0,0 +1,14 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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