From 557467baad000d17e0208d4133c6fc9159e793a2 Mon Sep 17 00:00:00 2001 From: vincent Date: Sat, 29 Aug 2026 13:25:17 +0200 Subject: [PATCH] feat: subtitles, delete-all, auto refresh, and menu cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subtitles: a language preference in Settings drives what is shown and what is downloaded. yt-dlp saves WebVTT sidecars next to each download, including YouTube's auto-generated track, and the player attaches them as elements so they work offline. Sidecars are removed with their video, and by Delete all. WebVTT rather than muxed subtitle streams because WebKit reads a reliably and largely ignores subtitle tracks inside an MP4. Downloads in progress now appear under the downloaded-only filter, so a download you just started does not vanish from the list you are watching it in. That view also gains a Delete all, behind a confirmation naming what goes. The feed refreshes on launch and whenever the player closes, so the Refresh button is only for staleness. Player: Delete moved to the footer and shortened, Open on YouTube is now an external-link icon. Removes the Edit and Help menus. Cut/Copy/Paste move to the app menu, without which their shortcuts would stop working in the search field. The webview context menu is suppressed outside text fields — its Reload and Back items act on a page the app does not present as one. Settings now warns that 4K AV1 plays back with artefacts: the files decode cleanly in ffmpeg, so it is the built-in decoder, not the download. --- src-tauri/src/commands.rs | 64 ++++++++++++++++++++- src-tauri/src/db.rs | 54 +++++++++++++++++- src-tauri/src/downloader.rs | 34 ++++++++++- src-tauri/src/lib.rs | 93 ++++++++++++++++++++++++++++++- src/App.tsx | 75 +++++++++++++++++++++++-- src/api.ts | 10 +++- src/components/Player.tsx | 71 ++++++++++++++++++----- src/components/PlayerControls.tsx | 32 +++++++---- src/components/Settings.tsx | 27 ++++++++- src/components/TopBar.tsx | 18 +++++- src/main.tsx | 11 ++++ src/types.ts | 12 ++++ 12 files changed, 458 insertions(+), 43 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index b4b08ec..99194a1 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -690,6 +690,7 @@ async fn cache_thumbnails(state: &State<'_, AppState>) { pub async fn download_video( video_id: String, quality: String, + sub_langs: String, app: AppHandle, state: State<'_, AppState>, ) -> Result<(), String> { @@ -723,7 +724,7 @@ pub async fn download_video( .join(downloader::OUTPUT_TEMPLATE) .to_string_lossy() .to_string(); - let mut args = downloader::build_args(&video_id, &out_template, &quality); + 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 // reason to have. Merging video and audio would fail on a clean machine. args.push("--ffmpeg-location".into()); @@ -922,6 +923,55 @@ async fn cleanup_partials(library: PathBuf, video_id: &str) { } } +/// WebVTT files yt-dlp wrote beside a download, as (language, path) pairs. +#[tauri::command] +pub async fn list_subtitles( + video_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let library = state.library.lock().await.clone(); + let Ok(mut entries) = tokio::fs::read_dir(&library).await else { + return Ok(Vec::new()); + }; + let mut out = Vec::new(); + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name().to_string_lossy().to_string(); + if !name.contains(&video_id) || !name.ends_with(".vtt") { + continue; + } + // yt-dlp names them "..vtt". + let lang = name + .trim_end_matches(".vtt") + .rsplit('.') + .next() + .unwrap_or("") + .to_string(); + out.push((lang, entry.path().to_string_lossy().to_string())); + } + out.sort(); + Ok(out) +} + +/// Removes every download and the files behind them, including subtitles. +#[tauri::command] +pub async fn delete_all_downloads(state: State<'_, AppState>) -> Result { + let paths = state.db.lock().await.all_download_paths()?; + for p in &paths { + let _ = tokio::fs::remove_file(p).await; + } + // Subtitle sidecars are not tracked in the database, so sweep them here. + let library = state.library.lock().await.clone(); + if let Ok(mut entries) = tokio::fs::read_dir(&library).await { + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name().to_string_lossy().to_string(); + if name.ends_with(".vtt") || name.contains(".part") || name.ends_with(".ytdl") { + let _ = tokio::fs::remove_file(entry.path()).await; + } + } + } + state.db.lock().await.clear_all_downloads() +} + #[tauri::command] pub async fn delete_download( video_id: String, @@ -931,7 +981,17 @@ pub async fn delete_download( if let Some(p) = path { let _ = tokio::fs::remove_file(&p).await; } - cleanup_partials(state.library.lock().await.clone(), &video_id).await; + let library = state.library.lock().await.clone(); + cleanup_partials(library.clone(), &video_id).await; + // The .vtt sidecars belong to the video, so they go with it. + if let Ok(mut entries) = tokio::fs::read_dir(&library).await { + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name().to_string_lossy().to_string(); + if name.contains(&video_id) && name.ends_with(".vtt") { + let _ = tokio::fs::remove_file(entry.path()).await; + } + } + } state.db.lock().await.clear_download(&video_id) } diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 4e6cff6..77924b1 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -369,7 +369,9 @@ impl Db { args.push(Box::new(pat)); } if f.downloaded_only { - sql.push_str(" AND d.state = 'done'"); + // Queued and running count: a download you started should not + // vanish from the very list you are watching it in. + sql.push_str(" AND d.state IN ('done','queued','running')"); } if f.hide_shorts { sql.push_str(" AND v.is_short = 0"); @@ -498,6 +500,26 @@ impl Db { .map_err(|e| e.to_string()) } + /// Paths of every completed download, for deleting them all at once. + pub fn all_download_paths(&self) -> Result, String> { + let mut stmt = self + .conn + .prepare("SELECT path FROM downloads WHERE path IS NOT NULL") + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map([], |r| r.get::<_, String>(0)) + .map_err(|e| e.to_string())?; + rows.collect::, _>>().map_err(|e| e.to_string()) + } + + pub fn clear_all_downloads(&self) -> Result { + let n = self + .conn + .execute("DELETE FROM downloads", []) + .map_err(|e| e.to_string())?; + Ok(n) + } + pub fn clear_download(&self, video_id: &str) -> Result<(), String> { self.conn .execute("DELETE FROM downloads WHERE video_id = ?1", params![video_id]) @@ -737,6 +759,36 @@ mod tests { assert_eq!(feed[0].id, "b"); } + #[test] + fn downloads_in_progress_still_show_in_the_downloaded_filter() { + let db = seeded(); + db.set_download_state("a", DownloadState::Running, None).unwrap(); + db.set_download_state("c", DownloadState::Queued, None).unwrap(); + db.set_download_state("b", DownloadState::Failed, Some("x")).unwrap(); + + let feed = db + .list_feed(&FeedFilter { downloaded_only: true, ..Default::default() }) + .unwrap(); + let ids: Vec<&str> = feed.iter().map(|f| f.id.as_str()).collect(); + assert!(ids.contains(&"a"), "running should be listed"); + assert!(ids.contains(&"c"), "queued should be listed"); + assert!(!ids.contains(&"b"), "failed should not be"); + } + + #[test] + fn clearing_all_downloads_empties_the_filter() { + let db = seeded(); + db.set_download_state("a", DownloadState::Done, None).unwrap(); + db.set_download_path("a", "/movies/a.mp4").unwrap(); + db.set_download_state("b", DownloadState::Done, None).unwrap(); + assert_eq!(db.all_download_paths().unwrap(), vec!["/movies/a.mp4".to_string()]); + db.clear_all_downloads().unwrap(); + assert!(db + .list_feed(&FeedFilter { downloaded_only: true, ..Default::default() }) + .unwrap() + .is_empty()); + } + #[test] fn hide_shorts_filter_excludes_shorts() { let db = seeded(); diff --git a/src-tauri/src/downloader.rs b/src-tauri/src/downloader.rs index b54e6e8..ab099ce 100644 --- a/src-tauri/src/downloader.rs +++ b/src-tauri/src/downloader.rs @@ -79,7 +79,12 @@ pub fn parse_progress_line(line: &str) -> Option { /// Arguments for downloading one video. Kept separate from process spawning so /// the argument construction is assertable in tests. -pub fn build_args(video_id: &str, out_template: &str, quality: &str) -> Vec { +pub fn build_args( + video_id: &str, + out_template: &str, + quality: &str, + sub_langs: &str, +) -> Vec { vec![ "-f".into(), format_selector(quality), @@ -94,6 +99,18 @@ pub fn build_args(video_id: &str, out_template: &str, quality: &str) -> Vec reliably, whereas + // subtitle streams inside an MP4 it largely ignores. + "--write-subs".into(), + "--write-auto-subs".into(), + "--sub-format".into(), + "vtt".into(), + "--convert-subs".into(), + "vtt".into(), + "--sub-langs".into(), + sub_langs.into(), "--print".into(), "after_move:FTPATH %(filepath)s".into(), "-o".into(), @@ -175,9 +192,20 @@ mod tests { assert_eq!(p.pct(), None); } + #[test] + fn subtitles_are_requested_including_auto_generated() { + let args = build_args("abc", "/tmp/o.%(ext)s", "best", "en.*,nl.*"); + assert!(args.contains(&"--write-subs".to_string())); + // The auto-generated track is the only one many videos have. + assert!(args.contains(&"--write-auto-subs".to_string())); + assert!(args.contains(&"en.*,nl.*".to_string())); + // WebVTT, because that is what a element can load. + assert!(args.contains(&"vtt".to_string())); + } + #[test] fn best_quality_takes_the_highest_available() { - let args = build_args("abc123", "/tmp/out.%(ext)s", "best"); + let args = build_args("abc123", "/tmp/out.%(ext)s", "best", "en.*"); assert!(args.contains(&FORMAT_BEST.to_string())); assert!(args.contains(&"mp4".to_string())); assert!(args.contains(&"https://www.youtube.com/watch?v=abc123".to_string())); @@ -197,7 +225,7 @@ mod tests { let sel = format_selector("1080"); assert!(sel.contains("height<=1080")); assert!(!sel.contains("height<=2160")); - assert!(build_args("x", "o", "1080").contains(&sel)); + assert!(build_args("x", "o", "1080", "en.*").contains(&sel)); } #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a60deee..53073c5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,8 +8,52 @@ pub mod playlist_server; pub mod takeout; pub mod thumbs; +use tauri::menu::{AboutMetadata, Menu, PredefinedMenuItem, Submenu}; use tauri::Manager; +/// The macOS menu bar, minus Edit and Help. +/// +/// Tauri's default adds both; neither has anything to offer here. Edit is kept +/// as a hidden-in-spirit necessity though — its Cut/Copy/Paste items are what +/// make those shortcuts work in the search field, so they live under the app +/// menu instead of their own top-level entry. +fn build_menu(app: &tauri::AppHandle) -> tauri::Result> { + let app_menu = Submenu::with_items( + app, + "FlightTube", + true, + &[ + &PredefinedMenuItem::about(app, None, Some(AboutMetadata::default()))?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::hide(app, None)?, + &PredefinedMenuItem::hide_others(app, None)?, + &PredefinedMenuItem::show_all(app, None)?, + &PredefinedMenuItem::separator(app)?, + // Keeps ⌘X/⌘C/⌘V working in text fields without an Edit menu. + &PredefinedMenuItem::cut(app, None)?, + &PredefinedMenuItem::copy(app, None)?, + &PredefinedMenuItem::paste(app, None)?, + &PredefinedMenuItem::select_all(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::quit(app, None)?, + ], + )?; + + let window_menu = Submenu::with_items( + app, + "Window", + true, + &[ + &PredefinedMenuItem::minimize(app, None)?, + &PredefinedMenuItem::maximize(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::close_window(app, None)?, + ], + )?; + + Menu::with_items(app, &[&app_menu, &window_menu]) +} + /// Turns on WKWebView's element fullscreen. /// /// It is off by default in a Tauri window, which is why the native player has @@ -35,6 +79,7 @@ fn enable_element_fullscreen(window: &tauri::WebviewWindow) { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .menu(build_menu) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .setup(|app| { @@ -50,7 +95,51 @@ pub fn run() { // the background rather than the first time Settings is opened. let handle = app.handle().clone(); tauri::async_runtime::spawn(async move { - use tauri::Manager; + use tauri::menu::{AboutMetadata, Menu, PredefinedMenuItem, Submenu}; +use tauri::Manager; + +/// The macOS menu bar, minus Edit and Help. +/// +/// Tauri's default adds both; neither has anything to offer here. Edit is kept +/// as a hidden-in-spirit necessity though — its Cut/Copy/Paste items are what +/// make those shortcuts work in the search field, so they live under the app +/// menu instead of their own top-level entry. +fn build_menu(app: &tauri::AppHandle) -> tauri::Result> { + let app_menu = Submenu::with_items( + app, + "FlightTube", + true, + &[ + &PredefinedMenuItem::about(app, None, Some(AboutMetadata::default()))?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::hide(app, None)?, + &PredefinedMenuItem::hide_others(app, None)?, + &PredefinedMenuItem::show_all(app, None)?, + &PredefinedMenuItem::separator(app)?, + // Keeps ⌘X/⌘C/⌘V working in text fields without an Edit menu. + &PredefinedMenuItem::cut(app, None)?, + &PredefinedMenuItem::copy(app, None)?, + &PredefinedMenuItem::paste(app, None)?, + &PredefinedMenuItem::select_all(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::quit(app, None)?, + ], + )?; + + let window_menu = Submenu::with_items( + app, + "Window", + true, + &[ + &PredefinedMenuItem::minimize(app, None)?, + &PredefinedMenuItem::maximize(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::close_window(app, None)?, + ], + )?; + + Menu::with_items(app, &[&app_menu, &window_menu]) +} let state = handle.state::(); let _ = commands::check_prereqs(state).await; }); @@ -69,6 +158,8 @@ pub fn run() { commands::download_video, commands::cancel_download, commands::delete_download, + commands::delete_all_downloads, + commands::list_subtitles, commands::get_connectivity, commands::set_library_path, commands::open_external, diff --git a/src/App.tsx b/src/App.tsx index 0c8bcf7..df232b2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { - cancelDownload, deleteDownload, downloadVideo, onRefreshProgress, refreshFeeds, + cancelDownload, deleteAllDownloads, deleteDownload, downloadVideo, + onRefreshProgress, refreshFeeds, } from "./api"; import Player from "./components/Player"; import Settings from "./components/Settings"; @@ -15,7 +16,7 @@ import { useDownloads } from "./hooks/useDownloads"; import { useFeed } from "./hooks/useFeed"; import { useWindowFullscreen } from "./hooks/useWindowFullscreen"; import { - QUALITIES, STREAM_QUALITIES, + QUALITIES, STREAM_QUALITIES, SUB_LANGS, type FeedFilter, type FeedItem, type Quality, type RefreshProgress, } from "./types"; @@ -48,6 +49,14 @@ export default function App() { const [showSettings, setShowSettings] = useState(false); // Index into the current feed, so the player can step through it. const [playingIndex, setPlayingIndex] = useState(null); + const [subLang, setSubLang] = useState(() => { + try { + const stored = localStorage.getItem("flighttube.subLang"); + return SUB_LANGS.some((l) => l.value === stored) ? stored! : "off"; + } catch { + return "off"; + } + }); const [streamQuality, setStreamQuality] = useState(() => { try { const stored = localStorage.getItem("flighttube.streamQuality"); @@ -88,13 +97,14 @@ export default function App() { localStorage.setItem("flighttube.view", view); localStorage.setItem("flighttube.quality", quality); localStorage.setItem("flighttube.streamQuality", streamQuality); + localStorage.setItem("flighttube.subLang", subLang); localStorage.setItem("flighttube.downloadedOnly", downloadedOnly ? "1" : "0"); localStorage.setItem("flighttube.hideShorts", hideShorts ? "1" : "0"); localStorage.setItem("flighttube.sidebarHidden", sidebarHidden ? "1" : "0"); } catch { /* storage blocked */ } - }, [view, quality, streamQuality, downloadedOnly, hideShorts, sidebarHidden]); + }, [view, quality, streamQuality, subLang, downloadedOnly, hideShorts, sidebarHidden]); // Offline, the only videos that can be played are the ones already on disk, // so the feed collapses to those regardless of the toggle. @@ -136,6 +146,10 @@ export default function App() { [channels], ); + // yt-dlp wants a pattern; "en.*" catches "en" and "en-orig" and the + // auto-generated "en" track alike. English is always fetched as a fallback. + const subLangArg = subLang === "off" ? "en.*" : `${subLang}.*,en.*`; + const doRefresh = useCallback(async () => { setRefreshing(true); try { @@ -204,6 +218,29 @@ export default function App() { return () => clearInterval(id); }, [online, refreshing, playingIndex, doRefresh]); + // Refresh once on launch, as soon as there is a connection and something to + // refresh, so the feed is current without anyone pressing anything. + const launched = useRef(false); + useEffect(() => { + if (launched.current || !online || channels.length === 0) return; + launched.current = true; + void doRefresh(); + }, [online, channels.length, doRefresh]); + + const [confirmWipe, setConfirmWipe] = useState(false); + + const wipeDownloads = useCallback(async () => { + setConfirmWipe(false); + try { + const n = await deleteAllDownloads(); + items.forEach((i) => clearLive(i.id)); + await reload(); + say(`Deleted ${n} download${n === 1 ? "" : "s"}`); + } catch (e) { + setFailure(String(e)); + } + }, [items, clearLive, reload, say]); + const emptyMessage = () => { if (loading) return "Loading…"; if (channels.length === 0) @@ -274,6 +311,7 @@ export default function App() { onRefresh={doRefresh} refreshing={refreshing} refreshProgress={refreshProgress} resultCount={items.length} view={view} onView={setView} + onDeleteAll={totals.downloaded > 0 ? () => setConfirmWipe(true) : undefined} sidebarHidden={sidebarHidden} onShowSidebar={() => { setSidebarHidden(false); setSidebarPeek(false); }} titleBarInset={titleBarInset} @@ -311,7 +349,7 @@ export default function App() { online, onOpen: () => openIndex(idx), onDownload: () => - downloadVideo(item.id, quality).catch((e) => setFailure(String(e))), + downloadVideo(item.id, quality, subLangArg).catch((e) => setFailure(String(e))), onCancel: () => cancelDownload(item.id).catch((e) => setFailure(String(e))), onDelete: () => @@ -339,6 +377,7 @@ export default function App() { index={playingIndex} total={items.length} maxHeight={streamQuality === "best" ? null : Number(streamQuality)} + subLang={subLang} titleBarInset={titleBarInset} onPrev={ stepFrom(playingIndex, -1) != null @@ -353,7 +392,7 @@ export default function App() { onDownload={ playing.path === null ? () => { - downloadVideo(playing.item.id, quality).catch((e) => setFailure(String(e))); + downloadVideo(playing.item.id, quality, subLangArg).catch((e) => setFailure(String(e))); say("Download started"); } : undefined @@ -361,7 +400,13 @@ export default function App() { downloading={["queued", "running"].includes( live[playing.item.id]?.state ?? playing.item.state ?? "", )} - onClose={() => { setPlayingIndex(null); reload(); }} + onClose={() => { + setPlayingIndex(null); + // Coming back from a video is the natural moment to pick up + // whatever has been posted since. + if (online && !refreshing) void doRefresh(); + else void reload(); + }} onDelete={async () => { await deleteDownload(playing.item.id); clearLive(playing.item.id); @@ -381,6 +426,8 @@ export default function App() { onQuality={setQuality} streamQuality={streamQuality} onStreamQuality={setStreamQuality} + subLang={subLang} + onSubLang={setSubLang} onError={setFailure} onImported={(n) => { reload(); @@ -389,6 +436,22 @@ export default function App() { /> )} + {confirmWipe && ( + setConfirmWipe(false)} + onConfirm={wipeDownloads} + confirmLabel="Delete all" + destructive + > +

+ All {totals.downloaded} downloaded video + {totals.downloaded === 1 ? "" : "s"} and their subtitles will be removed from + disk. Your subscriptions and the feed are untouched. +

+
+ )} + {failure && ( setFailure(null)}>

{failure}

diff --git a/src/api.ts b/src/api.ts index 7e9b7b2..b9ba341 100644 --- a/src/api.ts +++ b/src/api.ts @@ -24,8 +24,14 @@ export const listFeed = (filter: FeedFilter) => export const refreshFeeds = () => invoke("refresh_feeds"); -export const downloadVideo = (videoId: string, quality: Quality) => - invoke("download_video", { videoId, quality }); +export const downloadVideo = (videoId: string, quality: Quality, subLangs: string) => + invoke("download_video", { videoId, quality, subLangs }); + +export const deleteAllDownloads = () => invoke("delete_all_downloads"); + +/** WebVTT sidecars beside a downloaded video, as [language, path] pairs. */ +export const listSubtitles = (videoId: string) => + invoke>("list_subtitles", { videoId }); 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 1e12d93..c07b868 100644 --- a/src/components/Player.tsx +++ b/src/components/Player.tsx @@ -1,9 +1,9 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { fileUrl, openExternal, resolveStream, savePlayback } from "../api"; +import { fileUrl, listSubtitles, openExternal, resolveStream, savePlayback } from "../api"; import type { FeedItem } from "../types"; import { compactViews, relativeTime } from "./format"; import PlayerControls from "./PlayerControls"; -import { BTN, BTN_CHROME, Spinner } from "./ui"; +import { BTN, Spinner } from "./ui"; interface Props { item: FeedItem; @@ -18,6 +18,8 @@ interface Props { downloading?: boolean; /** Max height for streaming, or null to let the player adapt. */ maxHeight: number | null; + /** Preferred subtitle language, or "off". */ + subLang: string; /** Position in the current feed, for the "3 of 180" readout. */ index: number; total: number; @@ -97,12 +99,28 @@ const RESUME_EDGE_S = 5; */ export default function Player({ item, path, onClose, onDelete, onPrev, onNext, onDownload, downloading, - maxHeight, index, total, titleBarInset, + maxHeight, subLang, index, total, titleBarInset, }: Props) { const streaming = path === null; const [src, setSrc] = useState(path ? fileUrl(path) : null); const [error, setError] = useState(null); const [buffering, setBuffering] = useState(true); + // WebVTT files yt-dlp saved next to a download, so subtitles work offline. + const [sidecars, setSidecars] = useState>([]); + + useEffect(() => { + if (!path) { + setSidecars([]); + return; + } + let cancelled = false; + listSubtitles(item.id) + .then((s) => !cancelled && setSidecars(s)) + .catch(() => !cancelled && setSidecars([])); + return () => { + cancelled = true; + }; + }, [item.id, path]); // Controls and edge arrows fade away while you are just watching. const [chromeVisible, setChromeVisible] = useState(true); const hideTimer = useRef(undefined); @@ -236,17 +254,10 @@ export default function Player({ {item.channel_title} - {streaming ? ( + {streaming && ( {error ? "Unavailable" : src && !buffering ? "Streaming" : "Loading…"} - ) : ( - )} @@ -312,7 +323,17 @@ export default function Player({ onPlaying={() => setBuffering(false)} onSeeked={() => setBuffering(false)} className="absolute inset-0 size-full object-contain" - /> + > + {sidecars.map(([lang, file]) => ( + + ))} + ) : ( error && (
@@ -334,7 +355,12 @@ export default function Player({ chromeVisible ? "opacity-100" : "pointer-events-none opacity-0" }`} > - +
)} @@ -362,11 +388,26 @@ export default function Player({ {downloading ? "Downloading…" : "Download"} )} + {!streaming && ( + + )} diff --git a/src/components/PlayerControls.tsx b/src/components/PlayerControls.tsx index bb795de..b167574 100644 --- a/src/components/PlayerControls.tsx +++ b/src/components/PlayerControls.tsx @@ -34,6 +34,8 @@ interface Props { stageRef: React.RefObject; /** Nudges the auto-hide timer whenever the user does something. */ onActivity?: () => void; + /** Preferred subtitle language, or "off" to start with none. */ + subLang: string; } const SKIP_S = 10; @@ -59,7 +61,7 @@ const btn = * Owning the bar is the only way to get every control into one strip along the * bottom, so the native ones are switched off entirely. */ -export default function PlayerControls({ videoRef, stageRef, onActivity }: Props) { +export default function PlayerControls({ videoRef, stageRef, onActivity, subLang }: Props) { const [playing, setPlaying] = useState(false); const [time, setTime] = useState(0); const [duration, setDuration] = useState(0); @@ -83,15 +85,24 @@ export default function PlayerControls({ videoRef, stageRef, onActivity }: Props }; read(); - // Nothing translated gets forced on. YouTube marks its subtitle track - // AUTOSELECT=YES and WebKit will switch it on when it matches the system - // language; that is the same unwanted auto-selection as a dubbed audio - // track, so subtitles start off and stay a deliberate choice. - const silenceSubs = () => { - for (const t of Array.from(v.textTracks)) t.mode = "disabled"; + // Subtitles follow the language chosen in Settings and nothing else. + // WebKit will otherwise switch on whatever matches the system language, + // which is the same unwanted auto-selection as a dubbed audio track. + const applyPreference = () => { + const tracks = Array.from(v.textTracks); + const wanted = + subLang === "off" + ? undefined + : tracks.find((t) => + (t.language || "").toLowerCase().startsWith(subLang.toLowerCase()), + ); + for (const t of tracks) t.mode = t === wanted ? "showing" : "disabled"; read(); }; - v.addEventListener("loadedmetadata", silenceSubs); + applyPreference(); + v.addEventListener("loadedmetadata", applyPreference); + // HLS subtitle renditions arrive after metadata, so re-apply as they land. + v.textTracks.addEventListener?.("addtrack", applyPreference); v.addEventListener("loadedmetadata", read); const at = (v as VideoWithTracks).audioTracks; @@ -101,14 +112,15 @@ export default function PlayerControls({ videoRef, stageRef, onActivity }: Props const id = setInterval(read, 1000); const stop = setTimeout(() => clearInterval(id), 8000); return () => { - v.removeEventListener("loadedmetadata", silenceSubs); + v.removeEventListener("loadedmetadata", applyPreference); + v.textTracks.removeEventListener?.("addtrack", applyPreference); v.removeEventListener("loadedmetadata", read); at?.removeEventListener?.("addtrack", read); v.textTracks.removeEventListener?.("addtrack", read); clearInterval(id); clearTimeout(stop); }; - }, [videoRef]); + }, [videoRef, subLang]); // Close the menu on any outside click. useEffect(() => { diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 77e360a..d1d3a44 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -4,7 +4,8 @@ import { } from "../api"; import { APPEARANCE_MODES, type Appearance } from "../hooks/useAppearance"; import { - QUALITIES, STREAM_QUALITIES, type ImportPreview, type Prereqs, type Quality, + QUALITIES, STREAM_QUALITIES, SUB_LANGS, + type ImportPreview, type Prereqs, type Quality, } from "../types"; import TakeoutGuide from "./TakeoutGuide"; import { @@ -20,6 +21,8 @@ interface Props { onQuality: (q: Quality) => void; streamQuality: Quality; onStreamQuality: (q: Quality) => void; + subLang: string; + onSubLang: (l: string) => void; onError: (message: string) => void; } @@ -44,7 +47,7 @@ function StatusRow({ label, value }: { label: string; value: string | null }) { export default function Settings({ onClose, onImported, appearance, onAppearance, quality, onQuality, - streamQuality, onStreamQuality, onError, + streamQuality, onStreamQuality, subLang, onSubLang, onError, }: Props) { const [prereqs, setPrereqs] = useState(null); const [pending, setPending] = useState<{ path: string; preview: ImportPreview } | null>(null); @@ -182,6 +185,26 @@ export default function Settings({ Streaming quality applies when you play something you have not downloaded. Best lets the player adapt to your connection; a fixed height pins it.

+ + +

+ Shown automatically when a video has subtitles in this language, including + YouTube's auto-generated ones, and saved alongside anything you download so + they work offline. You can still switch tracks from the player. +

diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index ffc6d72..f5ff6f8 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -21,6 +21,8 @@ interface Props { onView: (v: ViewMode) => void; sidebarHidden: boolean; onShowSidebar: () => void; + /** Present only when there is something to delete. */ + onDeleteAll?: () => void; /** Matches the sidebar's inset so the two headers share a baseline. */ titleBarInset: boolean; } @@ -58,7 +60,7 @@ export default function TopBar({ search, onSearch, downloadedOnly, onDownloadedOnly, hideShorts, onHideShorts, online, reachable, forcedOffline, onToggleForcedOffline, onRefresh, refreshing, refreshProgress, resultCount, view, onView, - sidebarHidden, onShowSidebar, titleBarInset, + sidebarHidden, onShowSidebar, onDeleteAll, titleBarInset, }: Props) { const pct = refreshProgress && refreshProgress.total > 0 ? (refreshProgress.done / refreshProgress.total) * 100 @@ -105,6 +107,20 @@ export default function TopBar({ Downloaded only + {downloadedOnly && onDeleteAll && ( + + )} + onHideShorts(!hideShorts)} title="Hide Shorts from the feed"> Hide Shorts diff --git a/src/main.tsx b/src/main.tsx index 8b1ddb9..67804a9 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,6 +3,17 @@ import ReactDOM from "react-dom/client"; import App from "./App"; import "./index.css"; +// The webview's own context menu offers Reload, Back and Inspect — page +// actions in something that is not meant to read as a page. Text fields keep +// theirs so copy and paste stay reachable. +document.addEventListener("contextmenu", (e) => { + const el = e.target as HTMLElement | null; + const editable = + el?.closest("input, textarea, [contenteditable='true']") !== null && + el?.closest("input, textarea, [contenteditable='true']") !== undefined; + if (!editable) e.preventDefault(); +}); + ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/src/types.ts b/src/types.ts index dda8f16..7e48095 100644 --- a/src/types.ts +++ b/src/types.ts @@ -110,3 +110,15 @@ export const STREAM_QUALITIES: Array<{ value: Quality; label: string }> = [ { value: "720", label: "720p" }, { value: "480", label: "480p" }, ]; + +/** Preferred subtitle language: shown when available, and downloaded. */ +export const SUB_LANGS: Array<{ value: string; label: string }> = [ + { value: "off", label: "None" }, + { value: "en", label: "English" }, + { value: "nl", label: "Nederlands" }, + { value: "de", label: "Deutsch" }, + { value: "fr", label: "Français" }, + { value: "es", label: "Español" }, + { value: "it", label: "Italiano" }, + { value: "pt", label: "Português" }, +];