Skip to content
This repository was archived by the owner on Mar 24, 2026. It is now read-only.

Commit 0aab478

Browse files
committed
Clean up video player
1 parent 4ee62bb commit 0aab478

2 files changed

Lines changed: 109 additions & 17 deletions

File tree

src/app/meetings/[id]/page.tsx

Lines changed: 81 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,10 @@ import {
2626
ExternalLink,
2727
Trash2,
2828
Zap,
29+
Headphones,
2930
} from "lucide-react";
3031
import { AudioPlayer, type AudioPlayerHandle, type AudioFragment } from "@/components/recording/audio-player";
31-
import { VideoPlayer } from "@/components/recording/video-player";
32+
import { VideoPlayer, type VideoPlayerHandle } from "@/components/recording/video-player";
3233
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
3334
import { Button } from "@/components/ui/button";
3435
import { Badge } from "@/components/ui/badge";
@@ -46,7 +47,7 @@ import { useLiveTranscripts } from "@/hooks/use-live-transcripts";
4647
import { PLATFORM_CONFIG, getDetailedStatus } from "@/types/vexa";
4748
import type { MeetingStatus, Meeting } from "@/types/vexa";
4849
import { StatusHistory } from "@/components/meetings/status-history";
49-
import { cn } from "@/lib/utils";
50+
import { cn, parseUTCTimestamp } from "@/lib/utils";
5051
import { vexaAPI } from "@/lib/api";
5152
import { toast } from "sonner";
5253
import { LanguagePicker } from "@/components/language-picker";
@@ -145,8 +146,10 @@ export default function MeetingDetailPage() {
145146
);
146147
const [isUpdatingConfig, setIsUpdatingConfig] = useState(false);
147148

