feat: embedded subtitles, resumed downloads, subtitle-only track menu

Verified in the running app and against the files on disk.

Subtitles are muxed into the download as a soft SubRip track rather
than written beside it. Without --write-subs, yt-dlp fetches them,
embeds them and removes the WebVTT, so a download is one file — checked
with ffprobe: video, audio, one mov_text track, no sidecar. WebKit
exposes it, so the player lists and shows it with no fetch at all,
which also means it works offline.

They are converted to SubRip on the way in. YouTube's WebVTT pins every
cue to the left edge and fills it with karaoke timing tags; the first
embed carried both and rendered clamped to the side of the picture.
SubRip carries neither. Only the plain language is embedded — "en" and
"en-orig" are usually the same captions, and nothing can collapse
duplicates once they are muxed in.

Downloads left unfinished when the app closes are picked up on the next
launch, at the quality and language they were asked for, which the
downloads table now records. Verified by leaving a row "running" with
no process behind it and restarting: it downloaded and completed.

The player's track menu is subtitles only — the audio renditions are
gone, the original-audio default still being fixed in the manifest —
and tracks read "English" rather than "en".

Turning subtitles off in one video now sticks for the next. The guard
that applies the preference keyed on the number of text tracks alone,
which does not change from one video to the next, so a choice of Off
was never re-applied; it keys on the video as well.

Embedding no longer depends on the on/off preference, the same trap
that kept fetching switched off: a file downloaded without a subtitle
track can never gain one offline.

