From 22871f667996bfe0df0eba9dfab038f1f6e4ac2d Mon Sep 17 00:00:00 2001 From: Nice Try Date: Fri, 26 Jun 2026 14:34:10 +0000 Subject: [PATCH] feat: add multilingual subtitle tracks --- README.md | 26 ++++ app/progress/page.tsx | 2 +- app/scan/page.tsx | 6 +- app/watch/[lessonId]/WatchPageClient.tsx | 29 +++- app/watch/[lessonId]/page.tsx | 6 + components/VideoPlayer.tsx | 75 +++++++++- lib/scanner.ts | 166 ++++++++++++----------- lib/subtitles.ts | 144 ++++++++++++++++++++ package-lock.json | 7 + package.json | 1 + prisma/schema.prisma | 17 +++ scripts/verify-subtitle-parser.ts | 37 +++++ scripts/verify-subtitle-scan.ts | 59 ++++++++ 13 files changed, 486 insertions(+), 89 deletions(-) create mode 100644 lib/subtitles.ts create mode 100644 scripts/verify-subtitle-parser.ts create mode 100644 scripts/verify-subtitle-scan.ts diff --git a/README.md b/README.md index 84fb564..a24d283 100644 --- a/README.md +++ b/README.md @@ -445,6 +445,9 @@ My_Courses/ │ └── 01 - Welcome.mp4 ├── 02 - Core Concepts/ │ ├── 01 - Lesson One.mp4 + │ ├── 01 - Lesson One.en.vtt + │ ├── 01 - Lesson One.es.vtt + │ ├── 01 - Lesson One.ja.vtt │ └── 02 - Lesson Two.mp4 └── 03 - Practice/ └── 01 - Lab Walkthrough.mp4 @@ -452,6 +455,29 @@ My_Courses/ Supported video extensions include common browser-playable formats such as `.mp4`, plus additional local media formats depending on browser support. +### Subtitles + +OfflineAcademy supports subtitle files stored beside the matching video. Use `.vtt` for the best browser compatibility; `.srt` files are detected as best-effort tracks. + +Single subtitle track: + +```text +01 - Lesson.mp4 +01 - Lesson.vtt +``` + +Multiple language tracks: + +```text +01 - Lesson.mp4 +01 - Lesson.en.vtt +01 - Lesson.es.vtt +01 - Lesson.ja.vtt +01 - Lesson.fil.vtt +``` + +Language suffixes become selectable tracks in the video player settings menu. Examples: `en`, `es`, `fr`, `ja`, `fil`, `zh-CN`, `pt-BR`. + --- ## Data & Persistence diff --git a/app/progress/page.tsx b/app/progress/page.tsx index ef7aa32..11857c4 100644 --- a/app/progress/page.tsx +++ b/app/progress/page.tsx @@ -103,7 +103,7 @@ const liveGroups: FeatureGroup[] = [ items: [ 'Bookmarks with timestamp notes', 'Jump-to-time bookmark playback', - 'Subtitle support for .srt and .vtt', + 'Multi-language subtitle tracks for .vtt/.srt files', 'Inline document viewing for lessons', ], }, diff --git a/app/scan/page.tsx b/app/scan/page.tsx index 2957833..cd15bf8 100755 --- a/app/scan/page.tsx +++ b/app/scan/page.tsx @@ -182,7 +182,8 @@ export default function ScanPage() { ├── Course Name 1/ │ ├── 01 - Introduction/ │ │ ├── 01 - Introduction.mp4 -│ │ ├── 01 - Introduction.srt +│ │ ├── 01 - Introduction.en.vtt +│ │ ├── 01 - Introduction.es.vtt │ │ ├── 02 - Overview.pdf │ │ └── 03 - Notes.txt │ ├── 02 - Advanced/ @@ -204,7 +205,8 @@ export default function ScanPage() {

How the scanner reads it: course folder → module folder → lesson files.

-

Matching rule: subtitle files should share the same base name as the video, like lesson.mp4 + lesson.srt.

+

Matching rule: subtitle files can either share the exact video base name, like lesson.mp4 + lesson.vtt, or use language suffixes for multiple tracks, like lesson.en.vtt + lesson.es.vtt.

+

Subtitle formats: .vtt is preferred for browser playback; .srt files are detected and listed as best-effort tracks.

Sorting: numeric prefixes like 01, 02, 10 keep lessons in learning order.

