From 5b2be50852a7acc7c73ab0854c7b986737313de2 Mon Sep 17 00:00:00 2001 From: vincent Date: Sat, 29 Aug 2026 16:25:26 +0200 Subject: [PATCH] feat: subtitles while streaming, and fetched when a download lacks them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src-tauri/src/commands.rs | 78 ++++++++++++++++++++++++++++++- src-tauri/src/downloader.rs | 49 +++++++++++++++++++ src-tauri/src/lib.rs | 1 + src/App.tsx | 7 +-- src/api.ts | 5 ++ src/components/Player.tsx | 53 +++++++++++++++++++-- src/components/PlayerControls.tsx | 15 +++++- src/components/Settings.tsx | 9 ++-- src/components/format.ts | 13 ++++++ src/types.ts | 3 ++ 10 files changed, 221 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 3e539bb..aedcecb 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -14,7 +14,7 @@ use crate::thumbs; use futures::stream::StreamExt; use serde::Serialize; use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use tauri::{AppHandle, Emitter, Manager, State}; use tokio::io::{AsyncBufReadExt, BufReader}; @@ -1369,6 +1369,79 @@ pub async fn list_subtitles( Ok(out) } +/// Reads the WebVTT files in a directory as (language, text), newest naming +/// convention `..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 = 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, 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. #[tauri::command] pub async fn delete_all_downloads(state: State<'_, AppState>) -> Result { @@ -1386,6 +1459,9 @@ pub async fn delete_all_downloads(state: State<'_, AppState>) -> Result Vec { + 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. /// /// Only the language itself and YouTube's "-orig" variant; anything broader @@ -160,6 +186,29 @@ pub fn parse_final_path(line: &str) -> Option { mod tests { 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] fn parses_complete_line() { let p = parse_progress_line("FTPROG 1048576 10485760 524288.0 18").unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2278333..fa3cfe0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -160,6 +160,7 @@ fn build_menu(app: &tauri::AppHandle) -> tauri::Result> { commands::delete_download, commands::delete_all_downloads, commands::list_subtitles, + commands::fetch_subtitles, commands::fetch_durations, commands::list_browsers, commands::set_cookie_source, diff --git a/src/App.tsx b/src/App.tsx index 266e1ff..8ad3876 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,7 +16,8 @@ import { useDownloads } from "./hooks/useDownloads"; import { useFeed } from "./hooks/useFeed"; import { useWindowFullscreen } from "./hooks/useWindowFullscreen"; 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, } from "./types"; @@ -66,9 +67,9 @@ export default function App() { const [subLang, setSubLang] = useState(() => { try { 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 { - return "off"; + return DEFAULT_SUB_LANG; } }); const [streamQuality, setStreamQuality] = useState(() => { diff --git a/src/api.ts b/src/api.ts index ede81c8..1550bf0 100644 --- a/src/api.ts +++ b/src/api.ts @@ -54,6 +54,11 @@ export const updateYtDlp = () => invoke("update_yt_dlp"); export const listSubtitles = (videoId: string) => invoke>("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>("fetch_subtitles", { videoId, lang }); + export const savePlayback = (videoId: string, position: number, duration: number) => invoke("save_playback", { videoId, position, duration }); diff --git a/src/components/Player.tsx b/src/components/Player.tsx index 68bcfc1..3b35afd 100644 --- a/src/components/Player.tsx +++ b/src/components/Player.tsx @@ -1,7 +1,9 @@ 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 { compactViews, relativeTime } from "./format"; +import { compactViews, relativeTime, subtitleLabel } from "./format"; import PlayerControls from "./PlayerControls"; import { Badge, BTN, Spinner } from "./ui"; @@ -127,6 +129,41 @@ export default function Player({ cancelled = true; }; }, [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>([]); + 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. const [chromeVisible, setChromeVisible] = useState(true); const hideTimer = useRef(undefined); @@ -362,10 +399,19 @@ export default function Player({ key={file} kind="subtitles" srcLang={lang} - label={lang} + label={subtitleLabel(lang)} src={fileUrl(file)} /> ))} + {fetched.map(([lang, url]) => ( + + ))} ) : ( error && ( @@ -394,6 +440,7 @@ export default function Player({ onActivity={showChrome} subLang={subLang} onSubLang={onSubLang} + subsLoading={fetchingSubs} /> )} diff --git a/src/components/PlayerControls.tsx b/src/components/PlayerControls.tsx index 661507f..264499c 100644 --- a/src/components/PlayerControls.tsx +++ b/src/components/PlayerControls.tsx @@ -38,6 +38,8 @@ interface Props { subLang: string; /** Persists a choice made here, so the next video matches. */ onSubLang: (l: string) => void; + /** Captions are being fetched, so the menu is not empty for lack of any. */ + subsLoading?: boolean; } const SKIP_S = 10; @@ -64,7 +66,7 @@ const btn = * bottom, so the native ones are switched off entirely. */ export default function PlayerControls({ - videoRef, stageRef, onActivity, subLang, onSubLang, + videoRef, stageRef, onActivity, subLang, onSubLang, subsLoading, }: Props) { const [playing, setPlaying] = useState(false); 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) && (