Skip to content

Commit 68ff6d0

Browse files
committed
feat(recording-viewer): video subtitles + Open Folder button
Adds VTT/SRT subtitle support to the video player: - Server endpoints GET/HEAD/PUT /api/recordings/:id/subtitles in vite.config.ts, with line-by-line SRT->VTT conversion, validation on both read and write paths, 5 MB upload cap enforced during streaming, atomic write, and concurrency guard. - Session list response now includes hasSubtitles per session. - VideoPlayer renders <track kind="subtitles"> when subtitlesUrl is provided and exposes a showSubtitles() handle method to force-activate the track after re-uploads (where the `default` attribute alone is not enough). - New SubtitleUpload component mirrors VideoUpload (.srt/.vtt accepted). - Session table gets a Subtitles column. Also adds an "Open Folder" button to the session-detail header so the session folder is reachable without going back to the session list.
1 parent cec70be commit 68ff6d0

6 files changed

Lines changed: 330 additions & 9 deletions

File tree

recording-viewer/src/App.css

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@
7676

7777
.session-table-header {
7878
display: grid;
79-
grid-template-columns: 2fr 1fr 1.5fr 1fr 0.7fr 0.6fr 0.6fr 90px;
79+
grid-template-columns: 2fr 1fr 1.5fr 1fr 0.7fr 0.6fr 0.6fr 0.6fr 90px;
8080
gap: 12px;
8181
padding: 8px 12px;
8282
font-size: 11px;
@@ -89,7 +89,7 @@
8989

9090
.session-table-row {
9191
display: grid;
92-
grid-template-columns: 2fr 1fr 1.5fr 1fr 0.7fr 0.6fr 0.6fr 90px;
92+
grid-template-columns: 2fr 1fr 1.5fr 1fr 0.7fr 0.6fr 0.6fr 0.6fr 90px;
9393
align-items: center;
9494
gap: 12px;
9595
padding: 10px 12px;
@@ -213,6 +213,11 @@
213213
letter-spacing: -0.5px;
214214
}
215215

216+
.header-actions {
217+
display: flex;
218+
gap: 8px;
219+
}
220+
216221
.reset-btn {
217222
font-family: var(--sans);
218223
font-size: 13px;

recording-viewer/src/App.tsx

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { TrackingTimeline } from './components/TrackingTimeline';
1010
import { VideoPlayer } from './components/VideoPlayer';
1111
import type { VideoPlayerHandle } from './components/VideoPlayer';
1212
import { VideoUpload } from './components/VideoUpload';
13+
import { SubtitleUpload } from './components/SubtitleUpload';
1314
import { OffsetConfig } from './components/OffsetConfig';
1415
import { FreeAnnotationForm } from './components/FreeAnnotationForm';
1516
import { ALL_EVENT_TYPES } from './constants';
@@ -24,6 +25,7 @@ function App() {
2425

2526
// Video state
2627
const [videoSyncConfig, setVideoSyncConfig] = useState<VideoSyncConfig | null>(null);
28+
const [hasSubtitles, setHasSubtitles] = useState(false);
2729
const [isVideoPlaying, setIsVideoPlaying] = useState(false);
2830
const [videoCacheBust, setVideoCacheBust] = useState(0);
2931
const videoTimeRef = useRef<number>(0);
@@ -64,12 +66,13 @@ function App() {
6466
setViewMode('timeline');
6567
setScrollToTimestamp(null);
6668
try {
67-
const [eventsRes, metaRes, replayRes, annotRes, videoSyncRes] = await Promise.all([
69+
const [eventsRes, metaRes, replayRes, annotRes, videoSyncRes, subsRes] = await Promise.all([
6870
fetch(`/api/recordings/${sessionId}/events`),
6971
fetch(`/api/recordings/${sessionId}/metadata`),
7072
fetch(`/api/recordings/${sessionId}/replay-eq`),
7173
fetch(`/api/recordings/${sessionId}/annotations`),
7274
fetch(`/api/recordings/${sessionId}/video-sync`),
75+
fetch(`/api/recordings/${sessionId}/subtitles`, { method: 'HEAD' }),
7376
]);
7477

7578
const events: RecordedEvent[] = await eventsRes.json();
@@ -94,6 +97,7 @@ function App() {
9497
activeSessionId.current = sessionId;
9598
setAnnotations(loadedAnnotations);
9699
setVideoSyncConfig(syncConfig);
100+
setHasSubtitles(subsRes.ok);
97101
setVideoCacheBust(Date.now());
98102
setIsVideoPlaying(false);
99103
videoTimeRef.current = 0;
@@ -110,6 +114,7 @@ function App() {
110114
activeSessionId.current = null;
111115
setAnnotations([]);
112116
setVideoSyncConfig(null);
117+
setHasSubtitles(false);
113118
setIsVideoPlaying(false);
114119
videoTimeRef.current = 0;
115120
setViewMode('timeline');
@@ -122,6 +127,7 @@ function App() {
122127
activeSessionId.current = null;
123128
setAnnotations([]);
124129
setVideoSyncConfig(null);
130+
setHasSubtitles(false);
125131
setIsVideoPlaying(false);
126132
videoTimeRef.current = 0;
127133
setViewMode('timeline');
@@ -143,6 +149,19 @@ function App() {
143149
setVideoCacheBust(Date.now());
144150
}, []);
145151

152+
const handleSubtitleUploadComplete = useCallback(() => {
153+
setHasSubtitles(true);
154+
setVideoCacheBust(Date.now());
155+
// `<track>` is remounted via React key change; force-activate after new load.
156+
requestAnimationFrame(() => videoPlayerRef.current?.showSubtitles());
157+
}, []);
158+
159+
const handleOpenSessionFolder = useCallback(() => {
160+
if (!activeSessionId.current) return;
161+
fetch(`/api/recordings/${encodeURIComponent(activeSessionId.current)}/open`, { method: 'POST' })
162+
.catch(() => {/* best-effort */});
163+
}, []);
164+
146165
const handleOffsetChange = useCallback(async (newOffset: number) => {
147166
if (!activeSessionId.current || !videoSyncConfig) return;
148167
const updated = { ...videoSyncConfig, videoTimeAtSessionStartSeconds: newOffset };
@@ -256,14 +275,25 @@ function App() {
256275
? `/api/recordings/${encodeURIComponent(activeSessionId.current)}/video?v=${videoCacheBust}`
257276
: null;
258277

278+
const subtitlesUrl = activeSessionId.current && hasSubtitles
279+
? `/api/recordings/${encodeURIComponent(activeSessionId.current)}/subtitles?v=${videoCacheBust}`
280+
: null;
281+
259282
return (
260283
<div className="app">
261284
<header className="app-header">
262285
<h1>Artemis Extension Session Analyzer</h1>
263286
{session && (
264-
<button className="reset-btn" onClick={handleBack}>
265-
&larr; Back
266-
</button>
287+
<div className="header-actions">
288+
{activeSessionId.current && (
289+
<button className="reset-btn" onClick={handleOpenSessionFolder} title="Open session folder in Finder">
290+
Open Folder
291+
</button>
292+
)}
293+
<button className="reset-btn" onClick={handleBack}>
294+
&larr; Back
295+
</button>
296+
</div>
267297
)}
268298
</header>
269299

@@ -292,6 +322,7 @@ function App() {
292322
sessionEndTime={sessionEndTime}
293323
videoTimeAtSessionStartSeconds={videoSyncConfig.videoTimeAtSessionStartSeconds}
294324
videoUrl={videoUrl}
325+
subtitlesUrl={subtitlesUrl}
295326
videoTimeRef={videoTimeRef}
296327
onPlayStateChange={handleVideoPlayStateChange}
297328
/>
@@ -305,6 +336,11 @@ function App() {
305336
hasVideo={true}
306337
onUploadComplete={handleVideoUploadComplete}
307338
/>
339+
<SubtitleUpload
340+
sessionId={activeSessionId.current}
341+
hasSubtitles={hasSubtitles}
342+
onUploadComplete={handleSubtitleUploadComplete}
343+
/>
308344
</div>
309345
</div>
310346
)}

recording-viewer/src/components/SessionList.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ interface SessionEntry {
77
metadata: SessionMetadata | null;
88
hasReplay: boolean;
99
hasVideo: boolean;
10+
hasSubtitles: boolean;
1011
}
1112

1213
interface SessionListResponse {
@@ -143,6 +144,7 @@ export function SessionList({ onSelectSession }: Props) {
143144
<span>Events</span>
144145
<span>Replay</span>
145146
<span>Video</span>
147+
<span>Subtitles</span>
146148
<span></span>
147149
</div>
148150
{data.sessions.map(entry => (
@@ -183,6 +185,9 @@ export function SessionList({ onSelectSession }: Props) {
183185
<span className={`replay-indicator ${entry.hasVideo ? 'has-replay' : ''}`}>
184186
{entry.hasVideo ? 'Yes' : '—'}
185187
</span>
188+
<span className={`replay-indicator ${entry.hasSubtitles ? 'has-replay' : ''}`}>
189+
{entry.hasSubtitles ? 'Yes' : '—'}
190+
</span>
186191
<span className="session-actions">
187192
<button
188193
className="action-btn rename-btn"
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { useRef, useState, useCallback } from 'react';
2+
3+
interface Props {
4+
sessionId: string;
5+
hasSubtitles: boolean;
6+
onUploadComplete: () => void;
7+
}
8+
9+
type UploadState = 'idle' | 'uploading' | 'done' | 'error';
10+
11+
function contentTypeFor(file: File): string {
12+
if (file.type === 'text/vtt' || file.name.toLowerCase().endsWith('.vtt')) return 'text/vtt';
13+
if (file.type === 'application/x-subrip' || file.name.toLowerCase().endsWith('.srt')) return 'application/x-subrip';
14+
return 'text/plain';
15+
}
16+
17+
export function SubtitleUpload({ sessionId, hasSubtitles, onUploadComplete }: Props) {
18+
const inputRef = useRef<HTMLInputElement>(null);
19+
const [state, setState] = useState<UploadState>('idle');
20+
const [errorMsg, setErrorMsg] = useState('');
21+
22+
const handleFile = useCallback(async (file: File) => {
23+
const name = file.name.toLowerCase();
24+
if (!name.endsWith('.srt') && !name.endsWith('.vtt')) {
25+
setState('error');
26+
setErrorMsg('Only .srt and .vtt files are supported');
27+
return;
28+
}
29+
30+
setState('uploading');
31+
setErrorMsg('');
32+
33+
try {
34+
const res = await fetch(`/api/recordings/${encodeURIComponent(sessionId)}/subtitles`, {
35+
method: 'PUT',
36+
headers: { 'Content-Type': contentTypeFor(file) },
37+
body: file,
38+
});
39+
40+
if (!res.ok) {
41+
const err = await res.json().catch(() => ({ error: 'Upload failed' }));
42+
throw new Error(err.error || 'Upload failed');
43+
}
44+
45+
setState('done');
46+
onUploadComplete();
47+
setTimeout(() => setState('idle'), 2000);
48+
} catch (err) {
49+
setState('error');
50+
setErrorMsg(err instanceof Error ? err.message : 'Upload failed');
51+
}
52+
}, [sessionId, onUploadComplete]);
53+
54+
return (
55+
<div className="video-upload">
56+
<input
57+
ref={inputRef}
58+
type="file"
59+
accept=".srt,.vtt,text/vtt,application/x-subrip"
60+
style={{ display: 'none' }}
61+
onChange={e => {
62+
const file = e.target.files?.[0];
63+
if (file) handleFile(file);
64+
e.target.value = '';
65+
}}
66+
/>
67+
<button
68+
className="video-upload-btn"
69+
onClick={() => inputRef.current?.click()}
70+
disabled={state === 'uploading'}
71+
>
72+
{state === 'uploading' ? 'Uploading...' :
73+
state === 'done' ? 'Uploaded!' :
74+
hasSubtitles ? 'Replace subtitles' : 'Upload subtitles'}
75+
</button>
76+
{state === 'error' && <span className="video-upload-error">{errorMsg}</span>}
77+
</div>
78+
);
79+
}

recording-viewer/src/components/VideoPlayer.tsx

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ export interface VideoPlayerHandle {
44
seekToSessionTimestamp(ts: number): void;
55
getCurrentSessionTimestamp(): number;
66
isPaused(): boolean;
7+
showSubtitles(): void;
78
}
89

910
interface Props {
1011
sessionStartTime: number;
1112
sessionEndTime: number;
1213
videoTimeAtSessionStartSeconds: number;
1314
videoUrl: string;
15+
subtitlesUrl?: string | null;
1416
videoTimeRef: React.RefObject<number>;
1517
onPlayStateChange: (isPlaying: boolean) => void;
1618
}
@@ -23,7 +25,7 @@ function formatVideoTime(seconds: number): string {
2325
}
2426

2527
export const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer(
26-
{ sessionStartTime, sessionEndTime, videoTimeAtSessionStartSeconds, videoUrl, videoTimeRef, onPlayStateChange },
28+
{ sessionStartTime, sessionEndTime, videoTimeAtSessionStartSeconds, videoUrl, subtitlesUrl, videoTimeRef, onPlayStateChange },
2729
ref,
2830
) {
2931
const videoRef = useRef<HTMLVideoElement>(null);
@@ -61,6 +63,18 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPl
6163
isPaused() {
6264
return videoRef.current?.paused ?? true;
6365
},
66+
showSubtitles() {
67+
const video = videoRef.current;
68+
if (!video) return;
69+
// Force-activate the subtitles track. `default` attribute is only a hint
70+
// at first paint; switching it on after a new upload needs explicit mode = 'showing'.
71+
for (let i = 0; i < video.textTracks.length; i++) {
72+
const t = video.textTracks[i];
73+
if (t.kind === 'subtitles' || t.kind === 'captions') {
74+
t.mode = 'showing';
75+
}
76+
}
77+
},
6478
}), [sessionToVideoTime, videoTimeToSession]);
6579

6680
// Frame callback: update videoTimeRef with current session timestamp
@@ -194,7 +208,18 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPl
194208
onPlay={handlePlayPause}
195209
onPause={handlePlayPause}
196210
preload="metadata"
197-
/>
211+
>
212+
{subtitlesUrl && (
213+
<track
214+
key={subtitlesUrl}
215+
kind="subtitles"
216+
src={subtitlesUrl}
217+
srcLang="und"
218+
label="Transcript"
219+
default
220+
/>
221+
)}
222+
</video>
198223
<div className="video-controls">
199224
<button className="video-play-btn" onClick={togglePlay} title={playing ? 'Pause' : 'Play'}>
200225
{playing ? '\u23F8' : '\u25B6'}

0 commit comments

Comments
 (0)