diff --git a/app/watch/[lessonId]/WatchPageClient.tsx b/app/watch/[lessonId]/WatchPageClient.tsx index f87290a..0f582cf 100755 --- a/app/watch/[lessonId]/WatchPageClient.tsx +++ b/app/watch/[lessonId]/WatchPageClient.tsx @@ -21,6 +21,15 @@ import Link from 'next/link' import { formatDuration, formatTime, cn } from '@/lib/utils' import { getCourseDisplayName } from '@/lib/course-display' +interface SubtitleTrackType { + id?: string + src: string + lang: string + label: string + format?: string + isDefault: boolean +} + interface LessonType { id: string title: string @@ -33,6 +42,7 @@ interface LessonType { filePath: string thumbnail: string | null subtitlePath: string | null + subtitles?: SubtitleTrackType[] progress?: { completed: boolean; position: number; lastWatched: string } | null quiz?: { version: 1 @@ -118,13 +128,28 @@ function markCompleteFn(lessonId: string, courseId: string, moduleId: string, po } function VideoContent({ lesson, lessonProgress, onSave, onEnd, onTimeUpdate, seekRequest }: { - lesson: { id: string; filePath: string; thumbnail: string | null; duration: number | null; subtitlePath: string | null } + lesson: { id: string; filePath: string; thumbnail: string | null; duration: number | null; subtitlePath: string | null; subtitles?: SubtitleTrackType[] } lessonProgress: { completed: boolean; position: number; lastWatched: string } onSave: (position: number) => void onEnd: () => void onTimeUpdate: (time: number) => void seekRequest: { time: number; nonce: number } | null }) { + const subtitleTracks = React.useMemo(() => { + if (lesson.subtitles && lesson.subtitles.length > 0) { + return lesson.subtitles.map(track => ({ + src: '/api/files/' + track.src, + srcLang: track.lang, + label: track.label, + default: track.isDefault, + })) + } + + return lesson.subtitlePath + ? [{ src: '/api/files/' + lesson.subtitlePath, srcLang: 'en', label: 'English', default: true }] + : [] + }, [lesson.subtitlePath, lesson.subtitles]) + return ( ) } diff --git a/app/watch/[lessonId]/page.tsx b/app/watch/[lessonId]/page.tsx index e021458..866ed28 100755 --- a/app/watch/[lessonId]/page.tsx +++ b/app/watch/[lessonId]/page.tsx @@ -22,6 +22,12 @@ export default async function WatchPage({ params, searchParams }: WatchPageProps progress: { where: { userId: 'local-user' }, }, + subtitles: { + orderBy: [ + { isDefault: 'desc' }, + { label: 'asc' }, + ], + }, }, }) diff --git a/components/VideoPlayer.tsx b/components/VideoPlayer.tsx index 6f148c2..7a5d1c8 100755 --- a/components/VideoPlayer.tsx +++ b/components/VideoPlayer.tsx @@ -7,10 +7,17 @@ import { cn, formatTime } from '@/lib/utils' import { Button } from '@/components/ui/button' import { KeyboardShortcutsOverlay } from '@/components/KeyboardShortcutsOverlay' +interface VideoSubtitleTrack { + src: string + srcLang: string + label: string + default?: boolean +} + interface VideoPlayerProps { src: string poster?: string - subtitles?: string + subtitles?: VideoSubtitleTrack[] onTimeUpdate?: (time: number) => void onEnded?: () => void onProgressSave?: (time: number) => void @@ -46,6 +53,10 @@ export function VideoPlayer({ const [isPiPActive, setIsPiPActive] = useState(false) const [hoverProgress, setHoverProgress] = useState<{ time: number; x: number } | null>(null) const [showSettingsMenu, setShowSettingsMenu] = useState(false) + const [selectedSubtitle, setSelectedSubtitle] = useState(() => { + const defaultIndex = subtitles?.findIndex(track => track.default) ?? -1 + return defaultIndex >= 0 ? defaultIndex : 'off' + }) const settingsMenuRef = useRef(null) const controlsTimeoutRef = useRef>() const progressSaveTimeoutRef = useRef>() @@ -385,6 +396,20 @@ export function VideoPlayer({ return () => document.removeEventListener('mousedown', handleClickOutside) }, [showSettingsMenu]) + useEffect(() => { + const defaultIndex = subtitles?.findIndex(track => track.default) ?? -1 + setSelectedSubtitle(defaultIndex >= 0 ? defaultIndex : 'off') + }, [subtitles]) + + useEffect(() => { + const textTracks = videoRef.current?.textTracks + if (!textTracks) return + + for (let index = 0; index < textTracks.length; index += 1) { + textTracks[index].mode = selectedSubtitle === index ? 'showing' : 'disabled' + } + }, [selectedSubtitle, subtitles]) + return (
- {subtitles && ( - - )} + {subtitles?.map((track, index) => ( + + ))}
@@ -589,7 +621,7 @@ export function VideoPlayer({ {showSettingsMenu && (
Speed
@@ -611,6 +643,39 @@ export function VideoPlayer({ {playbackRate === rate && } ))} + {subtitles && subtitles.length > 0 && ( + <> +
+
Subtitles
+ + {subtitles.map((track, index) => ( + + ))} + + )}