Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,438 changes: 94 additions & 2,344 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
"tailwindcss": "^3.4.6",
"tailwindcss-animate": "^1.0.7",
"temporal-polyfill": "^0.2.5",
"typescript": "^5.5.3",
"typescript": "^5.6.3",
"vite": "^5.3.4",
"vite-tsconfig-paths": "^5.0.1",
"vitest": "^2.1.3"
Expand Down
206 changes: 180 additions & 26 deletions src/components/VideoPlayer.tsx
Original file line number Diff line number Diff line change
@@ -1,68 +1,222 @@
import VideoPlayerControls from "components/VideoPlayerControls";
import VideoPlayerProgressBar from "components/VideoPlayerProgressBar";
import { forwardRef, useImperativeHandle, useState } from "react";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import type { MediaFile } from "types";

interface VideoPlayerProps {
videoUrl: string;
media: MediaFile[];
length: number;
onTimeUpdate?: (time: number) => void;
onEnd?: () => void;
}

export interface VideoPlayerRef {
seekTo: (milliseconds: number) => void;
}

export default forwardRef<VideoPlayerRef, VideoPlayerProps>(
function VideoPlayer({ videoUrl, onTimeUpdate, onEnd }, ref) {
const [video, setVideo] = useState<HTMLVideoElement | null>(null);
function VideoPlayer({ media, length, onTimeUpdate }, ref) {
const videoRef = useRef<HTMLVideoElement>(null);

const timeUpdate = () => {
if (video) {
onTimeUpdate?.(video.currentTime * 1000);
}
};
const [currentVideoIndex, setCurrentVideoIndex] = useState(0);
const [currentVideoOffset, setCurrentVideoOffset] = useState(0);

const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [speed, setSpeed] = useState(1);

const progress = ((video?.currentTime || 0) / (video?.duration || 1)) * 100;
const setPlayheadTime = useCallback(
(milliseconds: number) => {
const mediaItemIndex = getMediaItemIndexForTime(media, milliseconds);
const mediaItem = media[mediaItemIndex];

setCurrentVideoIndex(mediaItemIndex);
setCurrentVideoOffset(milliseconds - mediaItem.offset);
},
[media],
);

useImperativeHandle(
ref,
() => ({
seekTo: (milliseconds: number) => {
if (video) {
video.currentTime = milliseconds / 1000;
}
setPlayheadTime(milliseconds);
},
}),
[video],
[setPlayheadTime],
);

const seekToPercent = (progress: number) => {
if (video) {
video.currentTime = (progress / 100) * video.duration;
const syncVideoState = () => {
if (videoRef.current === null) {
return;
}

// Play the video if it is not playing and the component is playing
if (isPlaying && videoRef.current.paused) {
videoRef.current.play();
}

// Pause the video if it is playing and the component is not playing
if (!isPlaying && !videoRef.current.paused) {
videoRef.current.pause();
}

videoRef.current.muted = isMuted;
videoRef.current.playbackRate = speed;
};

// biome-ignore lint/correctness/useExhaustiveDependencies: These are used in the callback
useEffect(syncVideoState, [isPlaying, isMuted, speed]);

useEffect(() => {
if (videoRef.current === null) {
return;
}

videoRef.current.src = media[currentVideoIndex].url;
videoRef.current.load();
}, [currentVideoIndex, media]);

useEffect(() => {
if (videoRef.current === null) {
return;
}

if (videoRef.current.currentTime !== currentVideoOffset / 1000) {
videoRef.current.currentTime = currentVideoOffset / 1000;
}
}, [currentVideoOffset]);

function handleCanPlay() {
console.log("Can play");
syncVideoState();
}

function handleEnded() {
console.log("Ended");

if (currentVideoIndex < media.length - 1) {
setCurrentVideoIndex((index) => index + 1);
setCurrentVideoOffset(0);
}
}

/**
* Updates the playhead time based on the video's current time
* relative to the media item it is currently playing within
* the overall `media` list.
*/
function handleTimeUpdate() {
console.log("Time update");

if (videoRef.current === null) {
return;
}

setCurrentVideoOffset(videoRef.current.currentTime * 1000);
}

function handleSeekTo(time: number) {
console.log("Seek to", time);

setPlayheadTime(time);
}

function handlePauseToggle() {
setIsPlaying((playing) => !playing);
}

function handleMuteToggle() {
setIsMuted((muted) => !muted);
}

function handleSpeedChange(newSpeed: number) {
setSpeed(newSpeed);
}

function handleLoadStart() {
console.log("Load start");
}

function handleLoadedData() {
console.log("Loaded data");
}

function handleLoadedMetadata() {
console.log("Loaded metadata");
}

const playheadTime = media[currentVideoIndex].offset + currentVideoOffset;

return (
<>
<video
ref={setVideo}
ref={videoRef}
className="w-full"
onTimeUpdate={timeUpdate}
onEnded={onEnd}
onTimeUpdate={handleTimeUpdate}
onCanPlay={handleCanPlay}
onEnded={handleEnded}
onError={console.error}
onLoadStart={handleLoadStart}
onLoadedData={handleLoadedData}
onLoadedMetadata={handleLoadedMetadata}
onStalled={console.error}
>
<source type="video/mp4" src={videoUrl} />
Your browser does not support the video tag.
</video>

<VideoPlayerControls video={video}>
<VideoPlayerControls
playheadTime={playheadTime}
playing={isPlaying}
onPauseToggle={handlePauseToggle}
muted={isMuted}
onMuteToggle={handleMuteToggle}
speed={speed}
onSpeedChange={handleSpeedChange}
>
<VideoPlayerProgressBar
progress={progress}
seekToPercent={seekToPercent}
duration={video?.duration || 0}
playheadTime={playheadTime}
seekTo={handleSeekTo}
duration={length}
/>
</VideoPlayerControls>
</>
);
},
);

function getMediaItemIndexForTime(media: MediaFile[], playheadTime: number) {
const mediaIndex = media.findLastIndex((m) => m.offset <= playheadTime);

if (mediaIndex === -1) {
console.error("No media found for time", playheadTime);
throw new Error("No media found for time");
}

return mediaIndex;
}

function getMediaItemForTime(media: MediaFile[], playheadTime: number) {
const mediaIndex = getMediaItemIndexForTime(media, playheadTime);

return media[mediaIndex];
}

function seekVideoToPlayheadTime(
video: HTMLVideoElement,
media: MediaFile[],
playheadTime: number,
) {
const mediaItem = getMediaItemForTime(media, playheadTime);
const targetTime = (playheadTime - mediaItem.offset) / 1000;

if (targetTime !== video.currentTime) {
video.currentTime = targetTime;
}
}
100 changes: 26 additions & 74 deletions src/components/VideoPlayerControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,77 +2,48 @@ import { type ReactNode, useEffect, useState } from "react";
import { format } from "utils/duration";

interface VideoPlayerControlsProps {
video: HTMLVideoElement | null;
playheadTime: number;

playing: boolean;
onPauseToggle: () => void;
muted: boolean;
onMuteToggle: () => void;
speed: number;
onSpeedChange: (newSpeed: number) => void;

children: ReactNode;
}

export default function VideoPlayerControls({
video,
children,
}: VideoPlayerControlsProps) {
const [isMuted, setIsMuted] = useState(false);
const [isLooping, setIsLooping] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [speed, setSpeed] = useState(1);

useEffect(() => {
if (video) {
video.muted = isMuted;
}
}, [isMuted, video]);

useEffect(() => {
if (video) {
video.loop = isLooping;
}
}, [isLooping, video]);
playheadTime,

useEffect(() => {
if (video) {
video.playbackRate = speed;
}
}, [speed, video]);
playing,
onPauseToggle,
muted,
onMuteToggle,
speed,
onSpeedChange,

const play = () => {
if (video) {
video.play().then(() => {
setIsPlaying(true);
});
}
};

const pause = () => {
if (video) {
video.pause();
setIsPlaying(false);
}
};

const toggleMute = () => {
setIsMuted((muted) => !muted);
};

const toggleLoop = () => {
setIsLooping((looping) => !looping);
};

const timestamp = format(`PT${video?.currentTime.toFixed(0) || 0}S`);
children,
}: VideoPlayerControlsProps) {
const playSeconds = playheadTime / 1000;
const timestamp = format(`PT${playSeconds.toFixed(0) || 0}S`);

return (
<div className="m-4 flex items-start">
{!isPlaying ? (
{!playing ? (
<button
type="button"
className="rounded bg-gray-200 px-4 py-2 text-gray-600 dark:bg-gray-800 dark:text-white"
onClick={play}
onClick={onPauseToggle}
>
Play
</button>
) : (
<button
type="button"
className="rounded bg-gray-200 px-4 py-2 text-gray-600 dark:bg-gray-800 dark:text-white"
onClick={pause}
onClick={onPauseToggle}
>
Pause
</button>
Expand All @@ -87,36 +58,17 @@ export default function VideoPlayerControls({
<button
type="button"
className={`ml-4 rounded px-4 py-2 ${
isMuted ? "bg-gray-200" : "bg-gray-600 text-white"
muted ? "bg-gray-200" : "bg-gray-600 text-white"
}`}
onClick={toggleMute}
onClick={onMuteToggle}
>
Mute
</button>

<button
type="button"
className={`
ml-4 rounded px-4 py-2
${video?.loop ? "bg-gray-200" : "bg-gray-600 text-white"}
`}
onClick={toggleLoop}
>
Loop
</button>

<button
type="button"
className="ml-4 rounded bg-gray-200 px-4 py-2 text-gray-600 dark:bg-gray-800 dark:text-white"
onClick={() => video?.requestFullscreen()}
>
Fullscreen
</button>

<select
className="ml-4 rounded bg-gray-200 px-4 py-2 text-gray-600 dark:bg-gray-800 dark:text-white"
defaultValue={speed}
onChange={(e) => setSpeed(Number.parseFloat(e.target.value))}
onChange={(e) => onSpeedChange(Number.parseFloat(e.target.value))}
>
<option value="0.5">0.5x</option>
<option value="1">1x</option>
Expand Down
Loading