Delete all is a trash icon.
This commit is contained in:
vincent
2026-08-29 17:12:21 +02:00
parent 62110b28fc
commit 2121f3e475
10 changed files with 188 additions and 99 deletions
+15 -1
View File
@@ -1026,7 +1026,7 @@ async fn cache_thumbnails(state: &State<'_, AppState>) {
pub async fn download_video( pub async fn download_video(
video_id: String, video_id: String,
quality: String, quality: String,
sub_langs: String, sub_lang: String,
app: AppHandle, app: AppHandle,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<(), String> { ) -> Result<(), String> {
@@ -1038,6 +1038,7 @@ pub async fn download_video(
{ {
let db = state.db.lock().await; let db = state.db.lock().await;
db.set_download_state(&video_id, DownloadState::Queued, None)?; db.set_download_state(&video_id, DownloadState::Queued, None)?;
db.set_download_request(&video_id, &quality, &sub_lang)?;
} }
let _ = app.emit( let _ = app.emit(
"download:state", "download:state",
@@ -1070,6 +1071,7 @@ pub async fn download_video(
.join(downloader::OUTPUT_TEMPLATE) .join(downloader::OUTPUT_TEMPLATE)
.to_string_lossy() .to_string_lossy()
.to_string(); .to_string();
let sub_langs = downloader::embed_sub_langs_for(&sub_lang);
let mut args = downloader::build_args(&video_id, &out_template, &quality, &sub_langs); let mut args = downloader::build_args(&video_id, &out_template, &quality, &sub_langs);
// Without this yt-dlp looks for ffmpeg on PATH, which a bundled app has no // Without this yt-dlp looks for ffmpeg on PATH, which a bundled app has no
// reason to have. Merging video and audio would fail on a clean machine. // reason to have. Merging video and audio would fail on a clean machine.
@@ -1443,6 +1445,18 @@ pub async fn fetch_subtitles(
Ok(read_vtt_dir(&dir).await) Ok(read_vtt_dir(&dir).await)
} }
/// Downloads that were still going when the app last closed.
///
/// Killing the app kills yt-dlp with it, leaving rows queued or running that no
/// process backs. The front end hands these straight back to `download_video`,
/// so they rejoin the same queue rather than needing a second code path.
#[tauri::command]
pub async fn interrupted_downloads(
state: State<'_, AppState>,
) -> Result<Vec<(String, String, String)>, String> {
state.db.lock().await.interrupted_downloads()
}
/// Stops everything downloading or waiting to download. /// Stops everything downloading or waiting to download.
/// ///
/// Kills the running processes, then marks every row the database still calls /// Kills the running processes, then marks every row the database still calls
+37
View File
@@ -92,6 +92,9 @@ impl Db {
let _ = conn.execute("ALTER TABLE videos ADD COLUMN duration INTEGER", []); let _ = conn.execute("ALTER TABLE videos ADD COLUMN duration INTEGER", []);
let _ = conn.execute("ALTER TABLE channels ADD COLUMN last_error TEXT", []); let _ = conn.execute("ALTER TABLE channels ADD COLUMN last_error TEXT", []);
let _ = conn.execute("ALTER TABLE channels ADD COLUMN last_checked INTEGER", []); let _ = conn.execute("ALTER TABLE channels ADD COLUMN last_checked INTEGER", []);
// What a download was asked for, so it can be resumed as requested.
let _ = conn.execute("ALTER TABLE downloads ADD COLUMN quality TEXT", []);
let _ = conn.execute("ALTER TABLE downloads ADD COLUMN sub_lang TEXT", []);
Ok(Db { conn }) Ok(Db { conn })
} }
@@ -508,6 +511,40 @@ impl Db {
Ok(()) Ok(())
} }
/// The quality and subtitle language a download was started with, so an
/// interrupted one resumes as it was asked for rather than as the settings
/// happen to read now.
pub fn set_download_request(
&self,
video_id: &str,
quality: &str,
sub_lang: &str,
) -> Result<(), String> {
self.conn
.execute(
"UPDATE downloads SET quality = ?2, sub_lang = ?3 WHERE video_id = ?1",
params![video_id, quality, sub_lang],
)
.map_err(|e| e.to_string())?;
Ok(())
}
/// Downloads left unfinished, as (video_id, quality, sub_lang). Killing the
/// app leaves rows queued or running with no process behind them.
pub fn interrupted_downloads(&self) -> Result<Vec<(String, String, String)>, String> {
let mut stmt = self
.conn
.prepare(
"SELECT video_id, COALESCE(quality, ''), COALESCE(sub_lang, '')
FROM downloads WHERE state IN ('queued','running')",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
.map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
}
pub fn set_download_state( pub fn set_download_state(
&self, &self,
video_id: &str, video_id: &str,
+49 -16
View File
@@ -111,21 +111,28 @@ pub fn build_args(
]; ];
if !sub_langs.is_empty() { if !sub_langs.is_empty() {
// Subtitles come along for offline use, including YouTube's // Muxed into the file as a soft subtitle track, not written beside it
// auto-generated ones. WebVTT beside the video rather than muxed in: // and not burned into the picture. Without --write-subs, yt-dlp fetches
// WebKit reads a <track> reliably and largely ignores subtitle streams // the captions, embeds them, and removes the WebVTT files, so a
// inside an MP4. // download is one self-contained file.
//
// Auto-generated captions are included: on most videos they are the
// only ones there are.
// //
// The languages are named exactly. A wildcard like "en.*" also matches // The languages are named exactly. A wildcard like "en.*" also matches
// every machine-translated variant YouTube offers — en-en-US, en-de and // every machine-translated variant YouTube offers — en-en-US, en-de and
// dozens more — and asking for all of them earns an HTTP 429. // dozens more — and asking for all of them earns an HTTP 429.
args.extend([ args.extend([
"--write-subs".into(),
"--write-auto-subs".into(), "--write-auto-subs".into(),
"--embed-subs".into(),
"--sub-format".into(), "--sub-format".into(),
"vtt".into(), "vtt".into(),
// Converted to SubRip on the way in. YouTube's WebVTT pins every
// cue to the left edge and fills it with karaoke timing tags;
// SubRip carries neither, so the embedded track is plain centred
// text rather than something clinging to the side of the picture.
"--convert-subs".into(), "--convert-subs".into(),
"vtt".into(), "srt".into(),
"--sub-langs".into(), "--sub-langs".into(),
sub_langs.to_string(), sub_langs.to_string(),
]); ]);
@@ -161,10 +168,12 @@ pub fn subs_only_args(video_id: &str, out_template: &str, sub_langs: &str) -> Ve
] ]
} }
/// The subtitle languages to request for a preference, or empty for none. /// The subtitle languages to fetch for a preference, or empty for none.
/// ///
/// Only the language itself and YouTube's "-orig" variant; anything broader /// The language itself and YouTube's "-orig" variant, which is the original
/// pulls in machine translations by the dozen. /// rather than a machine translation. Anything broader pulls in translations by
/// the dozen. The two are usually byte-identical, so whoever reads them back
/// collapses duplicates.
pub fn sub_langs_for(pref: &str) -> String { pub fn sub_langs_for(pref: &str) -> String {
if pref.is_empty() || pref == "off" { if pref.is_empty() || pref == "off" {
String::new() String::new()
@@ -173,6 +182,18 @@ pub fn sub_langs_for(pref: &str) -> String {
} }
} }
/// The languages to embed in a download: just the one.
///
/// Nothing downstream can collapse duplicates once they are muxed in, and
/// asking for "en,en-orig" gives a file with the same captions on two tracks.
pub fn embed_sub_langs_for(pref: &str) -> String {
if pref.is_empty() || pref == "off" {
String::new()
} else {
pref.to_string()
}
}
/// yt-dlp reports the final path via `--print after_move:`, which is more /// yt-dlp reports the final path via `--print after_move:`, which is more
/// reliable than guessing the extension after a merge. /// reliable than guessing the extension after a merge.
pub fn parse_final_path(line: &str) -> Option<String> { pub fn parse_final_path(line: &str) -> Option<String> {
@@ -270,24 +291,36 @@ mod tests {
} }
#[test] #[test]
fn subtitles_are_requested_including_auto_generated() { fn subtitles_are_embedded_not_written_beside_the_video() {
let args = build_args("abc", "/tmp/o.%(ext)s", "best", "en,en-orig"); let args = build_args("abc", "/tmp/o.%(ext)s", "best", "en");
assert!(args.contains(&"--write-subs".to_string())); assert!(args.contains(&"--embed-subs".to_string()));
// The auto-generated track is the only one many videos have. // The auto-generated track is the only one many videos have.
assert!(args.contains(&"--write-auto-subs".to_string())); assert!(args.contains(&"--write-auto-subs".to_string()));
assert!(args.contains(&"en,en-orig".to_string())); assert!(args.contains(&"en".to_string()));
// WebVTT, because that is what a <track> element can load. // Without --write-subs, yt-dlp removes the WebVTT files after
assert!(args.contains(&"vtt".to_string())); // embedding, leaving one self-contained file.
assert!(!args.contains(&"--write-subs".to_string()));
// SubRip, so YouTube's edge-pinned cue positioning does not come with.
assert!(args.contains(&"srt".to_string()));
} }
#[test] #[test]
fn no_preference_means_no_subtitle_requests_at_all() { fn no_preference_means_no_subtitle_requests_at_all() {
let args = build_args("abc", "/tmp/o.%(ext)s", "best", ""); let args = build_args("abc", "/tmp/o.%(ext)s", "best", "");
assert!(!args.contains(&"--write-subs".to_string())); assert!(!args.contains(&"--embed-subs".to_string()));
assert!(!args.contains(&"--write-auto-subs".to_string())); assert!(!args.contains(&"--write-auto-subs".to_string()));
assert!(!args.contains(&"--sub-langs".to_string())); assert!(!args.contains(&"--sub-langs".to_string()));
} }
#[test]
fn embedding_asks_for_one_language_and_fetching_asks_for_both() {
// Two identical tracks cannot be collapsed once they are muxed in.
assert_eq!(embed_sub_langs_for("en"), "en");
assert_eq!(embed_sub_langs_for("off"), "");
// Reading them back can collapse duplicates, so breadth is free there.
assert_eq!(sub_langs_for("en"), "en,en-orig");
}
#[test] #[test]
fn a_subtitle_failure_must_not_abort_the_video() { fn a_subtitle_failure_must_not_abort_the_video() {
// yt-dlp aborts the whole job on the first error without this. // yt-dlp aborts the whole job on the first error without this.
+1
View File
@@ -158,6 +158,7 @@ fn build_menu(app: &tauri::AppHandle) -> tauri::Result<Menu<tauri::Wry>> {
commands::download_video, commands::download_video,
commands::cancel_download, commands::cancel_download,
commands::cancel_all_downloads, commands::cancel_all_downloads,
commands::interrupted_downloads,
commands::delete_download, commands::delete_download,
commands::delete_all_downloads, commands::delete_all_downloads,
commands::list_subtitles, commands::list_subtitles,
+37 -5
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
cancelAllDownloads, cancelDownload, deleteAllDownloads, deleteDownload, downloadVideo, cancelAllDownloads, cancelDownload, deleteAllDownloads, deleteDownload, downloadVideo,
interruptedDownloads,
fetchDurations, onRefreshProgress, refreshFeeds, setCookieSource, fetchDurations, onRefreshProgress, refreshFeeds, setCookieSource,
} from "./api"; } from "./api";
import Player from "./components/Player"; import Player from "./components/Player";
@@ -148,6 +149,11 @@ export default function App() {
}, [view, quality, bulkLimit, streamQuality, subLang, browser, downloadedOnly, }, [view, quality, bulkLimit, streamQuality, subLang, browser, downloadedOnly,
hideShorts, sidebarHidden]); hideShorts, sidebarHidden]);
// The language a download embeds. Like the player's fetch, this does not
// depend on the on/off preference: that says what is shown, and a file
// downloaded without a subtitle track can never gain one offline.
const embedLang = subLang === "off" ? DEFAULT_SUB_LANG : subLang;
// Offline, the only videos that can be played are the ones already on disk, // Offline, the only videos that can be played are the ones already on disk,
// so the feed collapses to those regardless of the toggle. // so the feed collapses to those regardless of the toggle.
const effectiveDownloadedOnly = downloadedOnly || !online; const effectiveDownloadedOnly = downloadedOnly || !online;
@@ -191,7 +197,6 @@ export default function App() {
// Named exactly, never as a wildcard: "en.*" also matches every // Named exactly, never as a wildcard: "en.*" also matches every
// machine-translated variant YouTube offers, and asking for all of them // machine-translated variant YouTube offers, and asking for all of them
// earns an HTTP 429. Off means no subtitle requests at all. // earns an HTTP 429. Off means no subtitle requests at all.
const subLangArg = subLang === "off" ? "" : `${subLang},${subLang}-orig`;
const doRefresh = useCallback(async () => { const doRefresh = useCallback(async () => {
setRefreshing(true); setRefreshing(true);
@@ -275,6 +280,33 @@ export default function App() {
return ids.size; return ids.size;
}, [live, items]); }, [live, items]);
// Closing the app kills yt-dlp, so anything in flight is left unfinished.
// Pick it up on the next launch, at the quality and language it was asked
// for rather than whatever the settings happen to say now.
useEffect(() => {
let cancelled = false;
interruptedDownloads()
.then((rows) => {
if (cancelled || rows.length === 0) return;
say(
rows.length === 1
? "Resuming 1 unfinished download"
: `Resuming ${rows.length} unfinished downloads`,
);
for (const [id, q, lang] of rows) {
downloadVideo(id, (q || "best") as Quality, lang).catch(() => {});
}
})
.catch(() => {
/* nothing to resume is the normal case */
});
return () => {
cancelled = true;
};
// Once, on launch.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const stopAll = useCallback(() => { const stopAll = useCallback(() => {
cancelAllDownloads() cancelAllDownloads()
.then((n) => { .then((n) => {
@@ -311,9 +343,9 @@ export default function App() {
: `Queued ${bulkTargets.length} video${bulkTargets.length === 1 ? "" : "s"}`, : `Queued ${bulkTargets.length} video${bulkTargets.length === 1 ? "" : "s"}`,
); );
for (const i of bulkTargets) { for (const i of bulkTargets) {
downloadVideo(i.id, quality, subLangArg).catch(() => {}); downloadVideo(i.id, quality, embedLang).catch(() => {});
} }
}, [bulkTargets, pendingDownloads.length, quality, subLangArg, say]); }, [bulkTargets, pendingDownloads.length, quality, embedLang, say]);
// Ids on screen still lacking a length, newest first. Joined into a string // Ids on screen still lacking a length, newest first. Joined into a string
// so the effect below only re-runs when the set actually changes. // so the effect below only re-runs when the set actually changes.
@@ -502,7 +534,7 @@ export default function App() {
onOpen: () => openIndex(idx), onOpen: () => openIndex(idx),
onOpenChannel: () => { setChannelId(item.channel_id); setSearch(""); }, onOpenChannel: () => { setChannelId(item.channel_id); setSearch(""); },
onDownload: () => onDownload: () =>
downloadVideo(item.id, quality, subLangArg).catch((e) => setFailure(String(e))), downloadVideo(item.id, quality, embedLang).catch((e) => setFailure(String(e))),
onCancel: () => onCancel: () =>
cancelDownload(item.id).catch((e) => setFailure(String(e))), cancelDownload(item.id).catch((e) => setFailure(String(e))),
onDelete: () => onDelete: () =>
@@ -546,7 +578,7 @@ export default function App() {
onDownload={ onDownload={
playing.path === null playing.path === null
? () => { ? () => {
downloadVideo(playing.item.id, quality, subLangArg).catch((e) => setFailure(String(e))); downloadVideo(playing.item.id, quality, embedLang).catch((e) => setFailure(String(e)));
say("Download started"); say("Download started");
} }
: undefined : undefined
+6 -2
View File
@@ -25,8 +25,12 @@ export const listFeed = (filter: FeedFilter) =>
export const refreshFeeds = () => invoke<RefreshSummary>("refresh_feeds"); export const refreshFeeds = () => invoke<RefreshSummary>("refresh_feeds");
export const downloadVideo = (videoId: string, quality: Quality, subLangs: string) => export const downloadVideo = (videoId: string, quality: Quality, subLang: string) =>
invoke<void>("download_video", { videoId, quality, subLangs }); invoke<void>("download_video", { videoId, quality, subLang });
/** Downloads still unfinished from a previous run, as (id, quality, subLang). */
export const interruptedDownloads = () =>
invoke<Array<[string, string, string]>>("interrupted_downloads");
/** Stops everything downloading or waiting to. Returns how many were stopped. */ /** Stops everything downloading or waiting to. Returns how many were stopped. */
export const cancelAllDownloads = () => invoke<number>("cancel_all_downloads"); export const cancelAllDownloads = () => invoke<number>("cancel_all_downloads");
+16
View File
@@ -143,6 +143,21 @@ export default function Player({
setTracks([]); setTracks([]);
setFetchingSubs(false); 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 load = async () => { const load = async () => {
if (path) { if (path) {
const local = await listSubtitles(item.id).catch(() => []); const local = await listSubtitles(item.id).catch(() => []);
@@ -151,6 +166,7 @@ export default function Player({
setTracks(local.map(([lang, file]) => [lang, fileUrl(file)])); setTracks(local.map(([lang, file]) => [lang, fileUrl(file)]));
return; return;
} }
if (await hasEmbedded()) return;
} }
if (cancelled) return; if (cancelled) return;
setFetchingSubs(true); setFetchingSubs(true);
+15 -67
View File
@@ -1,25 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
/** WebKit exposes HLS alternate audio renditions here; the DOM lib omits it. */ import { subtitleLabel } from "./format";
interface AudioTrackLike {
id: string;
label: string;
language: string;
enabled: boolean;
}
interface AudioTrackListLike {
length: number;
[index: number]: AudioTrackLike;
addEventListener?: (t: string, fn: () => void) => void;
removeEventListener?: (t: string, fn: () => void) => void;
}
type VideoWithTracks = HTMLVideoElement & { audioTracks?: AudioTrackListLike };
function listAudio(v: HTMLVideoElement | null): AudioTrackLike[] {
const list = (v as VideoWithTracks | null)?.audioTracks;
if (!list) return [];
return Array.from({ length: list.length }, (_, i) => list[i]);
}
function listSubs(v: HTMLVideoElement | null): TextTrack[] { function listSubs(v: HTMLVideoElement | null): TextTrack[] {
if (!v) return []; if (!v) return [];
@@ -76,21 +57,17 @@ export default function PlayerControls({
const [pip, setPip] = useState(false); const [pip, setPip] = useState(false);
const [full, setFull] = useState(false); const [full, setFull] = useState(false);
const [menu, setMenu] = useState(false); const [menu, setMenu] = useState(false);
const [audio, setAudio] = useState<AudioTrackLike[]>([]);
const [subs, setSubs] = useState<TextTrack[]>([]); const [subs, setSubs] = useState<TextTrack[]>([]);
const [, bump] = useState(0); const [, bump] = useState(0);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
// How many text tracks the preference was last applied to. // Which video, and how many tracks, the preference was last applied to.
const applied = useRef(-1); const applied = useRef("");
// Tracks arrive with the manifest, after metadata rather than on mount. // Tracks arrive with the manifest, after metadata rather than on mount.
useEffect(() => { useEffect(() => {
const v = videoRef.current; const v = videoRef.current;
if (!v) return; if (!v) return;
const read = () => { const read = () => setSubs(listSubs(v));
setAudio(listAudio(v));
setSubs(listSubs(v));
};
read(); read();
// Subtitles follow the language chosen in Settings and nothing else. // Subtitles follow the language chosen in Settings and nothing else.
@@ -98,11 +75,12 @@ export default function PlayerControls({
// which is the same unwanted auto-selection as a dubbed audio track. // which is the same unwanted auto-selection as a dubbed audio track.
// Applied once per set of tracks. Re-applying on every poll would undo a // Applied once per set of tracks. Re-applying on every poll would undo a
// choice made in the menu a second after it was made. // choice made in the menu a second after it was made.
applied.current = -1; applied.current = "";
const applyPreference = () => { const applyPreference = () => {
const tracks = Array.from(v.textTracks); const tracks = Array.from(v.textTracks);
if (tracks.length === applied.current) return; const key = `${v.currentSrc}|${tracks.length}`;
applied.current = tracks.length; if (key === applied.current) return;
applied.current = key;
const wanted = const wanted =
subLang === "off" subLang === "off"
? undefined ? undefined
@@ -118,8 +96,6 @@ export default function PlayerControls({
v.textTracks.addEventListener?.("addtrack", applyPreference); v.textTracks.addEventListener?.("addtrack", applyPreference);
v.addEventListener("loadedmetadata", read); v.addEventListener("loadedmetadata", read);
const at = (v as VideoWithTracks).audioTracks;
at?.addEventListener?.("addtrack", read);
v.textTracks.addEventListener?.("addtrack", read); v.textTracks.addEventListener?.("addtrack", read);
// Manifests can take a moment to surface renditions. // Manifests can take a moment to surface renditions.
const id = setInterval(read, 1000); const id = setInterval(read, 1000);
@@ -128,7 +104,6 @@ export default function PlayerControls({
v.removeEventListener("loadedmetadata", applyPreference); v.removeEventListener("loadedmetadata", applyPreference);
v.textTracks.removeEventListener?.("addtrack", applyPreference); v.textTracks.removeEventListener?.("addtrack", applyPreference);
v.removeEventListener("loadedmetadata", read); v.removeEventListener("loadedmetadata", read);
at?.removeEventListener?.("addtrack", read);
v.textTracks.removeEventListener?.("addtrack", read); v.textTracks.removeEventListener?.("addtrack", read);
clearInterval(id); clearInterval(id);
clearTimeout(stop); clearTimeout(stop);
@@ -145,16 +120,6 @@ export default function PlayerControls({
return () => document.removeEventListener("mousedown", onDown); return () => document.removeEventListener("mousedown", onDown);
}, [menu]); }, [menu]);
const chooseAudio = (i: number) => {
const v = videoRef.current;
const list = (v as VideoWithTracks | null)?.audioTracks;
if (!list) return;
// Exactly one enabled, or WebKit mixes them.
for (let k = 0; k < list.length; k++) list[k].enabled = k === i;
bump((n) => n + 1);
onActivity?.();
};
const chooseSub = (track: TextTrack | null) => { const chooseSub = (track: TextTrack | null) => {
const v = videoRef.current; const v = videoRef.current;
if (!v) return; if (!v) return;
@@ -340,12 +305,12 @@ export default function PlayerControls({
}} }}
/> />
{(audio.length > 1 || subs.length > 0 || subsLoading) && ( {(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?.(); }}
className={btn} className={btn}
title="Audio and subtitles" title="Subtitles"
> >
<svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2"> <svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="5" width="18" height="14" rx="2" /> <rect x="3" y="5" width="18" height="14" rx="2" />
@@ -358,27 +323,6 @@ export default function PlayerControls({
className="absolute bottom-full right-0 mb-2 max-h-72 w-60 overflow-y-auto rounded-lg className="absolute bottom-full right-0 mb-2 max-h-72 w-60 overflow-y-auto rounded-lg
border border-slate-700 bg-slate-900/95 p-1 shadow-2xl backdrop-blur" border border-slate-700 bg-slate-900/95 p-1 shadow-2xl backdrop-blur"
> >
{audio.length > 1 && (
<>
<div className="px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest text-slate-400">
Audio
</div>
{audio.map((t, i) => (
<button
key={t.id || `${t.language}-${i}`}
onClick={() => chooseAudio(i)}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left
text-[12px] cursor-pointer hover:bg-slate-800 ${
t.enabled ? "text-white" : "text-slate-300"
}`}
>
<span className="w-3 shrink-0 text-sky-400">{t.enabled ? "✓" : ""}</span>
<span className="truncate">{t.label || t.language || `Track ${i + 1}`}</span>
</button>
))}
</>
)}
{subs.length === 0 && subsLoading && ( {subs.length === 0 && subsLoading && (
<> <>
<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">
@@ -419,7 +363,11 @@ export default function PlayerControls({
<span className="w-3 shrink-0 text-sky-400"> <span className="w-3 shrink-0 text-sky-400">
{t.mode === "showing" ? "✓" : ""} {t.mode === "showing" ? "✓" : ""}
</span> </span>
<span className="truncate">{t.label || t.language || `Subtitles ${i + 1}`}</span> <span className="truncate">
{t.language
? subtitleLabel(t.language)
: t.label || `Subtitles ${i + 1}`}
</span>
</button> </button>
))} ))}
</> </>
+2 -2
View File
@@ -280,8 +280,8 @@ export default function Settings({
captions on most videos those are the only ones there are. Downloads keep captions on most videos those are the only ones there are. Downloads keep
them beside the file for offline use; streams fetch them separately, since them beside the file for offline use; streams fetch them separately, since
YouTube's live manifest carries no subtitles at all. <b>Off</b> only means YouTube's live manifest carries no subtitles at all. <b>Off</b> only means
nothing comes on by itself: whatever a video has is still listed in the nothing comes on by itself: subtitles are still embedded in downloads and
player's subtitle menu. still listed in the player's subtitle menu.
</p> </p>
</section> </section>
+10 -6
View File
@@ -162,13 +162,17 @@ export default function TopBar({
<button <button
onClick={onDeleteAll} onClick={onDeleteAll}
title="Delete every download" title="Delete every download"
className={`inline-flex ${CONTROL_H} cursor-pointer items-center whitespace-nowrap aria-label="Delete every download"
rounded-lg border border-slate-300 px-2.5 text-[12px] font-medium className={`${ICON_BTN} border border-slate-300 text-slate-500 hover:border-red-500
text-slate-500 hover:border-red-500 hover:text-red-600 hover:text-red-600 dark:border-slate-700 dark:text-slate-400
dark:border-slate-700 dark:text-slate-400 dark:hover:border-red-500 dark:hover:border-red-500 dark:hover:text-red-400`}
dark:hover:text-red-400`}
> >
Delete all <svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor"
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 7h16M10 11v6M14 11v6" />
<path d="M6 7l1 12.5A1.5 1.5 0 008.5 21h7a1.5 1.5 0 001.5-1.5L18 7" />
<path d="M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2" />
</svg>
</button> </button>
)} )}