feat(player): let editors upload subtitles for a version

Subtitle tracks hang off a version rather than off a video, because re-editing a cut shifts every cue. The file
always lands in our own S3-compatible storage whatever hosts the video, so a Bunny-hosted cut and an R2 one take the
same path: both already play through our own video element, so a track element is all it takes.

Uploads are normalised before they are stored. Whatever arrives, SRT or WebVTT, is parsed into cues and
re-serialised as a canonical WebVTT file, and anything we did not understand is dropped rather than passed through.
That is what makes it safe to serve a user-supplied text file from our own origin. Files saved out of Windows
editors are decoded as windows-1254 or windows-1252 when they are not valid UTF-8, rather than refused.

A YouTube version cannot carry an uploaded track, so the same CC menu drives YouTube's own captions through the
iframe module API. The embed hides YouTube's controls, so until now those captions were unreachable even when the
video had them.

Uploading and deleting take the editor permission rather than the commenter one: a subtitle is part of the
delivered cut, not a comment attachment.
This commit is contained in:
2026-08-22 07:51:46 +03:00
parent 1f3c6b3f1e
commit d981d98cf5
26 changed files with 2529 additions and 25 deletions
+51
View File
@@ -38,6 +38,8 @@ import type {
} from '@/components/video-page/types';
import { useApprovals } from '@/components/video-page/hooks/use-approvals';
import { useVideoAssets } from '@/components/video-page/hooks/use-video-assets';
import { useSubtitles } from '@/components/video-page/hooks/use-subtitles';
import { useYoutubeCaptions } from '@/components/video-page/hooks/use-youtube-captions';
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
import { getSpeedOptionsForProvider } from '@/components/video-page/hooks/video-player-utils';
@@ -275,6 +277,24 @@ export function VideoPageContent({
}, [video?.versions, activeVersionId]);
const activeProviderId = activeVersion?.providerId;
const speedOptions = getSpeedOptionsForProvider(activeProviderId);
// Only the providers that play through our own <video> element can carry a <track>.
// A YouTube version is an iframe we do not control, and it brings its own captions.
const supportsSubtitles = activeProviderId === 'bunny' || activeProviderId === 'r2';
const {
subtitles,
subtitleTrackKey,
canManageSubtitles,
activeSubtitleLanguage,
selectSubtitleLanguage,
uploadSubtitle,
deleteSubtitle,
isUploadingSubtitle,
} = useSubtitles({
videoId,
versionId: activeVersionId,
videoRef,
supportsSubtitles,
});
const activeVersionDuration = activeVersion?.duration;
const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []);
const embedUrl = useMemo(() => {
@@ -305,6 +325,7 @@ export function VideoPageContent({
const {
isReady,
youtubeModuleRevision,
bunnyPlaybackState,
currentTime,
setCurrentTime,
@@ -359,6 +380,27 @@ export function VideoPageContent({
setViewingAnnotation,
});
const { youtubeCaptionTracks, activeYoutubeCaptionLanguage, selectYoutubeCaptionLanguage } =
useYoutubeCaptions({
videoId,
versionId: activeVersionId,
playerRef,
enabled: activeProviderId === 'youtube',
isReady,
moduleRevision: youtubeModuleRevision,
});
// One CC menu, two sources behind it. A YouTube version can only offer the captions the
// video already carries, so nothing there is ours to manage.
const isYoutubeVersion = activeProviderId === 'youtube';
const subtitleTracks = isYoutubeVersion ? youtubeCaptionTracks : subtitles;
const activeCaptionLanguage = isYoutubeVersion
? activeYoutubeCaptionLanguage
: activeSubtitleLanguage;
const selectCaptionLanguage = isYoutubeVersion
? selectYoutubeCaptionLanguage
: selectSubtitleLanguage;
const {
savedProgress,
showResumePrompt,
@@ -823,6 +865,15 @@ export function VideoPageContent({
selectedQualityLevel={selectedQualityLevel}
qualityOptions={qualityOptions}
handleQualityChange={handleQualityChange}
subtitles={subtitles}
subtitleTracks={subtitleTracks}
subtitleTrackKey={subtitleTrackKey}
activeSubtitleLanguage={activeCaptionLanguage}
onSelectSubtitleLanguage={selectCaptionLanguage}
canManageSubtitles={canManageSubtitles}
onUploadSubtitle={uploadSubtitle}
onDeleteSubtitle={deleteSubtitle}
isUploadingSubtitle={isUploadingSubtitle}
playbackSpeed={playbackSpeed}
speedOptions={speedOptions}
handleSpeedChange={handleSpeedChange}
@@ -0,0 +1,32 @@
/**
* The chosen subtitle language, remembered per video the way a player is expected to.
*
* Shared by both caption paths, so a viewer who turned Turkish on for a Bunny-hosted cut
* gets Turkish again on the YouTube version of the same video.
*/
function preferenceKey(videoId: string): string {
return `openframe:subtitle-language:${videoId}`;
}
export function readStoredSubtitleLanguage(videoId: string): string | null {
if (typeof window === 'undefined') return null;
try {
return window.localStorage.getItem(preferenceKey(videoId));
} catch {
return null;
}
}
export function writeStoredSubtitleLanguage(videoId: string, language: string | null): void {
if (typeof window === 'undefined') return;
try {
if (language) {
window.localStorage.setItem(preferenceKey(videoId), language);
} else {
window.localStorage.removeItem(preferenceKey(videoId));
}
} catch {
// A browser with storage disabled still gets subtitles, just not a remembered choice.
}
}
@@ -0,0 +1,270 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react';
import {
readStoredSubtitleLanguage,
writeStoredSubtitleLanguage,
} from '@/components/video-page/hooks/subtitle-preference';
import type { Subtitle } from '@/components/video-page/types';
interface UseSubtitlesParams {
videoId: string;
versionId: string | null;
videoRef: RefObject<HTMLVideoElement | null>;
/** Only the providers we play through our own element can carry a track. */
supportsSubtitles: boolean;
}
/**
* A wiped track is remounted at most this many times per version. A file that really is
* empty cannot reach storage (the upload route refuses one), so the cap only exists so a
* surprise can never turn into a fetch loop.
*/
const MAX_TRACK_REPAIRS = 3;
export function useSubtitles({
videoId,
versionId,
videoRef,
supportsSubtitles,
}: UseSubtitlesParams) {
const [subtitles, setSubtitles] = useState<Subtitle[]>([]);
const [canManageSubtitles, setCanManageSubtitles] = useState(false);
const [activeLanguage, setActiveLanguage] = useState<string | null>(null);
const [isUploadingSubtitle, setIsUploadingSubtitle] = useState(false);
// Bumped to remount the <track> elements when something empties them. See the effect
// below for what does that and why remounting is the fix.
const [trackEpoch, setTrackEpoch] = useState(0);
// The stored preference is applied once per version, not on every list refresh: turning
// subtitles off and then deleting an unrelated track must not switch them back on.
const appliedPreferenceForVersionRef = useRef<string | null>(null);
const loadedLanguagesRef = useRef<Set<string>>(new Set());
const repairCountRef = useRef(0);
useEffect(() => {
loadedLanguagesRef.current.clear();
repairCountRef.current = 0;
}, [versionId]);
const refresh = useCallback(async () => {
if (!versionId || !supportsSubtitles) {
setSubtitles([]);
setCanManageSubtitles(false);
return;
}
try {
const res = await fetch(
`/api/videos/${videoId}/subtitles?versionId=${encodeURIComponent(versionId)}`,
{ cache: 'no-store' }
);
if (!res.ok) return;
const payload = await res.json();
const list: Subtitle[] = Array.isArray(payload?.data?.subtitles)
? payload.data.subtitles
: [];
setSubtitles(list);
setCanManageSubtitles(Boolean(payload?.data?.canManageSubtitles));
} catch {
// A failed list leaves the player without tracks, which is the same as having none.
}
}, [supportsSubtitles, versionId, videoId]);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
if (!versionId) return;
if (appliedPreferenceForVersionRef.current === versionId) return;
if (subtitles.length === 0) return;
appliedPreferenceForVersionRef.current = versionId;
const stored = readStoredSubtitleLanguage(videoId);
if (stored && subtitles.some((subtitle) => subtitle.language === stored)) {
setActiveLanguage(stored);
}
}, [subtitles, versionId, videoId]);
// A track that is no longer in the list cannot stay selected.
useEffect(() => {
if (!activeLanguage) return;
if (subtitles.some((subtitle) => subtitle.language === activeLanguage)) return;
setActiveLanguage(null);
}, [activeLanguage, subtitles]);
/**
* React renders the <track> elements; their display mode is set here rather than through
* the `default` attribute, which the browser only honours on first load and which would
* fight the user's choice on every re-render.
*
* The second job here is repair. hls.js empties every text track on the media element,
* ours included, each time it loads a manifest (`_cleanTracks()` in its timeline
* controller). That fires on the initial load and again on every source switch, so a
* viewer who flips quality would watch the subtitles vanish for good: the file has
* already been fetched, so the browser never parses it a second time. Remounting the
* track element under a new key is what makes it fetch again.
*/
useEffect(() => {
const videoEl = videoRef.current;
if (!videoEl) return;
const findActiveTrack = (): TextTrack | null => {
if (!activeLanguage) return null;
const tracks = videoEl.textTracks;
for (let index = 0; index < tracks.length; index += 1) {
if (tracks[index].language === activeLanguage) return tracks[index];
}
return null;
};
const applyModes = () => {
const tracks = videoEl.textTracks;
for (let index = 0; index < tracks.length; index += 1) {
const track = tracks[index];
const shouldShow = Boolean(activeLanguage) && track.language === activeLanguage;
track.mode = shouldShow ? 'showing' : 'disabled';
if (!shouldShow) continue;
// The control bar sits over the bottom of the frame in fullscreen, so cues are
// lifted clear of it instead of landing underneath.
const cues = track.cues;
if (!cues) continue;
for (let cueIndex = 0; cueIndex < cues.length; cueIndex += 1) {
const cue = cues[cueIndex] as VTTCue;
if (typeof cue.line !== 'undefined') {
cue.snapToLines = true;
cue.line = -3;
}
}
}
};
const markLoaded = (event: Event) => {
const element = event.currentTarget as HTMLTrackElement;
loadedLanguagesRef.current.add(element.srclang);
applyModes();
};
const repairIfEmptied = () => {
if (!activeLanguage) return;
// Before the file has loaded a track legitimately has no cues, so only a track we
// have seen load and that is now empty counts as wiped.
if (!loadedLanguagesRef.current.has(activeLanguage)) return;
const track = findActiveTrack();
if (!track || (track.cues?.length ?? 0) > 0) return;
if (repairCountRef.current >= MAX_TRACK_REPAIRS) return;
repairCountRef.current += 1;
loadedLanguagesRef.current.delete(activeLanguage);
setTrackEpoch((epoch) => epoch + 1);
};
applyModes();
// A track's cues are null until the browser has fetched the file, which it only does
// once the track is not disabled. The lift above therefore has to run again on load.
const trackElements = Array.from(videoEl.querySelectorAll('track'));
trackElements.forEach((element) => element.addEventListener('load', markLoaded));
videoEl.textTracks.addEventListener('addtrack', applyModes);
// `loadeddata` catches a source switch while paused; `timeupdate` catches everything
// else within a quarter of a second of playback.
videoEl.addEventListener('loadeddata', repairIfEmptied);
videoEl.addEventListener('timeupdate', repairIfEmptied);
return () => {
trackElements.forEach((element) => element.removeEventListener('load', markLoaded));
videoEl.textTracks.removeEventListener('addtrack', applyModes);
videoEl.removeEventListener('loadeddata', repairIfEmptied);
videoEl.removeEventListener('timeupdate', repairIfEmptied);
};
}, [activeLanguage, subtitles, trackEpoch, videoRef, versionId]);
const selectSubtitleLanguage = useCallback(
(language: string | null) => {
setActiveLanguage(language);
writeStoredSubtitleLanguage(videoId, language);
appliedPreferenceForVersionRef.current = versionId;
},
[versionId, videoId]
);
const uploadSubtitle = useCallback(
async (file: File, language: string, label: string): Promise<string | null> => {
if (!versionId) return 'No version selected';
setIsUploadingSubtitle(true);
try {
const formData = new FormData();
formData.append('subtitle', file);
formData.append('versionId', versionId);
formData.append('language', language);
formData.append('label', label);
const res = await fetch(`/api/videos/${videoId}/subtitles`, {
method: 'POST',
body: formData,
});
const payload = await res.json().catch(() => null);
if (!res.ok) {
return payload?.error?.message || payload?.error || 'Failed to upload subtitle';
}
await refresh();
selectSubtitleLanguage(language.toLowerCase());
return null;
} catch {
return 'Failed to upload subtitle';
} finally {
setIsUploadingSubtitle(false);
}
},
[refresh, selectSubtitleLanguage, versionId, videoId]
);
const deleteSubtitle = useCallback(
async (subtitleId: string): Promise<string | null> => {
try {
const res = await fetch(`/api/videos/${videoId}/subtitles/${subtitleId}`, {
method: 'DELETE',
});
if (!res.ok) {
const payload = await res.json().catch(() => null);
return payload?.error?.message || payload?.error || 'Failed to delete subtitle';
}
await refresh();
return null;
} catch {
return 'Failed to delete subtitle';
}
},
[refresh, videoId]
);
return useMemo(
() => ({
subtitles,
canManageSubtitles,
activeSubtitleLanguage: activeLanguage,
subtitleTrackKey: String(trackEpoch),
selectSubtitleLanguage,
uploadSubtitle,
deleteSubtitle,
isUploadingSubtitle,
refreshSubtitles: refresh,
}),
[
activeLanguage,
canManageSubtitles,
deleteSubtitle,
isUploadingSubtitle,
refresh,
selectSubtitleLanguage,
subtitles,
trackEpoch,
uploadSubtitle,
]
);
}
@@ -81,6 +81,10 @@ export function useVideoPlayer({
}: UseVideoPlayerParams) {
const [isApiLoaded, setIsApiLoaded] = useState(false);
const [isReady, setIsReady] = useState(false);
// Bumped every time the YouTube player loads or unloads a module. It is the only
// signal that `getOption('captions', ...)` will answer, so the captions hook waits
// on it rather than polling.
const [youtubeModuleRevision, setYoutubeModuleRevision] = useState(0);
const [bunnyPlaybackState, setBunnyPlaybackState] = useState<BunnyPlaybackState>('none');
const [currentTime, setCurrentTime] = useState(0);
const [videoDuration, setVideoDuration] = useState(0);
@@ -315,6 +319,9 @@ export function useVideoPlayer({
const dur = event.target.getDuration();
if (dur > 0) setVideoDuration(dur);
},
onApiChange: () => {
setYoutubeModuleRevision((revision) => revision + 1);
},
onStateChange: (event: YT.OnStateChangeEvent) => {
setIsPlaying(event.data === YT.PlayerState.PLAYING);
@@ -1344,6 +1351,7 @@ export function useVideoPlayer({
return {
isReady,
youtubeModuleRevision,
bunnyPlaybackState,
currentTime,
setCurrentTime,
@@ -0,0 +1,177 @@
'use client';
// Same exemption as the players themselves: this hook mirrors an external player's
// caption state into React, which is the case the rule cannot distinguish.
/* eslint-disable react-hooks/set-state-in-effect */
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react';
import {
readStoredSubtitleLanguage,
writeStoredSubtitleLanguage,
} from '@/components/video-page/hooks/subtitle-preference';
import type { PlayerAdapter, SubtitleTrackOption } from '@/components/video-page/types';
interface UseYoutubeCaptionsParams {
videoId: string;
versionId: string | null;
playerRef: RefObject<YT.Player | PlayerAdapter | null>;
/** The active version is a YouTube one. */
enabled: boolean;
isReady: boolean;
/** Incremented by the player on every onApiChange. */
moduleRevision: number;
}
/** One entry of `getOption('captions', 'tracklist')`. Only these fields are relied on. */
type YoutubeCaptionTrack = {
languageCode?: string;
languageName?: string;
displayName?: string;
};
const CAPTIONS_MODULE = 'captions';
function asYoutubePlayer(
player: YT.Player | PlayerAdapter | null
): (YT.Player & { loadModule?: unknown }) | null {
if (!player) return null;
const candidate = player as YT.Player;
return typeof candidate.loadModule === 'function' ? candidate : null;
}
/**
* Drives YouTube's own captions from our control bar.
*
* A YouTube version plays inside an iframe we do not own, so a <track> element is not an
* option and neither is an uploaded file: the only captions that exist for it are the ones
* the video already carries. The player is embedded with controls=0, which hides
* YouTube's CC button along with the rest of its chrome, so without this the captions
* would be unreachable even when they exist.
*/
export function useYoutubeCaptions({
videoId,
versionId,
playerRef,
enabled,
isReady,
moduleRevision,
}: UseYoutubeCaptionsParams) {
const [tracks, setTracks] = useState<SubtitleTrackOption[]>([]);
const [activeLanguage, setActiveLanguage] = useState<string | null>(null);
// Read inside effects that must not re-run when the selection changes.
const activeLanguageRef = useRef<string | null>(null);
useEffect(() => {
activeLanguageRef.current = activeLanguage;
}, [activeLanguage]);
const appliedPreferenceForVersionRef = useRef<string | null>(null);
/**
* The caption state we last pushed into the player, or `undefined` before the first
* push. Loading and unloading a module both fire onApiChange, so an effect that reacted
* to every revision by unloading again would answer its own event forever.
*/
const appliedLanguageRef = useRef<string | null | undefined>(undefined);
useEffect(() => {
setTracks([]);
setActiveLanguage(null);
appliedLanguageRef.current = undefined;
}, [versionId]);
// Loading the module is what makes the track list readable, and it also switches
// captions on. The probe below turns them straight back off for a viewer who has not
// asked for them: at this point the video is at its first frame with no cue to draw,
// so there is nothing to flash.
useEffect(() => {
if (!enabled || !isReady) return;
const player = asYoutubePlayer(playerRef.current);
if (!player) return;
try {
player.loadModule(CAPTIONS_MODULE);
} catch {
// An older or restricted player without the module API simply has no captions.
}
}, [enabled, isReady, playerRef, versionId]);
useEffect(() => {
if (!enabled || !isReady || moduleRevision === 0) return;
const player = asYoutubePlayer(playerRef.current);
if (!player) return;
let rawTracks: YoutubeCaptionTrack[] = [];
try {
rawTracks = player.getOption<YoutubeCaptionTrack[]>(CAPTIONS_MODULE, 'tracklist') ?? [];
} catch {
rawTracks = [];
}
const mapped: SubtitleTrackOption[] = rawTracks
.filter((track): track is YoutubeCaptionTrack & { languageCode: string } =>
Boolean(track?.languageCode)
)
.map((track) => ({
id: `youtube:${track.languageCode}`,
language: track.languageCode.toLowerCase(),
label: track.displayName || track.languageName || track.languageCode.toUpperCase(),
canDelete: false,
}));
setTracks(mapped);
const stored =
versionId && appliedPreferenceForVersionRef.current !== versionId
? readStoredSubtitleLanguage(videoId)
: null;
if (versionId) appliedPreferenceForVersionRef.current = versionId;
const wanted =
activeLanguageRef.current ??
(stored && mapped.some((track) => track.language === stored) ? stored : null);
if (appliedLanguageRef.current === wanted) return;
appliedLanguageRef.current = wanted;
try {
if (wanted) {
player.setOption(CAPTIONS_MODULE, 'track', { languageCode: wanted });
setActiveLanguage(wanted);
} else {
player.unloadModule(CAPTIONS_MODULE);
}
} catch {
// Same as above: a player that will not take the option has no captions to give.
}
}, [enabled, isReady, moduleRevision, playerRef, versionId, videoId]);
const selectCaptionLanguage = useCallback(
(language: string | null) => {
setActiveLanguage(language);
writeStoredSubtitleLanguage(videoId, language);
appliedPreferenceForVersionRef.current = versionId;
appliedLanguageRef.current = language;
const player = asYoutubePlayer(playerRef.current);
if (!player) return;
try {
if (language) {
player.loadModule(CAPTIONS_MODULE);
player.setOption(CAPTIONS_MODULE, 'track', { languageCode: language });
} else {
player.unloadModule(CAPTIONS_MODULE);
}
} catch {
// Nothing to recover: the menu already reflects the choice, and a player that
// refuses the module was never going to show captions.
}
},
[playerRef, versionId, videoId]
);
return useMemo(
() => ({
youtubeCaptionTracks: enabled ? tracks : [],
activeYoutubeCaptionLanguage: enabled ? activeLanguage : null,
selectYoutubeCaptionLanguage: selectCaptionLanguage,
}),
[activeLanguage, enabled, selectCaptionLanguage, tracks]
);
}
+57 -2
View File
@@ -32,7 +32,13 @@ import {
type AnnotationStroke,
} from '@/components/annotation-canvas';
import { SILENT_ABOVE_SPEED } from '@/components/video-page/hooks/video-player-utils';
import type { BunnyQualityOption, CommentMarker } from '@/components/video-page/types';
import { SubtitleControls } from '@/components/video-page/subtitle-controls';
import type {
BunnyQualityOption,
CommentMarker,
Subtitle,
SubtitleTrackOption,
} from '@/components/video-page/types';
interface PlayerCoreProps {
activeVersionId: string | null;
@@ -85,6 +91,24 @@ interface PlayerCoreProps {
selectedQualityLevel: number;
qualityOptions: BunnyQualityOption[];
handleQualityChange: (level: number) => void;
/** Uploaded tracks, rendered as <track> elements. Empty for a YouTube version. */
subtitles: Subtitle[];
/**
* What the CC menu offers, which is the list above for our own player and YouTube's
* own caption list for an embedded YouTube version.
*/
subtitleTracks: SubtitleTrackOption[];
/**
* Changes when a track has to be re-fetched. It is part of each <track> key because
* remounting the element is the only way to make the browser parse the file again.
*/
subtitleTrackKey: string;
activeSubtitleLanguage: string | null;
onSelectSubtitleLanguage: (language: string | null) => void;
canManageSubtitles: boolean;
onUploadSubtitle: (file: File, language: string, label: string) => Promise<string | null>;
onDeleteSubtitle: (subtitleId: string) => Promise<string | null>;
isUploadingSubtitle: boolean;
playbackSpeed: number;
speedOptions: number[];
handleSpeedChange: (speed: number) => void;
@@ -153,6 +177,15 @@ export const PlayerCore = memo(function PlayerCore({
selectedQualityLevel,
qualityOptions,
handleQualityChange,
subtitles,
subtitleTracks,
subtitleTrackKey,
activeSubtitleLanguage,
onSelectSubtitleLanguage,
canManageSubtitles,
onUploadSubtitle,
onDeleteSubtitle,
isUploadingSubtitle,
playbackSpeed,
speedOptions,
handleSpeedChange,
@@ -208,7 +241,17 @@ export const PlayerCore = memo(function PlayerCore({
}}
preload="metadata"
playsInline
/>
>
{subtitles.map((subtitle) => (
<track
key={`${subtitle.id}:${subtitleTrackKey}`}
kind="subtitles"
src={subtitle.url}
srcLang={subtitle.language}
label={subtitle.label}
/>
))}
</video>
</div>
</div>
) : (
@@ -442,6 +485,18 @@ export const PlayerCore = memo(function PlayerCore({
</DropdownMenu>
)}
{activeProviderId && activeProviderId !== 'direct' && (
<SubtitleControls
subtitles={subtitleTracks}
activeSubtitleLanguage={activeSubtitleLanguage}
onSelectSubtitleLanguage={onSelectSubtitleLanguage}
canManageSubtitles={canManageSubtitles}
onUploadSubtitle={onUploadSubtitle}
onDeleteSubtitle={onDeleteSubtitle}
isUploadingSubtitle={isUploadingSubtitle}
/>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1 text-xs">
+303
View File
@@ -0,0 +1,303 @@
'use client';
import { memo, useCallback, useMemo, useRef, useState } from 'react';
import { Captions, Loader2, Trash2, Upload } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import type { SubtitleTrackOption } from '@/components/video-page/types';
const COMMON_LANGUAGES = [
'tr',
'en',
'de',
'fr',
'es',
'it',
'pt',
'nl',
'pl',
'ru',
'ar',
'ja',
'ko',
'zh',
'hi',
] as const;
const OTHER_LANGUAGE = '__other__';
/** A file named `cut-v3.tr.srt` already says which language it is. */
const LANGUAGE_FROM_FILENAME = /\.([a-z]{2,3}(?:-[a-z0-9]{2,8})?)\.(?:srt|vtt)$/i;
function describeLanguage(tag: string): string {
try {
const displayNames = new Intl.DisplayNames(undefined, { type: 'language' });
return displayNames.of(tag) || tag.toUpperCase();
} catch {
return tag.toUpperCase();
}
}
function guessLanguageFromFileName(fileName: string): string | null {
const match = LANGUAGE_FROM_FILENAME.exec(fileName);
return match ? match[1].toLowerCase() : null;
}
interface SubtitleControlsProps {
/**
* What the menu lists. For a Bunny or R2 version these are the tracks uploaded to this
* cut; for a YouTube version they are the captions the video already carries, which is
* why the shape is narrower than a stored subtitle.
*/
subtitles: SubtitleTrackOption[];
activeSubtitleLanguage: string | null;
onSelectSubtitleLanguage: (language: string | null) => void;
canManageSubtitles: boolean;
onUploadSubtitle: (file: File, language: string, label: string) => Promise<string | null>;
onDeleteSubtitle: (subtitleId: string) => Promise<string | null>;
isUploadingSubtitle: boolean;
}
export const SubtitleControls = memo(function SubtitleControls({
subtitles,
activeSubtitleLanguage,
onSelectSubtitleLanguage,
canManageSubtitles,
onUploadSubtitle,
onDeleteSubtitle,
isUploadingSubtitle,
}: SubtitleControlsProps) {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [languageChoice, setLanguageChoice] = useState<string>('tr');
const [customLanguage, setCustomLanguage] = useState('');
const [label, setLabel] = useState('');
const activeSubtitle = useMemo(
() => subtitles.find((subtitle) => subtitle.language === activeSubtitleLanguage) ?? null,
[activeSubtitleLanguage, subtitles]
);
const resolvedLanguage = (
languageChoice === OTHER_LANGUAGE ? customLanguage : languageChoice
).trim();
const handleFileChosen = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] ?? null;
// Clearing the input lets the same file be picked again after a failed upload.
event.target.value = '';
if (!file) return;
const guessed = guessLanguageFromFileName(file.name);
const known = guessed && (COMMON_LANGUAGES as readonly string[]).includes(guessed);
setLanguageChoice(known ? (guessed as string) : guessed ? OTHER_LANGUAGE : 'tr');
setCustomLanguage(known ? '' : (guessed ?? ''));
setLabel('');
setPendingFile(file);
}, []);
const handleUpload = useCallback(async () => {
if (!pendingFile || !resolvedLanguage) return;
const finalLabel = label.trim() || describeLanguage(resolvedLanguage);
const error = await onUploadSubtitle(pendingFile, resolvedLanguage, finalLabel);
if (error) {
toast.error(error);
return;
}
toast.success('Subtitle added');
setPendingFile(null);
}, [label, onUploadSubtitle, pendingFile, resolvedLanguage]);
const handleDelete = useCallback(
async (subtitle: SubtitleTrackOption) => {
const error = await onDeleteSubtitle(subtitle.id);
if (error) {
toast.error(error);
return;
}
toast.success(`${subtitle.label} removed`);
},
[onDeleteSubtitle]
);
if (subtitles.length === 0 && !canManageSubtitles) return null;
const replacesExisting = subtitles.some(
(subtitle) => subtitle.language === resolvedLanguage.toLowerCase()
);
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant={activeSubtitle ? 'default' : 'ghost'}
size="sm"
className="h-8 gap-1 text-xs"
title="Subtitles"
>
<Captions className="h-3.5 w-3.5" />
{activeSubtitle ? activeSubtitle.label : 'CC'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[180px]">
<DropdownMenuItem
onClick={() => onSelectSubtitleLanguage(null)}
className={cn(!activeSubtitleLanguage && 'font-bold text-primary')}
>
Off
</DropdownMenuItem>
{subtitles.map((subtitle) => (
<DropdownMenuItem
key={subtitle.id}
onClick={() => onSelectSubtitleLanguage(subtitle.language)}
className={cn(
'flex items-center justify-between gap-2',
subtitle.language === activeSubtitleLanguage && 'font-bold text-primary'
)}
>
<span className="truncate">{subtitle.label}</span>
{subtitle.canDelete && (
<button
type="button"
aria-label={`Delete ${subtitle.label} subtitle`}
className="text-muted-foreground hover:text-destructive"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void handleDelete(subtitle);
}}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</DropdownMenuItem>
))}
{canManageSubtitles && (
<>
{subtitles.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuItem
onClick={() => fileInputRef.current?.click()}
disabled={isUploadingSubtitle}
>
<Upload className="h-3.5 w-3.5 mr-2" />
Add subtitle
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<input
ref={fileInputRef}
type="file"
accept=".srt,.vtt,text/vtt,application/x-subrip"
className="hidden"
onChange={handleFileChosen}
/>
<Dialog
open={!!pendingFile}
onOpenChange={(open) => {
if (!open && !isUploadingSubtitle) setPendingFile(null);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add subtitle</DialogTitle>
<DialogDescription>
{pendingFile?.name} is attached to this version only, because cue timings belong to
one cut. SRT files are converted to WebVTT on upload.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="subtitle-language">Language</Label>
<Select value={languageChoice} onValueChange={setLanguageChoice}>
<SelectTrigger id="subtitle-language">
<SelectValue />
</SelectTrigger>
<SelectContent>
{COMMON_LANGUAGES.map((tag) => (
<SelectItem key={tag} value={tag}>
{describeLanguage(tag)}
</SelectItem>
))}
<SelectItem value={OTHER_LANGUAGE}>Other</SelectItem>
</SelectContent>
</Select>
{languageChoice === OTHER_LANGUAGE && (
<Input
value={customLanguage}
onChange={(event) => setCustomLanguage(event.target.value)}
placeholder="Language tag, e.g. en-US"
maxLength={20}
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="subtitle-label">Label</Label>
<Input
id="subtitle-label"
value={label}
onChange={(event) => setLabel(event.target.value)}
placeholder={resolvedLanguage ? describeLanguage(resolvedLanguage) : 'Türkçe'}
maxLength={60}
/>
</div>
{replacesExisting && (
<p className="text-xs text-muted-foreground">
This version already has a track in that language. Uploading replaces it.
</p>
)}
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setPendingFile(null)}
disabled={isUploadingSubtitle}
>
Cancel
</Button>
<Button
onClick={() => void handleUpload()}
disabled={isUploadingSubtitle || !resolvedLanguage}
>
{isUploadingSubtitle && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Upload
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
});
+21
View File
@@ -35,6 +35,27 @@ export interface VideoAsset {
canDelete: boolean;
}
/**
* What the player's CC menu needs to know about one track. Our own uploaded tracks and
* the ones a YouTube video brings with it are different things underneath, and the menu
* is the one place that does not have to care.
*/
export interface SubtitleTrackOption {
id: string;
language: string;
label: string;
canDelete: boolean;
}
export interface Subtitle extends SubtitleTrackOption {
versionId: string;
url: string;
sizeBytes: number;
createdAt: string;
updatedAt: string;
uploadedByUser: { id: string; name: string | null; image: string | null } | null;
}
export interface ApprovalDecision {
id: string;
approverId: string;