15 KiB
Multi-Language Subtitles Implementation Plan
For Hermes / future AI assistants: Use
offlineacademy-dev,writing-plans, andtest-driven-developmentbefore implementing this plan. Implement task-by-task on branchfeature/multilang-subtitles. Do not modify unrelated watch-page sidebar behavior, donation buttons, Docker docs, or quiz features while working on this branch.
Goal: Add first-class multi-language subtitle support to OfflineAcademy so one video lesson can expose multiple selectable subtitle tracks such as English, Spanish, Japanese, Filipino, Chinese, etc.
Current State: OfflineAcademy already detects one .srt or .vtt subtitle file whose base name exactly matches a video file, stores it in Lesson.subtitlePath, and renders one hardcoded English <track> in components/VideoPlayer.tsx.
Target State: OfflineAcademy detects multiple language-suffixed subtitle files per video, stores them as structured subtitle tracks, renders all tracks in the video player, and provides a clear subtitle selector in the existing player settings menu.
Tech Stack: Next.js App Router, React client components, Prisma, SQLite, native HTML video <track> elements, local course files served through /api/files/[...path].
1. User-Facing Behavior
Supported Folder Pattern
Preferred pattern:
My_Courses/
└── Course Name/
└── Module Name/
├── 01 - Lesson.mp4
├── 01 - Lesson.en.vtt
├── 01 - Lesson.es.vtt
├── 01 - Lesson.ja.vtt
└── 01 - Lesson.fil.vtt
Also accept single-file legacy pattern:
01 - Lesson.mp4
01 - Lesson.vtt
Legacy matching should continue to work and should be treated as English unless the implementation can confidently infer otherwise.
Supported Extensions
.vtt— preferred browser-native format..srt— detected by scanner; should either be served as-is for compatibility or converted/proxied to VTT before being used in<track>.
Language Naming Convention
Use language suffixes immediately before the subtitle extension:
<video base name>.<language-code>.<subtitle extension>
Examples:
lesson.en.vtt
lesson.es.vtt
lesson.fr.srt
lesson.zh.vtt
lesson.ja.vtt
lesson.fil.vtt
lesson.pt-BR.vtt
Language codes should be normalized to lowercase except region suffixes may remain readable if needed (pt-BR, zh-CN).
2. Acceptance Criteria
- A video can have more than one subtitle track.
- Scanner recognizes both legacy subtitles and language-suffixed subtitles.
- A video with
01 - Lesson.mp4,01 - Lesson.en.vtt, and01 - Lesson.es.vttgets two subtitle tracks. - The watch page passes all subtitle tracks to
VideoPlayer. - The video player renders one
<track>per subtitle. - The user can choose subtitle language or turn subtitles off from the player UI.
- Existing single subtitle files still work.
- Existing watch sidebar, bookmarks, speed selector, fullscreen, PiP, and tap-to-toggle behavior are not removed or regressed.
npm run buildpasses.- README and scan-page folder examples document the new pattern.
3. Recommended Data Model
Preferred Prisma Model
Add a new SubtitleTrack model rather than stuffing JSON into Lesson.
model Lesson {
id String @id @default(cuid())
// existing fields...
subtitlePath String? // keep temporarily for backwards compatibility
subtitles SubtitleTrack[]
}
model SubtitleTrack {
id String @id @default(cuid())
lessonId String
src String
lang String
label String
format String
isDefault Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
@@unique([lessonId, lang, src])
@@index([lessonId])
}
Why This Model
- Keeps one lesson to many subtitle tracks cleanly represented.
- Avoids fragile JSON parsing in Prisma query results.
- Allows future features: default language preference, uploaded subtitles, generated subtitles, subtitle source/provider, etc.
- Keeps
Lesson.subtitlePathtemporarily so existing data and old code do not break during migration.
4. File-by-File Implementation Map
Prisma / Database
Modify:
prisma/schema.prisma
Add:
SubtitleTrack model
Lesson.subtitles relation
Run:
npm run db:push
npx prisma generate
Verify:
npm run build
Scanner
Modify:
lib/scanner.ts
Current behavior:
const subtitleMap = new Map<string, string>()
subtitleMap.set(baseName, relativePath)
const subtitlePath = subtitleMap.get(baseName) || null
Target behavior:
type SubtitleTrackInput = {
src: string
lang: string
label: string
format: 'vtt' | 'srt'
isDefault: boolean
}
const subtitleMap = new Map<string, SubtitleTrackInput[]>()
Scanner should parse:
01 - Lesson.vtt -> base: 01 - Lesson, lang: en, label: English, default: true
01 - Lesson.en.vtt -> base: 01 - Lesson, lang: en, label: English, default: true
01 - Lesson.es.vtt -> base: 01 - Lesson, lang: es, label: Spanish
01 - Lesson.pt-BR.vtt -> base: 01 - Lesson, lang: pt-BR, label: Portuguese (Brazil)
When upserting a lesson:
- Keep setting
subtitlePathto the first/default track for backward compatibility. - Delete existing subtitle tracks for the lesson.
- Create new subtitle tracks for the detected files.
Important: do this after the lesson upsert returns the lesson ID. The current batch upsert transaction may need to be split or followed by another query.
Utility Function
Recommended new file:
lib/subtitles.ts
Suggested exports:
export type SubtitleTrackInput = {
src: string
lang: string
label: string
format: 'vtt' | 'srt'
isDefault: boolean
}
export function parseSubtitleFile(videoBaseName: string, subtitleFileName: string): SubtitleTrackInput | null
export function languageCodeToLabel(code: string): string
export function normalizeSubtitleLang(code: string): string
This keeps scanner logic readable and testable.
Watch Page Data Query
Modify likely files:
app/watch/[lessonId]/page.tsx
app/watch/[lessonId]/WatchPageClient.tsx
Make sure the Prisma query includes:
subtitles: {
orderBy: [
{ isDefault: 'desc' },
{ label: 'asc' },
],
}
Update client prop type from:
subtitlePath: string | null
to something like:
subtitles: Array<{
id: string
src: string
lang: string
label: string
format: string
isDefault: boolean
}>
Keep fallback behavior:
const subtitleTracks = lesson.subtitles?.length
? lesson.subtitles
: lesson.subtitlePath
? [{ src: lesson.subtitlePath, lang: 'en', label: 'English', isDefault: true }]
: []
Video Player
Modify:
components/VideoPlayer.tsx
Current prop:
subtitles?: string
Target prop:
type SubtitleTrack = {
src: string
srcLang: string
label: string
default?: boolean
}
subtitles?: SubtitleTrack[]
Render:
{subtitles?.map(track => (
<track
key={`${track.srcLang}-${track.src}`}
kind="subtitles"
src={track.src}
srcLang={track.srcLang}
label={track.label}
default={track.default}
/>
))}
Add subtitle selector inside existing settings menu:
Subtitles
- Off
- English
- Spanish
- Japanese
Use videoRef.current.textTracks to control active track:
function setSubtitleTrack(index: number | 'off') {
const tracks = videoRef.current?.textTracks
if (!tracks) return
for (let i = 0; i < tracks.length; i++) {
tracks[i].mode = index === i ? 'showing' : 'disabled'
}
}
Do not remove existing speed controls. Add subtitles below speed inside the same dropdown.
File Serving
Inspect:
app/api/files/[...path]/route.ts
Current MIME types:
srt: 'text/plain; charset=utf-8'
vtt: 'text/vtt; charset=utf-8'
For VTT, current behavior is fine.
For SRT, browsers may not display it reliably in <track>. Choose one of these approaches:
- Phase 1: document
.vttas preferred and keep.srtas best-effort. - Phase 2: add an API conversion route that converts SRT to VTT on the fly.
Recommended for first implementation: implement VTT fully and keep SRT backward-compatible/best-effort unless conversion is quick and tested.
5. Detailed Task Breakdown
Task 1: Add subtitle parsing utility
Objective: Create a testable helper that maps subtitle filenames to video base names and language metadata.
Files:
- Create:
lib/subtitles.ts - Create or modify tests if test setup exists. If no test harness exists, add a small script under
scripts/verify-subtitle-parser.ts.
Implementation notes:
- Parse only
.srtand.vtt. - Accept legacy exact base match:
lesson.vttforlesson.mp4. - Accept language suffix:
lesson.en.vtt,lesson.es.srt. - Reject unrelated files.
Verification:
npm run build
If script is added:
npx tsx scripts/verify-subtitle-parser.ts
Expected: parser examples pass.
Task 2: Add Prisma subtitle model
Objective: Store multiple subtitle tracks per lesson.
Files:
- Modify:
prisma/schema.prisma
Steps:
- Add
SubtitleTrackmodel. - Add
subtitles SubtitleTrack[]relation toLesson. - Keep
subtitlePath String?for backward compatibility. - Run:
npm run db:push
npx prisma generate
npm run build
Expected: Prisma client builds successfully and app compiles.
Task 3: Update scanner to detect multiple subtitle tracks
Objective: Replace single subtitleMap with many subtitle tracks per video.
Files:
- Modify:
lib/scanner.ts - Import helpers from:
lib/subtitles.ts
Steps:
- Build
Map<string, SubtitleTrackInput[]>. - For each subtitle file, compare against video base names.
- For each lesson, upsert lesson first.
- Delete existing
SubtitleTrackrows for that lesson. - Insert detected tracks.
- Set legacy
subtitlePathto default tracksrcornull.
Risk: Current lesson upserts are batched in a transaction. You may need to refactor to collect upsert results before creating subtitle tracks.
Verification:
Create a temporary course fixture outside the repo or in a safe ignored folder:
My_Courses/Test Subtitles/01 Module/01 Lesson.mp4
My_Courses/Test Subtitles/01 Module/01 Lesson.en.vtt
My_Courses/Test Subtitles/01 Module/01 Lesson.es.vtt
Run scan and verify DB has two subtitle tracks for the lesson.
Task 4: Update watch page query and props
Objective: Pass multiple subtitle tracks from server to client.
Files:
- Modify:
app/watch/[lessonId]/page.tsx - Modify:
app/watch/[lessonId]/WatchPageClient.tsx
Steps:
- Include
subtitlesrelation in Prisma query. - Update TypeScript lesson types.
- Convert DB tracks to video-player track props.
- Preserve fallback from
subtitlePath.
Verification:
npm run build
Expected: TypeScript passes.
Task 5: Update VideoPlayer to render and select subtitle tracks
Objective: Render multiple <track> elements and add a subtitle selector to the existing settings menu.
Files:
- Modify:
components/VideoPlayer.tsx
Steps:
- Replace
subtitles?: stringwithsubtitles?: SubtitleTrack[]. - Render one
<track>per subtitle. - Add state for selected subtitle track.
- Use
videoRef.current.textTracksto toggle track modes. - Add
Subtitlessection to the existing settings dropdown below Speed. - Include
Offoption.
Do not regress:
- Play/pause
- Speed selector
- Fullscreen
- PiP
- Keyboard shortcuts
- Tap-to-toggle
- Mobile layout
Verification:
npm run build
Manual browser check:
- Open a video with two
.vttsubtitle tracks. - Open settings menu.
- Select English.
- Select Spanish.
- Select Off.
Task 6: Update scan page docs and README examples
Objective: Explain subtitle naming clearly for users.
Files:
- Modify:
app/scan/page.tsx - Modify:
README.md
Add examples:
01 - Lesson.mp4
01 - Lesson.en.vtt
01 - Lesson.es.vtt
01 - Lesson.ja.vtt
Mention:
.vttis preferred..srtis detected but may be best-effort unless converted.- Language suffixes use ISO-like codes:
en,es,fr,ja,fil,zh-CN,pt-BR.
Verification:
npm run build
Task 7: Final verification and cleanup
Objective: Prove the feature is ready to merge.
Commands:
npm run build
git status --short
Optional live verification if the production test server is being used:
ss -tlnp | grep ':6767'
# restart only if user wants live testing
Manual checklist:
- Single legacy subtitle still works.
- Multiple VTT subtitles render and can be selected.
- SRT behavior is documented accurately.
- No watch sidebar regression.
- No donation/header changes.
- No Docker docs changed except if README subtitle docs are added.
6. Suggested Commit Sequence
Use small commits:
git commit -m "feat: add subtitle track parsing helpers"
git commit -m "feat: store multiple subtitle tracks per lesson"
git commit -m "feat: scan language-specific subtitle tracks"
git commit -m "feat: add subtitle selector to video player"
git commit -m "docs: document multilingual subtitle naming"
7. Implementation Warnings
- Do not remove
Lesson.subtitlePathimmediately; keep it as a compatibility bridge. - Do not import
fs/promises, Prisma, or scanner utilities intocomponents/VideoPlayer.tsx; it is a client component. - Do not assume
.srtwill render natively in all browsers. - Do not break the existing one-subtitle flow.
- Do not touch unrelated UI such as Ko-fi badges, header buttons, analytics, install/PWA leftovers, Docker compose files, or quiz flows.
- Do not use
window.promptor native alert flows for this feature.
8. Future Enhancements After MVP
These are intentionally out of scope for the first implementation:
- Automatic SRT-to-VTT conversion.
- User-level preferred subtitle language setting.
- Auto-generated subtitles/translations.
- Subtitle upload UI.
- Per-course subtitle language defaults.
- Subtitle style customization.
9. Human Summary
Today, OfflineAcademy can find one subtitle file per video. This feature branch is for making it understand many subtitle files per video, each with a language code, so the video player can let the user choose the subtitle language.
The simplest user workflow should become:
01 - Lesson.mp4
01 - Lesson.en.vtt
01 - Lesson.es.vtt
01 - Lesson.ja.vtt
Then scan the course, open the video, and pick the language from the player settings menu.