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