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" }, +];