feat: subtitles, delete-all, auto refresh, and menu cleanup

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 <track> 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 <track>
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.
This commit is contained in:
vincent
2026-08-29 13:25:17 +02:00
parent 73f12cfac1
commit 557467baad
12 changed files with 458 additions and 43 deletions
+62 -2
View File
@@ -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<Vec<(String, String)>, 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 "<base>.<lang>.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<usize, String> {
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)
}