148-
// Audio playback state
149+
// Audio/video playback state
149150
const audioPlayerRef = useRef<AudioPlayerHandle>(null);
151+
const videoPlayerRef = useRef<VideoPlayerHandle>(null);
152+
const [preferAudio, setPreferAudio] = useState(false);
150153
const [playbackTime, setPlaybackTime] = useState<number | null>(null);
151154
const [isPlaybackActive, setIsPlaybackActive] = useState(false);
152155
const [pendingSeekTime, setPendingSeekTime] = useState<number | null>(null);
@@ -159,7 +162,13 @@ export default function MeetingDetailPage() {
159162
// Include recordings that have audio media files, whether completed or in_progress
160163
// (in_progress recordings may have snapshot uploads available for playback)
161164
const availableRecordings = recordings
162-
.filter(r => (r.status === "completed" || r.status === "in_progress") && r.media_files?.some(mf => mf.type === "audio"))
165+
.filter(r =>
166+
(r.status === "completed" || r.status === "in_progress") &&
167+
r.media_files?.some(mf => mf.type === "audio") &&
168+
// Exclude recordings that also have video (those are cloud recordings;
169+
// their audio content is the same as the video and is shown there instead)
170+
!r.media_files?.some(mf => mf.type === "video")
171+
)
163172
.sort((a, b) => a.created_at.localeCompare(b.created_at));
164173

165174
return availableRecordings.map(rec => {
@@ -179,14 +188,27 @@ export default function MeetingDetailPage() {
179188
if (rec.status !== "completed" && rec.status !== "in_progress") continue;
180189
const videoMedia = rec.media_files?.find(mf => mf.type === "video");
181190
if (videoMedia) {
182-
return { src: vexaAPI.getRecordingVideoUrl(rec.id, videoMedia.id) };
191+
return { src: vexaAPI.getRecordingVideoUrl(rec.id, videoMedia.id), createdAt: rec.created_at };
183192
}
184193
}
185194
return null;
186195
}, [recordings]);
187196

188197
const hasRecordingAudio = recordingFragments.length > 0;
189198

199+
// Derive recording session start time from transcript segments.
200+
// Any segment with both start_time (relative) and absolute_start_time gives us:
201+
// sessionStart = absolute_start_time - start_time * 1000
202+
// This is more reliable than recording.created_at (which is the DB insert time, not session start).
203+
const videoTimeBase = useMemo(() => {
204+
for (const seg of transcripts) {
205+
if (seg.absolute_start_time && seg.start_time != null) {
206+
return parseUTCTimestamp(seg.absolute_start_time).getTime() - seg.start_time * 1000;
207+
}
208+
}
209+
return null;
210+
}, [transcripts]);
211+
190212
const handlePlaybackTimeUpdate = useCallback((time: number) => {
191213
setPlaybackTime(time);
192214
setIsPlaybackActive(true);
@@ -203,6 +225,14 @@ export default function MeetingDetailPage() {
203225
// then use start_time as the seek offset within that fragment (since start_time is
204226
// relative to the session and each recording fragment corresponds to one session).
205227
const handleSegmentClick = useCallback((startTimeSeconds: number, absoluteStartTime?: string) => {
228+
// If video is showing (not preferAudio), seek the video player directly
229+
if (videoRecording && !preferAudio) {
230+
videoPlayerRef.current?.seekTo(startTimeSeconds);
231+
setPlaybackTime(startTimeSeconds);
232+
setIsPlaybackActive(true);
233+
return;
234+
}
235+
206236
if (!hasRecordingAudio) {
207237
setPendingSeekTime(startTimeSeconds);
208238
return;
@@ -240,7 +270,7 @@ export default function MeetingDetailPage() {
240270
.reduce((sum, f) => sum + (f.duration || 0), 0);
241271
setPlaybackTime(virtualOffset + startTimeSeconds);
242272
setIsPlaybackActive(true);
243-
}, [hasRecordingAudio, recordingFragments]);
273+
}, [hasRecordingAudio, recordingFragments, videoRecording, preferAudio]);
244274

245275
useEffect(() => {
246276
if (!hasRecordingAudio || pendingSeekTime == null) return;
@@ -638,7 +668,14 @@ export default function MeetingDetailPage() {
638668
// In multi-fragment mode, we convert the virtual playback time to an ISO
639669
// absolute timestamp so the transcript viewer can match against absolute_start_time.
640670
const playbackAbsoluteTime = useMemo((): string | null => {
641-
if (playbackTime == null || !isPlaybackActive || recordingFragments.length === 0) return null;
671+
if (playbackTime == null || !isPlaybackActive) return null;
672+
// Video mode: use session start time derived from transcript segments.
673+
// (recording.created_at is the DB insert time, not when recording content started)
674+
if (videoRecording && !preferAudio) {
675+
if (videoTimeBase == null) return null;
676+
return new Date(videoTimeBase + playbackTime * 1000).toISOString();
677+
}
678+
if (recordingFragments.length === 0) return null;
642679
if (recordingFragments.length === 1) {
643680
// Single fragment: absolute time = fragment createdAt + playback time
644681
const fragStart = new Date(recordingFragments[0].createdAt).getTime();
@@ -655,7 +692,7 @@ export default function MeetingDetailPage() {
655692
remaining -= fragDur;
656693
}
657694
return null;
658-
}, [playbackTime, isPlaybackActive, recordingFragments]);
695+
}, [playbackTime, isPlaybackActive, recordingFragments, videoRecording, preferAudio, videoTimeBase]);
659696

660697
if (error) {
661698
return (
@@ -701,10 +738,44 @@ export default function MeetingDetailPage() {
701738
const noAudioRecordingForMeeting =
702739
recordingExplicitlyDisabled ||
703740
(currentMeeting.status === "completed" && !hasRecordingEntries);
704-
const canUseSegmentPlayback = isPostMeetingFlow && !noAudioRecordingForMeeting;
741+
const canUseSegmentPlayback = isPostMeetingFlow && (!noAudioRecordingForMeeting || !!videoRecording);
705742
const recordingTopBar = isPostMeetingFlow ? (
706743
videoRecording ? (
707-
<VideoPlayer src={videoRecording.src} className="w-full max-w-2xl" />
744+
<div className="w-full max-w-2xl space-y-1">
745+
{!preferAudio && (
746+
<VideoPlayer
747+
ref={videoPlayerRef}
748+
src={videoRecording.src}
749+
className="w-full"
750+
onTimeUpdate={handlePlaybackTimeUpdate}
751+
/>
752+
)}
753+
{preferAudio && hasRecordingAudio && (
754+
<AudioPlayer
755+
ref={audioPlayerRef}
756+
fragments={recordingFragments}
757+
onTimeUpdate={handlePlaybackTimeUpdate}
758+
onFragmentChange={handleFragmentChange}
759+
compact
760+
/>
761+
)}
762+
{hasRecordingAudio && (
763+
<div className="flex justify-end">
764+
<Button
765+
variant="ghost"
766+
size="sm"
767+
className="h-6 px-2 text-xs text-muted-foreground gap-1"
768+
onClick={() => setPreferAudio((v) => !v)}
769+
>
770+
{preferAudio ? (
771+
<><Video className="h-3 w-3" /> Show video</>
772+
) : (
773+
<><Headphones className="h-3 w-3" /> Audio only</>
774+
)}
775+
</Button>
776+
</div>
777+
)}
778+
</div>
708779
) : hasRecordingAudio ? (
709780
<AudioPlayer
710781
ref={audioPlayerRef}

src/components/recording/video-player.tsx

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,33 @@
11
"use client";
22

3-
import { useRef, useState, useEffect } from "react";
3+
import { useRef, useState, useEffect, useImperativeHandle, forwardRef } from "react";
44
import { Play, Pause, Volume2, VolumeX, Maximize2, AlertCircle } from "lucide-react";
55
import { Button } from "@/components/ui/button";
66
import { cn } from "@/lib/utils";
77

8+
export interface VideoPlayerHandle {
9+
seekTo: (seconds: number) => void;
10+
}
11+
812
interface VideoPlayerProps {
913
src: string;
1014
className?: string;
15+
onTimeUpdate?: (currentTime: number) => void;
1116
}
1217

13-
export function VideoPlayer({ src, className }: VideoPlayerProps) {
18+
export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
19+
function VideoPlayer({ src, className, onTimeUpdate }, ref) {
1420
const videoRef = useRef<HTMLVideoElement>(null);
21+
22+
useImperativeHandle(ref, () => ({
23+
seekTo: (seconds: number) => {
24+
const video = videoRef.current;
25+
if (!video) return;
26+
video.currentTime = seconds;
27+
setCurrentTime(seconds);
28+
video.play();
29+
},
30+
}));
1531
const [isPlaying, setIsPlaying] = useState(false);
1632
const [isMuted, setIsMuted] = useState(false);
1733
const [currentTime, setCurrentTime] = useState(0);
@@ -28,7 +44,10 @@ export function VideoPlayer({ src, className }: VideoPlayerProps) {
2844
setIsLoaded(true);
2945
setError(null);
3046
};
31-
const onTimeUpdate = () => setCurrentTime(video.currentTime);
47+
const handleTimeUpdate = () => {
48+
setCurrentTime(video.currentTime);
49+
onTimeUpdate?.(video.currentTime);
50+
};
3251
const onPlay = () => setIsPlaying(true);
3352
const onPause = () => setIsPlaying(false);
3453
const onEnded = () => setIsPlaying(false);
@@ -43,21 +62,21 @@ export function VideoPlayer({ src, className }: VideoPlayerProps) {
4362
};
4463

4564
video.addEventListener("loadedmetadata", onLoadedMetadata);
46-
video.addEventListener("timeupdate", onTimeUpdate);
65+
video.addEventListener("timeupdate", handleTimeUpdate);
4766
video.addEventListener("play", onPlay);
4867
video.addEventListener("pause", onPause);
4968
video.addEventListener("ended", onEnded);
5069
video.addEventListener("error", onError);
5170

5271
return () => {
5372
video.removeEventListener("loadedmetadata", onLoadedMetadata);
54-
video.removeEventListener("timeupdate", onTimeUpdate);
73+
video.removeEventListener("timeupdate", handleTimeUpdate);
5574
video.removeEventListener("play", onPlay);
5675
video.removeEventListener("pause", onPause);
5776
video.removeEventListener("ended", onEnded);
5877
video.removeEventListener("error", onError);
5978
};
60-
}, []);
79+
}, [onTimeUpdate]);
6180

6281
const togglePlay = () => {
6382
const video = videoRef.current;
@@ -175,4 +194,6 @@ export function VideoPlayer({ src, className }: VideoPlayerProps) {
175194
)}
176195
</div>
177196
);
178-
}
197+
});
198+
199+
VideoPlayer.displayName = "VideoPlayer";

0 commit comments

Comments
 (0)