feat: subtitles while streaming, and fetched when a download lacks them

YouTube's HLS manifest carries a dozen audio renditions and no
subtitles whatsoever — checked against a live master playlist: 16
EXT-X-MEDIA entries, all TYPE=AUDIO, zero SUBTITLES. So the track menu
could only ever list audio while streaming, which is what it did.

Subtitles are now fetched separately with yt-dlp, tidied through the
existing VTT cleanup, and cached per video and language. They reach the
player as blob URLs, which share the document's origin — a file:// or
127.0.0.1 track would be cross-origin to the page and need CORS the
media pipeline cannot supply. The same path fills in a download saved
before subtitles were switched on, without fetching the video again.

YouTube serves identical auto-generated captions under both "en" and
"en-orig", so byte-identical texts collapse to one entry rather than
offering the same track twice, and tracks are labelled "English"
rather than "en".

The subtitle preference now defaults to English. "None" is a poor
default for a setting whose whole purpose is captions: it silently
means no subtitles are downloaded, fetched, or offered anywhere, and
the Settings text now says so.
This commit is contained in:
vincent
2026-08-29 16:25:26 +02:00
parent 66cb80a044
commit 5b2be50852
10 changed files with 221 additions and 12 deletions
+77 -1
View File
@@ -14,7 +14,7 @@ use crate::thumbs;
use futures::stream::StreamExt; use futures::stream::StreamExt;
use serde::Serialize; use serde::Serialize;
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager, State}; use tauri::{AppHandle, Emitter, Manager, State};
use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::io::{AsyncBufReadExt, BufReader};
@@ -1369,6 +1369,79 @@ pub async fn list_subtitles(
Ok(out) Ok(out)
} }
/// Reads the WebVTT files in a directory as (language, text), newest naming
/// convention `<id>.<lang>.vtt`. Identical texts are collapsed: YouTube serves
/// the same auto-generated captions under both `en` and `en-orig`, and offering
/// the same track twice is just noise in the menu.
async fn read_vtt_dir(dir: &Path) -> Vec<(String, String)> {
let Ok(mut entries) = tokio::fs::read_dir(dir).await else {
return Vec::new();
};
let mut out: Vec<(String, String)> = Vec::new();
while let Ok(Some(entry)) = entries.next_entry().await {
let name = entry.file_name().to_string_lossy().to_string();
let Some(stem) = name.strip_suffix(".vtt") else { continue };
let Some((_, lang)) = stem.rsplit_once('.') else { continue };
let Ok(text) = tokio::fs::read_to_string(entry.path()).await else {
continue;
};
out.push((lang.to_string(), tidy_vtt(&text)));
}
// Sorting first makes the survivor of a duplicate the plainer code: "en"
// rather than "en-orig".
out.sort();
let mut seen: Vec<String> = Vec::new();
out.retain(|(_, text)| {
if seen.iter().any(|t| t == text) {
false
} else {
seen.push(text.clone());
true
}
});
out
}
/// Subtitles for a video, as (language, WebVTT text).
///
/// Streaming has no other source: YouTube's HLS manifest lists a dozen audio
/// renditions and no subtitles at all. A download saved before subtitles were
/// switched on has none beside it either, and this fills those in without
/// fetching the video again. Results are cached per video and language, so
/// replaying something costs nothing and works offline.
#[tauri::command]
pub async fn fetch_subtitles(
video_id: String,
lang: String,
state: State<'_, AppState>,
) -> Result<Vec<(String, String)>, String> {
let sub_langs = downloader::sub_langs_for(&lang);
if sub_langs.is_empty() {
return Ok(Vec::new());
}
let dir = state.app_data.join("subs").join(&video_id).join(&lang);
let cached = read_vtt_dir(&dir).await;
if !cached.is_empty() {
return Ok(cached);
}
tokio::fs::create_dir_all(&dir)
.await
.map_err(|e| format!("Cannot create subtitle cache: {e}"))?;
let template = dir.join("%(id)s.%(ext)s").to_string_lossy().to_string();
let mut args = downloader::subs_only_args(&video_id, &template, &sub_langs);
args.push("--ffmpeg-location".into());
args.push(bin("ffmpeg"));
// A video with no captions in this language is an ordinary outcome, not a
// failure worth reporting, so the exit status is not consulted: whatever
// landed on disk is the answer.
let _ = state.yt_dlp().await.args(&args).output().await;
Ok(read_vtt_dir(&dir).await)
}
/// Removes every download and the files behind them, including subtitles. /// Removes every download and the files behind them, including subtitles.
#[tauri::command] #[tauri::command]
pub async fn delete_all_downloads(state: State<'_, AppState>) -> Result<usize, String> { pub async fn delete_all_downloads(state: State<'_, AppState>) -> Result<usize, String> {
@@ -1386,6 +1459,9 @@ pub async fn delete_all_downloads(state: State<'_, AppState>) -> Result<usize, S
} }
} }
} }
// Fetched captions live in the cache rather than beside the video, so they
// would otherwise survive a wipe.
let _ = tokio::fs::remove_dir_all(state.app_data.join("subs")).await;
state.db.lock().await.clear_all_downloads() state.db.lock().await.clear_all_downloads()
} }
+49
View File
@@ -135,6 +135,32 @@ pub fn build_args(
args args
} }
/// Arguments for fetching only the subtitles of a video.
///
/// YouTube's HLS manifest carries audio renditions but no subtitles whatsoever,
/// so a streamed video has nothing to show unless the captions are fetched
/// separately. Auto-generated captions are included: on most videos they are
/// the only ones there are.
pub fn subs_only_args(video_id: &str, out_template: &str, sub_langs: &str) -> Vec<String> {
vec![
"--skip-download".into(),
"--no-playlist".into(),
"--no-colors".into(),
"--ignore-errors".into(),
"--write-subs".into(),
"--write-auto-subs".into(),
"--sub-format".into(),
"vtt".into(),
"--convert-subs".into(),
"vtt".into(),
"--sub-langs".into(),
sub_langs.to_string(),
"-o".into(),
out_template.into(),
format!("https://www.youtube.com/watch?v={video_id}"),
]
}
/// The subtitle languages to request for a preference, or empty for none. /// The subtitle languages to request for a preference, or empty for none.
/// ///
/// Only the language itself and YouTube's "-orig" variant; anything broader /// Only the language itself and YouTube's "-orig" variant; anything broader
@@ -160,6 +186,29 @@ pub fn parse_final_path(line: &str) -> Option<String> {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn subs_only_args_take_auto_generated_captions() {
let args = subs_only_args("abc", "/tmp/%(id)s.%(ext)s", "en,en-orig");
// Most videos have no hand-written captions at all; without this the
// fetch comes back empty.
assert!(args.iter().any(|a| a == "--write-auto-subs"));
assert!(args.iter().any(|a| a == "--write-subs"));
assert!(args.iter().any(|a| a == "--skip-download"));
// Named exactly: a wildcard drags in dozens of machine translations.
let langs = args.iter().position(|a| a == "--sub-langs").unwrap();
assert_eq!(args[langs + 1], "en,en-orig");
assert_eq!(args.last().unwrap(), "https://www.youtube.com/watch?v=abc");
}
#[test]
fn subs_only_args_are_written_where_asked() {
let args = subs_only_args("abc", "/cache/%(id)s.%(ext)s", "nl,nl-orig");
let out = args.iter().position(|a| a == "-o").unwrap();
assert_eq!(args[out + 1], "/cache/%(id)s.%(ext)s");
}
use super::*;
#[test] #[test]
fn parses_complete_line() { fn parses_complete_line() {
let p = parse_progress_line("FTPROG 1048576 10485760 524288.0 18").unwrap(); let p = parse_progress_line("FTPROG 1048576 10485760 524288.0 18").unwrap();
+1
View File
@@ -160,6 +160,7 @@ fn build_menu(app: &tauri::AppHandle) -> tauri::Result<Menu<tauri::Wry>> {
commands::delete_download, commands::delete_download,
commands::delete_all_downloads, commands::delete_all_downloads,
commands::list_subtitles, commands::list_subtitles,
commands::fetch_subtitles,
commands::fetch_durations, commands::fetch_durations,
commands::list_browsers, commands::list_browsers,
commands::set_cookie_source, commands::set_cookie_source,
+4 -3
View File
@@ -16,7 +16,8 @@ import { useDownloads } from "./hooks/useDownloads";
import { useFeed } from "./hooks/useFeed"; import { useFeed } from "./hooks/useFeed";
import { useWindowFullscreen } from "./hooks/useWindowFullscreen"; import { useWindowFullscreen } from "./hooks/useWindowFullscreen";
import { import {
BULK_LIMITS, DEFAULT_BULK_LIMIT, QUALITIES, STREAM_QUALITIES, SUB_LANGS, BULK_LIMITS, DEFAULT_BULK_LIMIT, DEFAULT_SUB_LANG,
QUALITIES, STREAM_QUALITIES, SUB_LANGS,
type FeedFilter, type FeedItem, type Quality, type RefreshProgress, type FeedFilter, type FeedItem, type Quality, type RefreshProgress,
} from "./types"; } from "./types";
@@ -66,9 +67,9 @@ export default function App() {
const [subLang, setSubLang] = useState(() => { const [subLang, setSubLang] = useState(() => {
try { try {
const stored = localStorage.getItem("flighttube.subLang"); const stored = localStorage.getItem("flighttube.subLang");
return SUB_LANGS.some((l) => l.value === stored) ? stored! : "off"; return SUB_LANGS.some((l) => l.value === stored) ? stored! : DEFAULT_SUB_LANG;
} catch { } catch {
return "off"; return DEFAULT_SUB_LANG;
} }
}); });
const [streamQuality, setStreamQuality] = useState<Quality>(() => { const [streamQuality, setStreamQuality] = useState<Quality>(() => {
+5
View File
@@ -54,6 +54,11 @@ export const updateYtDlp = () => invoke<string>("update_yt_dlp");
export const listSubtitles = (videoId: string) => export const listSubtitles = (videoId: string) =>
invoke<Array<[string, string]>>("list_subtitles", { videoId }); invoke<Array<[string, string]>>("list_subtitles", { videoId });
/** Subtitles as (language, WebVTT text) — fetched and cached when there are
* none beside the video. Streaming has no other source. */
export const fetchSubtitles = (videoId: string, lang: string) =>
invoke<Array<[string, string]>>("fetch_subtitles", { videoId, lang });
export const savePlayback = (videoId: string, position: number, duration: number) => export const savePlayback = (videoId: string, position: number, duration: number) =>
invoke<void>("save_playback", { videoId, position, duration }); invoke<void>("save_playback", { videoId, position, duration });
+50 -3
View File
@@ -1,7 +1,9 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { fileUrl, listSubtitles, openExternal, resolveStream, savePlayback } from "../api"; import {
fetchSubtitles, fileUrl, listSubtitles, openExternal, resolveStream, savePlayback,
} from "../api";
import type { FeedItem } from "../types"; import type { FeedItem } from "../types";
import { compactViews, relativeTime } from "./format"; import { compactViews, relativeTime, subtitleLabel } from "./format";
import PlayerControls from "./PlayerControls"; import PlayerControls from "./PlayerControls";
import { Badge, BTN, Spinner } from "./ui"; import { Badge, BTN, Spinner } from "./ui";
@@ -127,6 +129,41 @@ export default function Player({
cancelled = true; cancelled = true;
}; };
}, [item.id, path]); }, [item.id, path]);
// Captions fetched on demand, as (language, blob URL). YouTube's HLS
// manifest carries a dozen audio renditions and no subtitles at all, so a
// stream has nothing to show without this; a download saved before subtitles
// were switched on is in the same position. Blob URLs share the document's
// origin, which a file:// or 127.0.0.1 track would not.
const [fetched, setFetched] = useState<Array<[string, string]>>([]);
const [fetchingSubs, setFetchingSubs] = useState(false);
useEffect(() => {
setFetched([]);
if (subLang === "off" || sidecars.length > 0) return;
const urls: string[] = [];
let cancelled = false;
setFetchingSubs(true);
fetchSubtitles(item.id, subLang)
.then((list) => {
if (cancelled) return;
setFetched(
list.map(([lang, text]) => {
const url = URL.createObjectURL(new Blob([text], { type: "text/vtt" }));
urls.push(url);
return [lang, url] as [string, string];
}),
);
})
.catch(() => {
/* no captions in this language is an ordinary outcome */
})
.finally(() => !cancelled && setFetchingSubs(false));
return () => {
cancelled = true;
for (const u of urls) URL.revokeObjectURL(u);
};
}, [item.id, subLang, sidecars.length]);
// Controls and edge arrows fade away while you are just watching. // Controls and edge arrows fade away while you are just watching.
const [chromeVisible, setChromeVisible] = useState(true); const [chromeVisible, setChromeVisible] = useState(true);
const hideTimer = useRef<number | undefined>(undefined); const hideTimer = useRef<number | undefined>(undefined);
@@ -362,10 +399,19 @@ export default function Player({
key={file} key={file}
kind="subtitles" kind="subtitles"
srcLang={lang} srcLang={lang}
label={lang} label={subtitleLabel(lang)}
src={fileUrl(file)} src={fileUrl(file)}
/> />
))} ))}
{fetched.map(([lang, url]) => (
<track
key={url}
kind="subtitles"
srcLang={lang}
label={subtitleLabel(lang)}
src={url}
/>
))}
</video> </video>
) : ( ) : (
error && ( error && (
@@ -394,6 +440,7 @@ export default function Player({
onActivity={showChrome} onActivity={showChrome}
subLang={subLang} subLang={subLang}
onSubLang={onSubLang} onSubLang={onSubLang}
subsLoading={fetchingSubs}
/> />
</div> </div>
)} )}
+13 -2
View File
@@ -38,6 +38,8 @@ interface Props {
subLang: string; subLang: string;
/** Persists a choice made here, so the next video matches. */ /** Persists a choice made here, so the next video matches. */
onSubLang: (l: string) => void; onSubLang: (l: string) => void;
/** Captions are being fetched, so the menu is not empty for lack of any. */
subsLoading?: boolean;
} }
const SKIP_S = 10; const SKIP_S = 10;
@@ -64,7 +66,7 @@ const btn =
* bottom, so the native ones are switched off entirely. * bottom, so the native ones are switched off entirely.
*/ */
export default function PlayerControls({ export default function PlayerControls({
videoRef, stageRef, onActivity, subLang, onSubLang, videoRef, stageRef, onActivity, subLang, onSubLang, subsLoading,
}: Props) { }: Props) {
const [playing, setPlaying] = useState(false); const [playing, setPlaying] = useState(false);
const [time, setTime] = useState(0); const [time, setTime] = useState(0);
@@ -331,7 +333,7 @@ export default function PlayerControls({
}} }}
/> />
{(audio.length > 1 || subs.length > 0) && ( {(audio.length > 1 || subs.length > 0 || subsLoading) && (
<div ref={menuRef} className="relative shrink-0"> <div ref={menuRef} className="relative shrink-0">
<button <button
onClick={() => { setMenu((m) => !m); onActivity?.(); }} onClick={() => { setMenu((m) => !m); onActivity?.(); }}
@@ -370,6 +372,15 @@ export default function PlayerControls({
</> </>
)} )}
{subs.length === 0 && subsLoading && (
<>
<div className="mt-1 px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest text-slate-400">
Subtitles
</div>
<div className="px-2 py-1.5 text-[12px] text-slate-400">Fetching</div>
</>
)}
{subs.length > 0 && ( {subs.length > 0 && (
<> <>
<div className="mt-1 px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest text-slate-400"> <div className="mt-1 px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest text-slate-400">
+6 -3
View File
@@ -276,9 +276,12 @@ export default function Settings({
</select> </select>
</label> </label>
<p className={`mt-2 ${HELP}`}> <p className={`mt-2 ${HELP}`}>
Shown automatically when a video has subtitles in this language, including Shown whenever a video has subtitles in this language, including YouTube's
YouTube's auto-generated ones, and saved alongside anything you download so auto-generated ones on most videos those are the only ones there are.
they work offline. You can still switch tracks from the player. Downloads keep them beside the file for offline use; streams fetch them
separately, because YouTube's live manifest carries no subtitles at all.
<b> None means no subtitles anywhere.</b> You can still switch tracks from
the player.
</p> </p>
</section> </section>
+13
View File
@@ -1,3 +1,5 @@
import { SUB_LANGS } from "../types";
export function relativeTime(unixSeconds: number): string { export function relativeTime(unixSeconds: number): string {
if (!unixSeconds) return ""; if (!unixSeconds) return "";
const diff = Date.now() / 1000 - unixSeconds; const diff = Date.now() / 1000 - unixSeconds;
@@ -51,3 +53,14 @@ export function clockDuration(seconds: number | null): string {
const mm = h > 0 ? String(m).padStart(2, "0") : String(m); const mm = h > 0 ? String(m).padStart(2, "0") : String(m);
return `${h > 0 ? `${h}:` : ""}${mm}:${String(s).padStart(2, "0")}`; return `${h > 0 ? `${h}:` : ""}${mm}:${String(s).padStart(2, "0")}`;
} }
/**
* A readable name for a subtitle track. YouTube's "-orig" variant is the
* captions in the video's own language, as opposed to a machine translation.
*/
export function subtitleLabel(code: string): string {
const base = code.replace(/-orig$/, "");
const named = SUB_LANGS.find((l) => l.value === base.toLowerCase());
const name = named?.label ?? base.toUpperCase();
return code.endsWith("-orig") ? `${name} (original)` : name;
}
+3
View File
@@ -128,6 +128,9 @@ export const BULK_LIMITS: Array<{ value: number; label: string }> = [
export const DEFAULT_BULK_LIMIT = 25; export const DEFAULT_BULK_LIMIT = 25;
/** Preferred subtitle language: shown when available, and downloaded. */ /** Preferred subtitle language: shown when available, and downloaded. */
/** On by default: "None" means no captions anywhere, which is rarely wanted. */
export const DEFAULT_SUB_LANG = "en";
export const SUB_LANGS: Array<{ value: string; label: string }> = [ export const SUB_LANGS: Array<{ value: string; label: string }> = [
{ value: "off", label: "None" }, { value: "off", label: "None" },
{ value: "en", label: "English" }, { value: "en", label: "English" },