feat: 4K downloads, watch progress, player controls, new icon
Window chrome: titleBarStyle Overlay (Transparent left the native bar in place, white in dark mode, with a dead strip beneath it). The app now paints that strip itself, so it matches the page background. Player: prev/next through the feed, a full-screen button, a spinner while a stream resolves, Open on YouTube on downloaded videos too, and the description collapsed behind a disclosure. Downloads default to Best, which reaches real 4K — above 1080p YouTube serves VP9/AV1, verified to play natively in WKWebView here. Audio stays pinned to AAC because Opus in MP4 would be silent. A Compatible setting keeps the old 1080p H.264 behaviour. Files are now named '<ISO date> - <title> [<id>].mp4'. Watch progress is recorded and drawn under thumbnails like YouTube's, and reopening a video resumes where it left off. Tiles clamp every text line to a fixed height so they share a baseline, and the search field no longer clips its placeholder. Fixes a Picture-in-Picture leak: WebKit kept a detached video playing after the player closed, so a second video could play over the first with no way to stop it. Every exit path now tears the element down.
This commit is contained in:
@@ -220,6 +220,17 @@ async fn yt_dlp_print(args: &[&str]) -> Option<String> {
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// Called periodically while a video plays, and once when the player closes.
|
||||
#[tauri::command]
|
||||
pub async fn save_playback(
|
||||
video_id: String,
|
||||
position: f64,
|
||||
duration: f64,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
state.db.lock().await.save_playback(&video_id, position, duration)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_channels(state: State<'_, AppState>) -> Result<Vec<ChannelWithCount>, String> {
|
||||
state.db.lock().await.list_channels()
|
||||
@@ -333,6 +344,7 @@ async fn cache_thumbnails(state: &State<'_, AppState>) {
|
||||
#[tauri::command]
|
||||
pub async fn download_video(
|
||||
video_id: String,
|
||||
quality: String,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
@@ -362,8 +374,11 @@ pub async fn download_video(
|
||||
.await
|
||||
.map_err(|e| format!("Download queue closed: {e}"))?;
|
||||
|
||||
let out_template = library.join("%(id)s.%(ext)s").to_string_lossy().to_string();
|
||||
let args = downloader::build_args(&video_id, &out_template);
|
||||
let out_template = library
|
||||
.join(downloader::OUTPUT_TEMPLATE)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let args = downloader::build_args(&video_id, &out_template, &quality);
|
||||
|
||||
let mut child = tokio::process::Command::new(bin("yt-dlp"))
|
||||
.args(&args)
|
||||
@@ -462,12 +477,14 @@ pub async fn download_video(
|
||||
drop(permit);
|
||||
|
||||
if status.success() {
|
||||
let path = final_path.unwrap_or_else(|| {
|
||||
library
|
||||
.join(format!("{video_id}.mp4"))
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
});
|
||||
// yt-dlp normally reports the path via `--print after_move:`; if that
|
||||
// line went missing, find the file it wrote by its embedded video id.
|
||||
let path = match final_path {
|
||||
Some(p) => p,
|
||||
None => find_by_video_id(&library, &video_id)
|
||||
.await
|
||||
.ok_or("Download finished but the file could not be located.")?,
|
||||
};
|
||||
let db = state.db.lock().await;
|
||||
db.set_download_state(&video_id, DownloadState::Done, None)?;
|
||||
db.set_download_path(&video_id, &path)?;
|
||||
@@ -531,6 +548,18 @@ pub async fn cancel_download(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Locates a finished download by the `[<id>]` tag in its filename.
|
||||
async fn find_by_video_id(library: &std::path::Path, video_id: &str) -> Option<String> {
|
||||
let mut entries = tokio::fs::read_dir(library).await.ok()?;
|
||||
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.contains(".part") && !name.ends_with(".ytdl") {
|
||||
return Some(entry.path().to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// yt-dlp leaves `.part`, `.ytdl` and format-specific fragments behind when
|
||||
/// killed; without this the library slowly fills with dead bytes.
|
||||
async fn cleanup_partials(library: PathBuf, video_id: &str) {
|
||||
@@ -539,7 +568,7 @@ async fn cleanup_partials(library: PathBuf, video_id: &str) {
|
||||
};
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with(video_id) && (name.contains(".part") || name.ends_with(".ytdl")) {
|
||||
if name.contains(video_id) && (name.contains(".part") || name.ends_with(".ytdl")) {
|
||||
let _ = tokio::fs::remove_file(entry.path()).await;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user