fix: subtitles centred and quieter
The captions were pinned to one side and set for a television across a room. Verified fixed in the running app: centred at the bottom of the picture, smaller, on a translucent ground instead of solid black. The position was not in the file — the embedded track extracts to WebVTT with no cue settings at all. It was the element's in-band rendering: WebKit hands a container's own subtitle stream to the media pipeline, which places it wherever the container's text box points and which no CSS can reach. So the player reads the stream out of the file with ffmpeg — about 40ms, no re-encoding — and attaches it as an ordinary track. The download stays one self-contained file; the cues become ours to place and style. The file's own track is filtered out of the menu and left disabled, or the same captions would be offered twice and the in-band copy would render underneath. Cue styling: 58% of WebKit's default size, on slate at 55% rather than opaque black.
This commit is contained in:
@@ -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<Vec<(String, String)>, 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 "<index>,<language>" 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
|
||||
|
||||
@@ -163,6 +163,7 @@ fn build_menu(app: &tauri::AppHandle) -> tauri::Result<Menu<tauri::Wry>> {
|
||||
commands::delete_all_downloads,
|
||||
commands::list_subtitles,
|
||||
commands::fetch_subtitles,
|
||||
commands::embedded_subtitles,
|
||||
commands::fetch_durations,
|
||||
commands::list_browsers,
|
||||
commands::set_cookie_source,
|
||||
|
||||
@@ -61,6 +61,10 @@ export const updateYtDlp = () => invoke<string>("update_yt_dlp");
|
||||
export const listSubtitles = (videoId: string) =>
|
||||
invoke<Array<[string, string]>>("list_subtitles", { videoId });
|
||||
|
||||
/** Subtitles read out of a downloaded file, as (language, WebVTT text). */
|
||||
export const embeddedSubtitles = (videoId: string) =>
|
||||
invoke<Array<[string, string]>>("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) =>
|
||||
|
||||
+21
-23
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user