From 7d0f976da0945fa5d31757534a45060cb6e77910 Mon Sep 17 00:00:00 2001 From: Nice Try Date: Fri, 26 Jun 2026 20:12:48 +0000 Subject: [PATCH] feat: multi-language subtitle support (SRT/VTT) --- README.md | 2 +- app/api/files/[...path]/route.ts | 19 ++++++++++++++++++- components/VideoPlayer.tsx | 28 ++++++++++++++++++++++++++++ lib/subtitles.ts | 26 ++++++++++++++++++++++++++ public/sw.js | 22 +++++++++++++++++++--- scripts/verify-subtitle-parser.ts | 7 ++++++- 6 files changed, 98 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a24d283..9e58afe 100644 --- a/README.md +++ b/README.md @@ -457,7 +457,7 @@ Supported video extensions include common browser-playable formats such as `.mp4 ### 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. +OfflineAcademy supports subtitle files stored beside the matching video. `.vtt` files are served directly; `.srt` files are detected and converted to browser-compatible WebVTT when served to the video player. Single subtitle track: diff --git a/app/api/files/[...path]/route.ts b/app/api/files/[...path]/route.ts index c42031a..fabe051 100755 --- a/app/api/files/[...path]/route.ts +++ b/app/api/files/[...path]/route.ts @@ -1,8 +1,9 @@ import { NextRequest, NextResponse } from 'next/server' -import { prisma } from '@/lib/prisma' import { existsSync, statSync, createReadStream } from 'fs' +import { readFile } from 'fs/promises' import { join, resolve } from 'path' import { getCoursesRootPath } from '@/lib/scanner' +import { convertSrtToVtt } from '@/lib/subtitles' export async function GET( request: NextRequest, @@ -79,6 +80,22 @@ export async function GET( txt: 'text/plain; charset=utf-8', } const contentType = mimeTypes[actualExt || ''] || 'application/octet-stream' + + // Browsers expect WebVTT for . Serve .srt subtitles as converted WebVTT. + if (actualExt === 'srt') { + const srtContent = await readFile(actualPath, 'utf8') + const vttContent = convertSrtToVtt(srtContent) + return new NextResponse(vttContent, { + status: 200, + headers: { + 'Content-Length': Buffer.byteLength(vttContent, 'utf8').toString(), + 'Content-Type': 'text/vtt; charset=utf-8', + 'Cache-Control': 'no-cache', + 'Content-Disposition': `inline; filename="${actualPath.split('/').pop()?.replace(/\.srt$/i, '.vtt')}"`, + 'X-Content-Type-Options': 'nosniff', + }, + }) + } // Handle range requests (video seeking) if (range) { diff --git a/components/VideoPlayer.tsx b/components/VideoPlayer.tsx index 7a5d1c8..4eb5ab7 100755 --- a/components/VideoPlayer.tsx +++ b/components/VideoPlayer.tsx @@ -410,6 +410,18 @@ export function VideoPlayer({ } }, [selectedSubtitle, subtitles]) + const hasSubtitles = Boolean(subtitles && subtitles.length > 0) + const captionsEnabled = selectedSubtitle !== 'off' + const toggleCaptions = () => { + if (!hasSubtitles) return + if (captionsEnabled) { + setSelectedSubtitle('off') + return + } + const defaultIndex = subtitles?.findIndex(track => track.default) ?? -1 + setSelectedSubtitle(defaultIndex >= 0 ? defaultIndex : 0) + } + return (
+ {hasSubtitles && ( + + )} + {/* Fullscreen - FIRST */}