mirror of
https://github.com/nicetry247/offlineacademy.git
synced 2026-07-26 11:58:44 +00:00
docs: plan multilingual subtitle support
This commit is contained in:
@@ -0,0 +1,644 @@
|
||||
# Multi-Language Subtitles Implementation Plan
|
||||
|
||||
> **For Hermes / future AI assistants:** Use `offlineacademy-dev`, `writing-plans`, and `test-driven-development` before implementing this plan. Implement task-by-task on branch `feature/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:
|
||||
|
||||
```text
|
||||
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:
|
||||
|
||||
```text
|
||||
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:
|
||||
|
||||
```text
|
||||
<video base name>.<language-code>.<subtitle extension>
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
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`, and `01 - Lesson.es.vtt` gets 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 build` passes.
|
||||
- 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`.
|
||||
|
||||
```prisma
|
||||
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.subtitlePath` temporarily so existing data and old code do not break during migration.
|
||||
|
||||
---
|
||||
|
||||
## 4. File-by-File Implementation Map
|
||||
|
||||
### Prisma / Database
|
||||
|
||||
Modify:
|
||||
|
||||
```text
|
||||
prisma/schema.prisma
|
||||
```
|
||||
|
||||
Add:
|
||||
|
||||
```text
|
||||
SubtitleTrack model
|
||||
Lesson.subtitles relation
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run db:push
|
||||
npx prisma generate
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Scanner
|
||||
|
||||
Modify:
|
||||
|
||||
```text
|
||||
lib/scanner.ts
|
||||
```
|
||||
|
||||
Current behavior:
|
||||
|
||||
```ts
|
||||
const subtitleMap = new Map<string, string>()
|
||||
subtitleMap.set(baseName, relativePath)
|
||||
const subtitlePath = subtitleMap.get(baseName) || null
|
||||
```
|
||||
|
||||
Target behavior:
|
||||
|
||||
```ts
|
||||
type SubtitleTrackInput = {
|
||||
src: string
|
||||
lang: string
|
||||
label: string
|
||||
format: 'vtt' | 'srt'
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
const subtitleMap = new Map<string, SubtitleTrackInput[]>()
|
||||
```
|
||||
|
||||
Scanner should parse:
|
||||
|
||||
```text
|
||||
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:
|
||||
|
||||
1. Keep setting `subtitlePath` to the first/default track for backward compatibility.
|
||||
2. Delete existing subtitle tracks for the lesson.
|
||||
3. 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:
|
||||
|
||||
```text
|
||||
lib/subtitles.ts
|
||||
```
|
||||
|
||||
Suggested exports:
|
||||
|
||||
```ts
|
||||
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:
|
||||
|
||||
```text
|
||||
app/watch/[lessonId]/page.tsx
|
||||
app/watch/[lessonId]/WatchPageClient.tsx
|
||||
```
|
||||
|
||||
Make sure the Prisma query includes:
|
||||
|
||||
```ts
|
||||
subtitles: {
|
||||
orderBy: [
|
||||
{ isDefault: 'desc' },
|
||||
{ label: 'asc' },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
Update client prop type from:
|
||||
|
||||
```ts
|
||||
subtitlePath: string | null
|
||||
```
|
||||
|
||||
to something like:
|
||||
|
||||
```ts
|
||||
subtitles: Array<{
|
||||
id: string
|
||||
src: string
|
||||
lang: string
|
||||
label: string
|
||||
format: string
|
||||
isDefault: boolean
|
||||
}>
|
||||
```
|
||||
|
||||
Keep fallback behavior:
|
||||
|
||||
```ts
|
||||
const subtitleTracks = lesson.subtitles?.length
|
||||
? lesson.subtitles
|
||||
: lesson.subtitlePath
|
||||
? [{ src: lesson.subtitlePath, lang: 'en', label: 'English', isDefault: true }]
|
||||
: []
|
||||
```
|
||||
|
||||
### Video Player
|
||||
|
||||
Modify:
|
||||
|
||||
```text
|
||||
components/VideoPlayer.tsx
|
||||
```
|
||||
|
||||
Current prop:
|
||||
|
||||
```ts
|
||||
subtitles?: string
|
||||
```
|
||||
|
||||
Target prop:
|
||||
|
||||
```ts
|
||||
type SubtitleTrack = {
|
||||
src: string
|
||||
srcLang: string
|
||||
label: string
|
||||
default?: boolean
|
||||
}
|
||||
|
||||
subtitles?: SubtitleTrack[]
|
||||
```
|
||||
|
||||
Render:
|
||||
|
||||
```tsx
|
||||
{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:
|
||||
|
||||
```text
|
||||
Subtitles
|
||||
- Off
|
||||
- English
|
||||
- Spanish
|
||||
- Japanese
|
||||
```
|
||||
|
||||
Use `videoRef.current.textTracks` to control active track:
|
||||
|
||||
```ts
|
||||
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:
|
||||
|
||||
```text
|
||||
app/api/files/[...path]/route.ts
|
||||
```
|
||||
|
||||
Current MIME types:
|
||||
|
||||
```ts
|
||||
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:
|
||||
|
||||
1. **Phase 1:** document `.vtt` as preferred and keep `.srt` as best-effort.
|
||||
2. **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 `.srt` and `.vtt`.
|
||||
- Accept legacy exact base match: `lesson.vtt` for `lesson.mp4`.
|
||||
- Accept language suffix: `lesson.en.vtt`, `lesson.es.srt`.
|
||||
- Reject unrelated files.
|
||||
|
||||
**Verification:**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
If script is added:
|
||||
|
||||
```bash
|
||||
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:**
|
||||
|
||||
1. Add `SubtitleTrack` model.
|
||||
2. Add `subtitles SubtitleTrack[]` relation to `Lesson`.
|
||||
3. Keep `subtitlePath String?` for backward compatibility.
|
||||
4. Run:
|
||||
|
||||
```bash
|
||||
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:**
|
||||
|
||||
1. Build `Map<string, SubtitleTrackInput[]>`.
|
||||
2. For each subtitle file, compare against video base names.
|
||||
3. For each lesson, upsert lesson first.
|
||||
4. Delete existing `SubtitleTrack` rows for that lesson.
|
||||
5. Insert detected tracks.
|
||||
6. Set legacy `subtitlePath` to default track `src` or `null`.
|
||||
|
||||
**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:
|
||||
|
||||
```text
|
||||
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:**
|
||||
|
||||
1. Include `subtitles` relation in Prisma query.
|
||||
2. Update TypeScript lesson types.
|
||||
3. Convert DB tracks to video-player track props.
|
||||
4. Preserve fallback from `subtitlePath`.
|
||||
|
||||
**Verification:**
|
||||
|
||||
```bash
|
||||
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:**
|
||||
|
||||
1. Replace `subtitles?: string` with `subtitles?: SubtitleTrack[]`.
|
||||
2. Render one `<track>` per subtitle.
|
||||
3. Add state for selected subtitle track.
|
||||
4. Use `videoRef.current.textTracks` to toggle track modes.
|
||||
5. Add `Subtitles` section to the existing settings dropdown below Speed.
|
||||
6. Include `Off` option.
|
||||
|
||||
**Do not regress:**
|
||||
|
||||
- Play/pause
|
||||
- Speed selector
|
||||
- Fullscreen
|
||||
- PiP
|
||||
- Keyboard shortcuts
|
||||
- Tap-to-toggle
|
||||
- Mobile layout
|
||||
|
||||
**Verification:**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Manual browser check:
|
||||
|
||||
- Open a video with two `.vtt` subtitle 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:**
|
||||
|
||||
```text
|
||||
01 - Lesson.mp4
|
||||
01 - Lesson.en.vtt
|
||||
01 - Lesson.es.vtt
|
||||
01 - Lesson.ja.vtt
|
||||
```
|
||||
|
||||
**Mention:**
|
||||
|
||||
- `.vtt` is preferred.
|
||||
- `.srt` is detected but may be best-effort unless converted.
|
||||
- Language suffixes use ISO-like codes: `en`, `es`, `fr`, `ja`, `fil`, `zh-CN`, `pt-BR`.
|
||||
|
||||
**Verification:**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Final verification and cleanup
|
||||
|
||||
**Objective:** Prove the feature is ready to merge.
|
||||
|
||||
**Commands:**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
git status --short
|
||||
```
|
||||
|
||||
Optional live verification if the production test server is being used:
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
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.subtitlePath` immediately; keep it as a compatibility bridge.
|
||||
- Do not import `fs/promises`, Prisma, or scanner utilities into `components/VideoPlayer.tsx`; it is a client component.
|
||||
- Do not assume `.srt` will 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.prompt` or 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:
|
||||
|
||||
```text
|
||||
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.
|
||||
Reference in New Issue
Block a user