summaryrefslogtreecommitdiff
path: root/apps/web/src/components/ui/video-player.tsx
blob: 1a44ef76bdbf49453087fcb0a96c36226f67fb9e (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
"use client";

import { useRef, useEffect } from "react";
import { usePlaybackStore } from "@/stores/playback-store";

interface VideoPlayerProps {
	src: string;
	poster?: string;
	className?: string;
	clipStartTime: number;
	trimStart: number;
	trimEnd: number;
	clipDuration: number;
}

export function VideoPlayer({
	src,
	poster,
	className = "",
	clipStartTime,
	trimStart,
	trimEnd,
	clipDuration,
}: VideoPlayerProps) {
	const videoRef = useRef<HTMLVideoElement>(null);
	const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore();

	// Calculate if we're within this clip's timeline range
	const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
	const isInClipRange =
		currentTime >= clipStartTime && currentTime < clipEndTime;

	// Sync playback events
	useEffect(() => {
		const video = videoRef.current;
		if (!video || !isInClipRange) return;

		const handleSeekEvent = (e: CustomEvent) => {
			// Always update video time, even if outside clip range
			const timelineTime = e.detail.time;
			const videoTime = Math.max(
				trimStart,
				Math.min(
					clipDuration - trimEnd,
					timelineTime - clipStartTime + trimStart,
				),
			);
			video.currentTime = videoTime;
		};

		const handleUpdateEvent = (e: CustomEvent) => {
			// Always update video time, even if outside clip range
			const timelineTime = e.detail.time;
			const targetTime = Math.max(
				trimStart,
				Math.min(
					clipDuration - trimEnd,
					timelineTime - clipStartTime + trimStart,
				),
			);

			if (Math.abs(video.currentTime - targetTime) > 0.5) {
				video.currentTime = targetTime;
			}
		};

		const handleSpeed = (e: CustomEvent) => {
			video.playbackRate = e.detail.speed;
		};

		window.addEventListener("playback-seek", handleSeekEvent as EventListener);
		window.addEventListener(
			"playback-update",
			handleUpdateEvent as EventListener,
		);
		window.addEventListener("playback-speed", handleSpeed as EventListener);

		return () => {
			window.removeEventListener(
				"playback-seek",
				handleSeekEvent as EventListener,
			);
			window.removeEventListener(
				"playback-update",
				handleUpdateEvent as EventListener,
			);
			window.removeEventListener(
				"playback-speed",
				handleSpeed as EventListener,
			);
		};
	}, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);

	// Sync playback state
	useEffect(() => {
		const video = videoRef.current;
		if (!video) return;

		if (isPlaying && isInClipRange) {
			video.play().catch(() => {});
		} else {
			video.pause();
		}
	}, [isPlaying, isInClipRange]);

	// Sync volume and speed
	useEffect(() => {
		const video = videoRef.current;
		if (!video) return;

		video.volume = volume;
		video.muted = muted;
		video.playbackRate = speed;
	}, [volume, speed, muted]);

	return (
		<video
			ref={videoRef}
			src={src}
			poster={poster}
			className={`max-w-full max-h-full object-contain ${className}`}
			playsInline
			preload="auto"
			controls={false}
			disablePictureInPicture
			disableRemotePlayback
			style={{ pointerEvents: "none" }}
			onContextMenu={(e) => e.preventDefault()}
		/>
	);
}