diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 7b001ef..70f1df4 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1405,6 +1405,80 @@ async fn read_vtt_dir(dir: &Path) -> Vec<(String, String)> { out } +/// Subtitles muxed into a downloaded file, as (language, WebVTT text). +/// +/// The file keeps them, but the player does not use the element's own in-band +/// rendering: WebKit hands those to the media pipeline, which places them +/// wherever the container's text box says — bottom right, in practice — and no +/// CSS can reach them. Read out as WebVTT they are ordinary cues, centred and +/// styleable like any other. Reading one costs about 40ms and no re-encoding. +#[tauri::command] +pub async fn embedded_subtitles( + video_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let Some(path) = state.db.lock().await.get_download_path(&video_id)? else { + return Ok(Vec::new()); + }; + if tokio::fs::metadata(&path).await.is_err() { + return Ok(Vec::new()); + } + + let probe = tokio::process::Command::new(bin("ffprobe")) + .args([ + "-v", "error", + "-select_streams", "s", + "-show_entries", "stream=index:stream_tags=language", + "-of", "csv=p=0", + &path, + ]) + .output() + .await + .map_err(|e| format!("Could not read the file: {e}"))?; + + let mut out: Vec<(String, String)> = Vec::new(); + // ffprobe prints "," per subtitle stream, and the index is + // the absolute stream index — the mapping below counts subtitle streams, so + // position in this list is what matters, not the number printed. + for (nth, line) in String::from_utf8_lossy(&probe.stdout).lines().enumerate() { + let lang = line + .split(',') + .nth(1) + .map(|l| l.trim()) + .filter(|l| !l.is_empty() && *l != "und") + .unwrap_or("en") + .to_string(); + let dump = tokio::process::Command::new(bin("ffmpeg")) + .args([ + "-v", "error", + "-i", &path, + "-map", &format!("0:s:{nth}"), + "-f", "webvtt", + "-", + ]) + .output() + .await; + let Ok(dump) = dump else { continue }; + let text = String::from_utf8_lossy(&dump.stdout).to_string(); + if text.contains("-->") { + // ISO 639-2 is what a container stores; the player labels by the + // two-letter code everything else uses. + let short = match lang.as_str() { + "eng" => "en", + "nld" | "dut" => "nl", + "deu" | "ger" => "de", + "fra" | "fre" => "fr", + "spa" => "es", + "ita" => "it", + "por" => "pt", + other => other, + }; + out.push((short.to_string(), tidy_vtt(&text))); + } + } + Ok(out) +} + /// Subtitles for a video, as (language, WebVTT text). /// /// Streaming has no other source: YouTube's HLS manifest lists a dozen audio diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ce28b3e..acb1307 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -163,6 +163,7 @@ fn build_menu(app: &tauri::AppHandle) -> tauri::Result> { commands::delete_all_downloads, commands::list_subtitles, commands::fetch_subtitles, + commands::embedded_subtitles, commands::fetch_durations, commands::list_browsers, commands::set_cookie_source, diff --git a/src/api.ts b/src/api.ts index 1fbfc4e..f87acb8 100644 --- a/src/api.ts +++ b/src/api.ts @@ -61,6 +61,10 @@ export const updateYtDlp = () => invoke("update_yt_dlp"); export const listSubtitles = (videoId: string) => invoke>("list_subtitles", { videoId }); +/** Subtitles read out of a downloaded file, as (language, WebVTT text). */ +export const embeddedSubtitles = (videoId: string) => + invoke>("embedded_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) => diff --git a/src/components/Player.tsx b/src/components/Player.tsx index 61d21a1..f0891b0 100644 --- a/src/components/Player.tsx +++ b/src/components/Player.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { - fetchSubtitles, fileUrl, listSubtitles, openExternal, resolveStream, savePlayback, + embeddedSubtitles, fetchSubtitles, fileUrl, listSubtitles, openExternal, resolveStream, + savePlayback, } from "../api"; import { DEFAULT_SUB_LANG, type FeedItem } from "../types"; import { compactViews, relativeTime, subtitleLabel } from "./format"; @@ -143,43 +144,40 @@ export default function Player({ setTracks([]); setFetchingSubs(false); - // A download made since subtitles became embedded carries them inside the - // file, where the element exposes them itself. Give it until the metadata - // is parsed to say so before going to the network for something already on - // disk — which would also fail offline. - const hasEmbedded = async () => { - const v = videoRef.current; - if (!v) return false; - for (let i = 0, settled = 0; i < 20 && settled < 3; i++) { - if (v.textTracks.length > 0) return true; - if (v.readyState >= 1) settled++; - await new Promise((r) => setTimeout(r, 150)); - } - return (videoRef.current?.textTracks.length ?? 0) > 0; - }; + const asBlobs = (list: Array<[string, string]>) => + list.map(([lang, text]) => { + const url = URL.createObjectURL(new Blob([text], { type: "text/vtt" })); + blobs.push(url); + return [lang, url] as [string, string]; + }); const load = async () => { if (path) { + // Muxed into the download, which is where they belong — but read out + // rather than left to the element's in-band rendering, which the media + // pipeline draws wherever the container's text box points and which no + // styling can reach. + const inside = await embeddedSubtitles(item.id).catch(() => []); + if (cancelled) return; + if (inside.length > 0) { + setTracks(asBlobs(inside)); + return; + } + // Downloads from before subtitles were embedded kept them beside the + // file instead. const local = await listSubtitles(item.id).catch(() => []); if (cancelled) return; if (local.length > 0) { setTracks(local.map(([lang, file]) => [lang, fileUrl(file)])); return; } - if (await hasEmbedded()) return; } if (cancelled) return; setFetchingSubs(true); try { const list = await fetchSubtitles(item.id, wantLang); if (cancelled) return; - setTracks( - list.map(([lang, text]) => { - const url = URL.createObjectURL(new Blob([text], { type: "text/vtt" })); - blobs.push(url); - return [lang, url] as [string, string]; - }), - ); + setTracks(asBlobs(list)); } catch { /* a video with no captions in this language is an ordinary outcome */ } finally { diff --git a/src/components/PlayerControls.tsx b/src/components/PlayerControls.tsx index 3ba321f..c17b2da 100644 --- a/src/components/PlayerControls.tsx +++ b/src/components/PlayerControls.tsx @@ -2,10 +2,20 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { subtitleLabel } from "./format"; +/** + * Only the tracks this app attached. + * + * A downloaded file carries its own subtitle stream and WebKit exposes that + * here too. Listing it would offer the same captions twice, and the in-band + * copy is the one the media pipeline draws wherever the container's text box + * points, out of reach of any styling. The app reads that stream out of the + * file and attaches it itself instead. + */ function listSubs(v: HTMLVideoElement | null): TextTrack[] { if (!v) return []; + const own = new Set(Array.from(v.querySelectorAll("track")).map((el) => el.track)); return Array.from(v.textTracks).filter( - (t) => t.kind === "subtitles" || t.kind === "captions", + (t) => own.has(t) && (t.kind === "subtitles" || t.kind === "captions"), ); } @@ -84,9 +94,11 @@ export default function PlayerControls({ const wanted = subLang === "off" ? undefined - : tracks.find((t) => + : listSubs(v).find((t) => (t.language || "").toLowerCase().startsWith(subLang.toLowerCase()), ); + // Everything else off, in-band tracks included — otherwise the file's own + // copy renders underneath ours. for (const t of tracks) t.mode = t === wanted ? "showing" : "disabled"; read(); }; diff --git a/src/index.css b/src/index.css index 0904490..4f03de0 100644 --- a/src/index.css +++ b/src/index.css @@ -35,6 +35,15 @@ button:focus, button:focus-visible, [contenteditable]:focus { outline: none !important; } + /* Subtitles. WebKit's default is sized for a television across a room and + boxed in solid black; this is the same thing said more quietly. */ + video::cue { + font-size: 58%; + line-height: 1.35; + background-color: rgb(2 6 23 / 0.55); + color: #f8fafc; + } + html, body, #root { height: 100%; } body { margin: 0;