@@ -26,9 +26,10 @@ import {
2626 ExternalLink ,
2727 Trash2 ,
2828 Zap ,
29+ Headphones ,
2930} from "lucide-react" ;
3031import { 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" ;
3233import { Card , CardContent , CardHeader , CardTitle } from "@/components/ui/card" ;
3334import { Button } from "@/components/ui/button" ;
3435import { Badge } from "@/components/ui/badge" ;
@@ -46,7 +47,7 @@ import { useLiveTranscripts } from "@/hooks/use-live-transcripts";
4647import { PLATFORM_CONFIG , getDetailedStatus } from "@/types/vexa" ;
4748import type { MeetingStatus , Meeting } from "@/types/vexa" ;
4849import { StatusHistory } from "@/components/meetings/status-history" ;
49- import { cn } from "@/lib/utils" ;
50+ import { cn , parseUTCTimestamp } from "@/lib/utils" ;
5051import { vexaAPI } from "@/lib/api" ;
5152import { toast } from "sonner" ;
5253import { 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 }
0 commit comments