From e553616c52cf80d1c399f256f944d62df7d195ab Mon Sep 17 00:00:00 2001 From: RetricSu Date: Thu, 2 Jul 2026 15:00:50 +0800 Subject: [PATCH 01/30] Fix lyrics and download followups --- README.md | 12 + src/app/components/footer.rs | 2 +- src/app/components/playback_info_panel.rs | 28 ++ src/app/components/player_component.rs | 2 +- src/app/components/playlist_table/services.rs | 2 +- src/app/components/playlist_tabs.rs | 16 +- src/app/components/window_chrome.rs | 12 +- src/app/core.rs | 55 ++- src/app/i18n.rs | 49 +++ src/app/state/ui_state.rs | 29 ++ src/lib/services/youtube_download.rs | 315 ++++++++++++++++-- 11 files changed, 488 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 86a3d98..1e4f34e 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,18 @@ cargo build --release The compiled binary will be available in `target/release/bird-player`. +### macOS App Bundle + +Build a macOS `.app` bundle with: + +```bash +cargo bundle --release +``` + +The bundle is written to `target/release/bundle/osx/Bird Player.app`. To install it locally, copy it to `/Applications`. + +When the app is launched from Finder, Dock, or LaunchServices, it does not inherit the interactive shell `PATH`. Any bundled build that calls user-installed command line tools must set a runtime `PATH` explicitly. Bird Player currently does this for `yt-dlp` by adding common user binary directories such as `~/.local/bin`, `~/bin`, `/opt/homebrew/bin`, and `/usr/local/bin` before spawning the command. Keep this in mind when adding future external tools, otherwise a tool that works in Terminal may fail inside the packaged app with "not found". + ## Usage 1. Launch Bird Player: diff --git a/src/app/components/footer.rs b/src/app/components/footer.rs index 7438793..b96ee0c 100644 --- a/src/app/components/footer.rs +++ b/src/app/components/footer.rs @@ -232,7 +232,7 @@ impl AppComponent for Footer { } if fetch_lyrics { - ctx.fetch_lyrics_for_current_track(); + ctx.auto_fetch_lyrics_for_current_track(); } }); }); diff --git a/src/app/components/playback_info_panel.rs b/src/app/components/playback_info_panel.rs index 7c81c30..5d1361d 100644 --- a/src/app/components/playback_info_panel.rs +++ b/src/app/components/playback_info_panel.rs @@ -65,6 +65,15 @@ impl PlaybackInfoPanel { .hint_text("https://www.youtube.com/watch?v=..."), ); + ui.add_space(tokens::spacing::XS); + ui.add_enabled_ui(!ctx.ui_state.youtube_download_in_progress, |ui| { + ui.checkbox( + &mut ctx.ui_state.youtube_download_include_playlist, + t("download_entire_playlist"), + ) + .on_hover_text(t("download_entire_playlist_hint")); + }); + ui.add_space(tokens::spacing::XS); ui.label(t("save_to")); ui.horizontal(|ui| { @@ -95,6 +104,22 @@ impl PlaybackInfoPanel { ui.label(RichText::new(status).weak()); } + if let Some(progress) = ctx.ui_state.youtube_download_progress { + ui.add_space(tokens::spacing::XS); + ui.add( + egui::ProgressBar::new(progress) + .show_percentage() + .desired_width(ui.available_width()), + ); + } else if ctx.ui_state.youtube_download_in_progress { + ui.add_space(tokens::spacing::XS); + ui.add( + egui::ProgressBar::new(0.0) + .animate(true) + .desired_width(ui.available_width()), + ); + } + ui.add_space(tokens::spacing::SM); ui.horizontal(|ui| { let can_download = !ctx.ui_state.youtube_download_in_progress @@ -114,6 +139,9 @@ impl PlaybackInfoPanel { .clicked() { ctx.ui_state.youtube_download_url.clear(); + ctx.ui_state.youtube_download_progress = None; + ctx.ui_state.youtube_download_last_file_count = None; + ctx.ui_state.youtube_download_resync_in_progress = false; ctx.ui_state.youtube_download_status = None; } }); diff --git a/src/app/components/player_component.rs b/src/app/components/player_component.rs index eedb61e..417ec6a 100644 --- a/src/app/components/player_component.rs +++ b/src/app/components/player_component.rs @@ -311,7 +311,7 @@ impl AppComponent for PlayerComponent { } } if fetch_lyrics { - ctx.fetch_lyrics_for_current_track(); + ctx.auto_fetch_lyrics_for_current_track(); } } diff --git a/src/app/components/playlist_table/services.rs b/src/app/components/playlist_table/services.rs index fe0c676..5554f41 100644 --- a/src/app/components/playlist_table/services.rs +++ b/src/app/components/playlist_table/services.rs @@ -80,7 +80,7 @@ impl<'a> PlaylistTableService<'a> { } self.ctx.app_settings.playing_playlist_idx = Some(self.playlist_idx); - self.ctx.fetch_lyrics_for_current_track(); + self.ctx.auto_fetch_lyrics_for_current_track(); } pub(crate) fn remove_track(&mut self, idx: usize) { diff --git a/src/app/components/playlist_tabs.rs b/src/app/components/playlist_tabs.rs index a38ed7e..9bd0631 100644 --- a/src/app/components/playlist_tabs.rs +++ b/src/app/components/playlist_tabs.rs @@ -12,7 +12,21 @@ impl AppComponent for PlaylistTabs { fn add(ctx: &mut Self::Context, ui: &mut eframe::egui::Ui) { // Caller (PlaylistContent) wraps us in style::panel_header which - // already provides ui.horizontal + min_height — render directly. + // already provides ui.horizontal + min_height. The tab strip itself + // may be wider than the panel, so keep it horizontally scrollable. + egui::ScrollArea::horizontal() + .id_salt("playlist_tabs_scroll") + .auto_shrink([false, true]) + .show(ui, |ui| { + ui.horizontal(|ui| { + Self::show_tabs(ctx, ui); + }); + }); + } +} + +impl PlaylistTabs { + fn show_tabs(ctx: &mut App, ui: &mut eframe::egui::Ui) { // Add playlist tabs for (idx, playlist) in ctx.playlists.iter_mut().enumerate() { let is_selected = ctx.app_settings.current_playlist_idx == Some(idx); diff --git a/src/app/components/window_chrome.rs b/src/app/components/window_chrome.rs index a4cb7be..d24104e 100644 --- a/src/app/components/window_chrome.rs +++ b/src/app/components/window_chrome.rs @@ -138,7 +138,7 @@ impl AppComponent for WindowChrome { }); if fetch_lyrics { - ctx.fetch_lyrics_for_current_track(); + ctx.auto_fetch_lyrics_for_current_track(); } // Add View menu @@ -149,9 +149,17 @@ impl AppComponent for WindowChrome { t("show_lyrics") }; if ui.button(lyrics_text).clicked() { - ctx.ui_state.show_lyrics_panel = !ctx.ui_state.show_lyrics_panel; + let will_show = !ctx.ui_state.show_lyrics_panel; + ctx.ui_state.show_lyrics_panel = will_show; + if will_show { + ctx.fetch_lyrics_for_current_track(); + } ui.close_menu(); } + ui.checkbox( + &mut ctx.ui_state.auto_fetch_missing_lyrics, + t("auto_fetch_missing_lyrics"), + ); ui.separator(); diff --git a/src/app/core.rs b/src/app/core.rs index 8285afe..8ba65eb 100644 --- a/src/app/core.rs +++ b/src/app/core.rs @@ -208,7 +208,7 @@ impl App { // Fetch lyrics for restored track if needed if should_fetch_lyrics { self.ui_state.should_fetch_lyrics_on_init = true; - self.fetch_lyrics_for_current_track(); + self.auto_fetch_lyrics_for_current_track(); self.ui_state.should_fetch_lyrics_on_init = false; } @@ -302,21 +302,35 @@ impl App { } self.ui_state.youtube_download_in_progress = true; - self.ui_state.youtube_download_status = Some(i18n::t("downloading")); + self.ui_state.youtube_download_resync_in_progress = false; + self.ui_state.youtube_download_progress = None; + self.ui_state.youtube_download_last_file_count = None; + self.ui_state.youtube_download_status = Some(i18n::t("download_preparing")); YoutubeDownloadService::download_authorized_audio( url, self.ui_state.youtube_download_dir.clone(), + self.ui_state.youtube_download_include_playlist, self.youtube_download_tx().clone(), ); } pub fn handle_youtube_download_event(&mut self, event: YoutubeDownloadEvent) { - self.ui_state.youtube_download_in_progress = false; - match event { + YoutubeDownloadEvent::Progress(progress) => { + self.ui_state.youtube_download_in_progress = true; + self.ui_state.youtube_download_progress = Some(progress); + self.ui_state.youtube_download_status = Some(i18n::tf( + "download_progress", + &[&format!("{:.0}", progress * 100.0)], + )); + } YoutubeDownloadEvent::Finished(Ok(result)) => { + self.ui_state.youtube_download_in_progress = false; + self.ui_state.youtube_download_progress = None; let file_count = result.downloaded_files.len(); + self.ui_state.youtube_download_last_file_count = Some(file_count); + self.ui_state.youtube_download_resync_in_progress = true; self.ui_state.youtube_download_status = Some(if file_count == 0 { i18n::t("download_finished_resync") } else { @@ -337,6 +351,10 @@ impl App { } } YoutubeDownloadEvent::Finished(Err(err)) => { + self.ui_state.youtube_download_in_progress = false; + self.ui_state.youtube_download_resync_in_progress = false; + self.ui_state.youtube_download_progress = None; + self.ui_state.youtube_download_last_file_count = None; self.ui_state.youtube_download_status = Some(err); } } @@ -499,7 +517,7 @@ impl App { if let Some(playlist) = playlist_clone { PlayerService::next_track(self.player_mut_ref(), &playlist); } - self.fetch_lyrics_for_current_track(); + self.auto_fetch_lyrics_for_current_track(); } AudioEvent::PlaybackStateChanged(is_playing) => { tracing::info!( @@ -528,6 +546,7 @@ impl App { /// Process a freshly received lyrics response. pub fn handle_lyrics_response(&mut self, should_show_panel: bool) { + let should_show_panel = should_show_panel && self.ui_state.auto_fetch_missing_lyrics; let track_key = self .runtime .as_ref() @@ -564,13 +583,37 @@ impl App { pub fn process_library_command(&mut self, lib_cmd: LibraryCommand) { if matches!(lib_cmd, LibraryCommand::AddPathId(_)) { self.ui_state.is_importing = false; + if self.ui_state.youtube_download_resync_in_progress { + self.ui_state.youtube_download_resync_in_progress = false; + self.ui_state.youtube_download_progress = None; + if let Some(file_count) = self.ui_state.youtube_download_last_file_count { + self.ui_state.youtube_download_status = Some(i18n::tf( + "downloaded_files_done", + &[&file_count.to_string()], + )); + } + } // Also explicitly save state after completing an import! self.save_state(); } LibraryService::process_library_command(&mut self.library, lib_cmd); } - /// Fetch lyrics for the currently selected track + /// Fetch lyrics after an automatic track change, honoring the user preference. + pub fn auto_fetch_lyrics_for_current_track(&mut self) { + if !self.ui_state.auto_fetch_missing_lyrics { + let lyrics_manager = self.lyrics_manager_mut(); + lyrics_manager.set_current_lyrics(None); + let _ = lyrics_manager.take_pending_lyrics_rx(); + self.ui_state.lyrics_fetch_state = LyricsFetchState::Idle; + self.ui_state.show_lyrics_panel = false; + return; + } + + self.fetch_lyrics_for_current_track(); + } + + /// Fetch lyrics for the currently selected track. pub fn fetch_lyrics_for_current_track(&mut self) { if self.runtime.is_none() { tracing::warn!("⚠️ Player not available for lyrics fetch"); diff --git a/src/app/i18n.rs b/src/app/i18n.rs index 193b529..e73ab96 100644 --- a/src/app/i18n.rs +++ b/src/app/i18n.rs @@ -57,6 +57,10 @@ pub fn init() { en.insert("view".to_string(), "View".to_string()); en.insert("show_lyrics".to_string(), "Show Lyrics".to_string()); en.insert("hide_lyrics".to_string(), "Hide Lyrics".to_string()); + en.insert( + "auto_fetch_missing_lyrics".to_string(), + "Auto-fetch missing lyrics on track change".to_string(), + ); // Help menu en.insert("help".to_string(), "Help".to_string()); @@ -119,6 +123,15 @@ pub fn init() { "Only download audio you are authorized to save locally.".to_string(), ); en.insert("youtube_url".to_string(), "YouTube URL".to_string()); + en.insert( + "download_entire_playlist".to_string(), + "Download entire playlist".to_string(), + ); + en.insert( + "download_entire_playlist_hint".to_string(), + "When off, playlist parameters are removed and only the current video is downloaded." + .to_string(), + ); en.insert("save_to".to_string(), "Save to".to_string()); en.insert( "choose_download_folder".to_string(), @@ -127,7 +140,15 @@ pub fn init() { en.insert("download".to_string(), "Download".to_string()); en.insert("clear".to_string(), "Clear".to_string()); en.insert("url_required".to_string(), "URL is required".to_string()); + en.insert( + "download_preparing".to_string(), + "Preparing download...".to_string(), + ); en.insert("downloading".to_string(), "Downloading...".to_string()); + en.insert( + "download_progress".to_string(), + "Downloading... {}%".to_string(), + ); en.insert( "download_finished_resync".to_string(), "Download finished. Re-syncing folder...".to_string(), @@ -136,6 +157,10 @@ pub fn init() { "downloaded_files_resync".to_string(), "Downloaded {} file(s). Re-syncing folder...".to_string(), ); + en.insert( + "downloaded_files_done".to_string(), + "Downloaded {} file(s). Folder is synced.".to_string(), + ); // Playlist tabs component en.insert("rename".to_string(), "Rename".to_string()); @@ -185,6 +210,10 @@ pub fn init() { zh.insert("view".to_string(), "视图".to_string()); zh.insert("show_lyrics".to_string(), "显示歌词".to_string()); zh.insert("hide_lyrics".to_string(), "隐藏歌词".to_string()); + zh.insert( + "auto_fetch_missing_lyrics".to_string(), + "切歌时自动获取缺失歌词".to_string(), + ); // Help menu zh.insert("help".to_string(), "帮助".to_string()); @@ -238,6 +267,14 @@ pub fn init() { "请只下载你有权保存到本地的音频。".to_string(), ); zh.insert("youtube_url".to_string(), "YouTube URL".to_string()); + zh.insert( + "download_entire_playlist".to_string(), + "下载整个播放列表".to_string(), + ); + zh.insert( + "download_entire_playlist_hint".to_string(), + "关闭时会移除播放列表参数,只下载当前视频。".to_string(), + ); zh.insert("save_to".to_string(), "保存到".to_string()); zh.insert( "choose_download_folder".to_string(), @@ -246,7 +283,15 @@ pub fn init() { zh.insert("download".to_string(), "下载".to_string()); zh.insert("clear".to_string(), "清空".to_string()); zh.insert("url_required".to_string(), "请输入 URL".to_string()); + zh.insert( + "download_preparing".to_string(), + "正在准备下载...".to_string(), + ); zh.insert("downloading".to_string(), "正在下载...".to_string()); + zh.insert( + "download_progress".to_string(), + "正在下载... {}%".to_string(), + ); zh.insert( "download_finished_resync".to_string(), "下载完成,正在重新同步文件夹...".to_string(), @@ -255,6 +300,10 @@ pub fn init() { "downloaded_files_resync".to_string(), "已下载 {} 个文件,正在重新同步文件夹...".to_string(), ); + zh.insert( + "downloaded_files_done".to_string(), + "已下载 {} 个文件,文件夹已同步。".to_string(), + ); // Playlist tabs component zh.insert("rename".to_string(), "重命名".to_string()); diff --git a/src/app/state/ui_state.rs b/src/app/state/ui_state.rs index b295a0b..84416d2 100644 --- a/src/app/state/ui_state.rs +++ b/src/app/state/ui_state.rs @@ -28,6 +28,9 @@ pub struct UiState { /// Whether the lyrics panel is shown pub show_lyrics_panel: bool, + /// Whether track changes may fetch missing lyrics from the network and open the panel. + pub auto_fetch_missing_lyrics: bool, + /// Whether the desktop lyrics mode is active pub desktop_lyrics_enabled: bool, @@ -55,6 +58,18 @@ pub struct UiState { /// Whether a yt-dlp download task is currently running. pub youtube_download_in_progress: bool, + /// Whether a completed download is being imported into the library. + pub youtube_download_resync_in_progress: bool, + + /// Latest yt-dlp download progress, from 0.0 to 1.0. + pub youtube_download_progress: Option, + + /// Last completed authorized-audio download file count. + pub youtube_download_last_file_count: Option, + + /// Whether playlist URLs should download every playlist item. + pub youtube_download_include_playlist: bool, + /// Last user-facing download status or error message. pub youtube_download_status: Option, @@ -87,6 +102,7 @@ impl Default for UiState { default_window_height: crate::app::constants::DEFAULT_WINDOW_HEIGHT as f64, is_maximized: false, show_lyrics_panel: false, + auto_fetch_missing_lyrics: true, desktop_lyrics_enabled: false, should_fetch_lyrics_on_init: false, lyrics_fetch_state: LyricsFetchState::Idle, @@ -96,6 +112,10 @@ impl Default for UiState { youtube_download_url: String::new(), youtube_download_dir: YoutubeDownloadService::default_download_dir(), youtube_download_in_progress: false, + youtube_download_resync_in_progress: false, + youtube_download_progress: None, + youtube_download_last_file_count: None, + youtube_download_include_playlist: false, youtube_download_status: None, volume_before_mute: None, last_persistence_save: Instant::now(), @@ -112,6 +132,8 @@ pub struct UiSettings { pub library_folders_expanded: bool, pub default_window_height: f64, pub show_lyrics_panel: bool, + #[serde(default = "default_auto_fetch_missing_lyrics")] + pub auto_fetch_missing_lyrics: bool, pub desktop_lyrics_enabled: bool, #[serde(default = "default_desktop_lyrics_font_size")] pub desktop_lyrics_font_size: f32, @@ -125,6 +147,10 @@ fn default_desktop_lyrics_font_size() -> f32 { 48.0 } +fn default_auto_fetch_missing_lyrics() -> bool { + true +} + fn default_desktop_lyrics_color() -> [u8; 4] { [0, 255, 255, 255] } @@ -135,6 +161,7 @@ impl Default for UiSettings { library_folders_expanded: false, default_window_height: crate::app::constants::DEFAULT_WINDOW_HEIGHT as f64, show_lyrics_panel: false, + auto_fetch_missing_lyrics: true, desktop_lyrics_enabled: false, desktop_lyrics_font_size: default_desktop_lyrics_font_size(), desktop_lyrics_color: default_desktop_lyrics_color(), @@ -150,6 +177,7 @@ impl UiState { library_folders_expanded: self.library_folders_expanded, default_window_height: self.default_window_height, show_lyrics_panel: self.show_lyrics_panel, + auto_fetch_missing_lyrics: self.auto_fetch_missing_lyrics, desktop_lyrics_enabled: self.desktop_lyrics_enabled, desktop_lyrics_font_size: self.desktop_lyrics_font_size, desktop_lyrics_color: self.desktop_lyrics_color, @@ -162,6 +190,7 @@ impl UiState { self.library_folders_expanded = settings.library_folders_expanded; self.default_window_height = settings.default_window_height; self.show_lyrics_panel = settings.show_lyrics_panel; + self.auto_fetch_missing_lyrics = settings.auto_fetch_missing_lyrics; self.desktop_lyrics_enabled = settings.desktop_lyrics_enabled; self.desktop_lyrics_font_size = settings.desktop_lyrics_font_size; self.desktop_lyrics_color = settings.desktop_lyrics_color; diff --git a/src/lib/services/youtube_download.rs b/src/lib/services/youtube_download.rs index a134a48..be89b15 100644 --- a/src/lib/services/youtube_download.rs +++ b/src/lib/services/youtube_download.rs @@ -1,9 +1,12 @@ +use std::ffi::OsString; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Stdio}; use std::sync::mpsc::Sender; +use std::time::Duration; #[derive(Debug, Clone)] pub enum YoutubeDownloadEvent { + Progress(f32), Finished(Result), } @@ -15,6 +18,8 @@ pub struct YoutubeDownloadResult { pub struct YoutubeDownloadService; +const PROGRESS_PREFIX: &str = "BIRD_PLAYER_PROGRESS:"; + impl YoutubeDownloadService { pub fn default_download_dir() -> PathBuf { std::env::var_os("HOME") @@ -28,25 +33,32 @@ impl YoutubeDownloadService { pub fn download_authorized_audio( url: String, output_dir: PathBuf, + include_playlist: bool, event_tx: Sender, ) { std::thread::spawn(move || { - let result = Self::run_download(&url, &output_dir); + let result = Self::run_download(&url, &output_dir, include_playlist, &event_tx); let _ = event_tx.send(YoutubeDownloadEvent::Finished(result)); }); } - fn run_download(url: &str, output_dir: &Path) -> Result { + fn run_download( + url: &str, + output_dir: &Path, + include_playlist: bool, + event_tx: &Sender, + ) -> Result { let url = url.trim(); if url.is_empty() { return Err("URL is required".to_string()); } + let url = Self::playlist_safe_url(url, include_playlist); if let Err(err) = std::fs::create_dir_all(output_dir) { return Err(format!("Failed to create output folder: {}", err)); } - if Command::new("yt-dlp").arg("--version").output().is_err() { + if Self::yt_dlp_command().arg("--version").output().is_err() { return Err( "yt-dlp was not found. Install yt-dlp and make sure it is available in PATH." .to_string(), @@ -56,7 +68,19 @@ impl YoutubeDownloadService { let output_template = output_dir .join("%(artist,creator,uploader|Unknown Artist)s - %(title)s [%(id)s].%(ext)s"); - let output = Command::new("yt-dlp") + let mut command = Self::yt_dlp_command(); + command + .arg("--color") + .arg("never") + .arg("--newline") + .arg("--progress") + .arg("--progress-delta") + .arg("0.5") + .arg("--progress-template") + .arg(format!( + "download:{}%(progress._percent_str)s", + PROGRESS_PREFIX + )) .arg("--extract-audio") .arg("--audio-format") .arg("mp3") @@ -64,36 +88,283 @@ impl YoutubeDownloadService { .arg("0") .arg("--embed-metadata") .arg("--embed-thumbnail") - .arg("--restrict-filenames") + .arg("--restrict-filenames"); + + if include_playlist { + command.arg("--yes-playlist"); + } else { + command.arg("--no-playlist"); + } + + let mut child = command .arg("--print") .arg("after_move:filepath") .arg("--output") .arg(output_template) .arg("--") .arg(url) - .output() + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() .map_err(|err| format!("Failed to start yt-dlp: {}", err))?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - return Err(if stderr.is_empty() { - format!("yt-dlp failed with status {}", output.status) - } else { - stderr - }); - } + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let (line_tx, line_rx) = std::sync::mpsc::channel(); + let stdout_handle = stdout.map(|stream| Self::read_lines(stream, line_tx.clone())); + let stderr_handle = stderr.map(|stream| Self::read_lines(stream, line_tx)); - let downloaded_files = String::from_utf8_lossy(&output.stdout) - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .map(PathBuf::from) - .filter(|path| path.exists()) - .collect::>(); + let mut downloaded_files = Vec::new(); + let mut output_lines = Vec::new(); + + loop { + if let Ok(line) = line_rx.recv_timeout(Duration::from_millis(100)) { + Self::handle_download_output_line( + &line, + &mut downloaded_files, + &mut output_lines, + event_tx, + ); + for line in line_rx.try_iter() { + Self::handle_download_output_line( + &line, + &mut downloaded_files, + &mut output_lines, + event_tx, + ); + } + } + + match child + .try_wait() + .map_err(|err| format!("Failed to wait for yt-dlp: {}", err))? + { + Some(status) => { + if let Some(handle) = stdout_handle { + let _ = handle.join(); + } + if let Some(handle) = stderr_handle { + let _ = handle.join(); + } + for line in line_rx.try_iter() { + Self::handle_download_output_line( + &line, + &mut downloaded_files, + &mut output_lines, + event_tx, + ); + } + + if !status.success() { + let details = output_lines + .iter() + .rev() + .take(8) + .cloned() + .collect::>() + .into_iter() + .rev() + .collect::>() + .join("\n"); + return Err(if details.trim().is_empty() { + format!("yt-dlp failed with status {}", status) + } else { + details + }); + } + break; + } + None => {} + } + } Ok(YoutubeDownloadResult { output_dir: output_dir.to_path_buf(), downloaded_files, }) } + + fn yt_dlp_command() -> Command { + let mut command = Command::new("yt-dlp"); + command.env("PATH", Self::app_runtime_path()); + command + } + + fn app_runtime_path() -> OsString { + let mut paths = std::env::var_os("PATH") + .map(|path| std::env::split_paths(&path).collect::>()) + .unwrap_or_default(); + + if let Some(home) = std::env::var_os("HOME") { + let home = PathBuf::from(home); + Self::push_path_once(&mut paths, home.join(".local/bin")); + Self::push_path_once(&mut paths, home.join("bin")); + } + + for path in [ + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + ] { + Self::push_path_once(&mut paths, PathBuf::from(path)); + } + + std::env::join_paths(paths).unwrap_or_else(|_| { + OsString::from("/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin") + }) + } + + fn push_path_once(paths: &mut Vec, path: PathBuf) { + if !paths.iter().any(|existing| existing == &path) { + paths.push(path); + } + } + + fn read_lines( + stream: R, + line_tx: std::sync::mpsc::Sender, + ) -> std::thread::JoinHandle<()> { + std::thread::spawn(move || { + use std::io::BufRead; + + let reader = std::io::BufReader::new(stream); + for line in reader.lines().map_while(Result::ok) { + let _ = line_tx.send(line); + } + }) + } + + fn handle_download_output_line( + line: &str, + downloaded_files: &mut Vec, + output_lines: &mut Vec, + event_tx: &Sender, + ) { + let trimmed = line.trim(); + if trimmed.is_empty() { + return; + } + + let is_progress_line = if let Some(progress) = + Self::parse_machine_progress(trimmed).or_else(|| Self::parse_progress(trimmed)) + { + let _ = event_tx.send(YoutubeDownloadEvent::Progress(progress)); + true + } else { + false + }; + + let path = PathBuf::from(trimmed); + if path.exists() { + downloaded_files.push(path); + } else if !is_progress_line { + output_lines.push(trimmed.to_string()); + } + } + + fn parse_machine_progress(line: &str) -> Option { + let progress_text = line.strip_prefix(PROGRESS_PREFIX)?.trim(); + Self::parse_progress(progress_text) + } + + fn parse_progress(line: &str) -> Option { + let percent_pos = line.find('%')?; + let before_percent = &line[..percent_pos]; + let start = before_percent + .rfind(|ch: char| !(ch.is_ascii_digit() || ch == '.')) + .map_or(0, |idx| idx + 1); + before_percent[start..] + .parse::() + .ok() + .map(|percent| (percent / 100.0).clamp(0.0, 1.0)) + } + + fn playlist_safe_url(url: &str, include_playlist: bool) -> String { + if include_playlist { + return url.to_string(); + } + + let (url_without_fragment, fragment) = url + .split_once('#') + .map_or((url, None), |(url, fragment)| (url, Some(fragment))); + + let Some((base, query)) = url_without_fragment.split_once('?') else { + return url.to_string(); + }; + + let kept_params = query + .split('&') + .filter(|param| { + let key = param.split_once('=').map_or(*param, |(key, _)| key); + !matches!(key, "list" | "index" | "start_radio" | "pp") + }) + .collect::>(); + + let mut rebuilt = if kept_params.is_empty() { + base.to_string() + } else { + format!("{}?{}", base, kept_params.join("&")) + }; + if let Some(fragment) = fragment { + rebuilt.push('#'); + rebuilt.push_str(fragment); + } + rebuilt + } +} + +#[cfg(test)] +mod tests { + use super::YoutubeDownloadService; + + #[test] + fn playlist_safe_url_removes_playlist_params_by_default() { + let url = "https://www.youtube.com/watch?v=P8jOQUsTU9o&list=RDGMEM6ijAnFTG9nX1G-kbWBUCJA&index=7&pp=8AUB"; + + assert_eq!( + YoutubeDownloadService::playlist_safe_url(url, false), + "https://www.youtube.com/watch?v=P8jOQUsTU9o" + ); + } + + #[test] + fn playlist_safe_url_keeps_playlist_when_enabled() { + let url = + "https://www.youtube.com/watch?v=P8jOQUsTU9o&list=RDGMEM6ijAnFTG9nX1G-kbWBUCJA&index=7"; + + assert_eq!(YoutubeDownloadService::playlist_safe_url(url, true), url); + } + + #[test] + fn parse_progress_reads_yt_dlp_percentages() { + let progress = YoutubeDownloadService::parse_progress("[download] 42.5% of 4.00MiB") + .expect("progress should parse"); + + assert!((progress - 0.425).abs() < f32::EPSILON); + } + + #[test] + fn parse_machine_progress_requires_bird_player_prefix() { + let progress = + YoutubeDownloadService::parse_machine_progress("BIRD_PLAYER_PROGRESS: 42.5%") + .expect("machine progress should parse"); + + assert!((progress - 0.425).abs() < f32::EPSILON); + assert!(YoutubeDownloadService::parse_machine_progress("[download] 42.5%").is_none()); + } + + #[test] + fn app_runtime_path_includes_user_binary_dirs() { + let path = YoutubeDownloadService::app_runtime_path(); + let paths = std::env::split_paths(&path).collect::>(); + + if let Some(home) = std::env::var_os("HOME") { + assert!(paths.contains(&std::path::PathBuf::from(home).join(".local/bin"))); + } + assert!(paths.contains(&std::path::PathBuf::from("/opt/homebrew/bin"))); + assert!(paths.contains(&std::path::PathBuf::from("/usr/local/bin"))); + } } From 32ea6e1483693a61e71e645c37363f3219927f51 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Thu, 2 Jul 2026 15:06:22 +0800 Subject: [PATCH 02/30] Polish download button and scrollbars --- src/app/bootstrap.rs | 1 + src/app/components/playback_info_panel.rs | 7 ++++++- src/app/components/playlist_tabs.rs | 1 + src/app/style/mod.rs | 16 ++++++++++++++++ 4 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/app/bootstrap.rs b/src/app/bootstrap.rs index efc2c37..92c9f75 100644 --- a/src/app/bootstrap.rs +++ b/src/app/bootstrap.rs @@ -91,6 +91,7 @@ pub fn start_app() -> Result<()> { cc.egui_ctx.style_mut(|style| { crate::app::style::apply_brand_visuals(&mut style.visuals); crate::app::style::apply_compact_spacing(&mut style.spacing); + crate::app::style::apply_light_scrollbars(&mut style.spacing); }); Ok(Box::new(app)) diff --git a/src/app/components/playback_info_panel.rs b/src/app/components/playback_info_panel.rs index 5d1361d..f52ce94 100644 --- a/src/app/components/playback_info_panel.rs +++ b/src/app/components/playback_info_panel.rs @@ -32,7 +32,12 @@ impl PlaybackInfoPanel { let active = ctx.ui_state.show_youtube_download_dialog || ctx.ui_state.youtube_download_in_progress; let button = ui - .add(player_button(icons::DOWNLOAD, active)) + .allocate_ui_with_layout( + egui::vec2(tokens::size::ICON_BTN, tokens::size::ICON_BTN), + egui::Layout::centered_and_justified(egui::Direction::TopDown), + |ui| ui.add(player_button(icons::DOWNLOAD, active)), + ) + .inner .on_hover_text(t("download_authorized_audio")); if button.clicked() { diff --git a/src/app/components/playlist_tabs.rs b/src/app/components/playlist_tabs.rs index 9bd0631..be2ec2c 100644 --- a/src/app/components/playlist_tabs.rs +++ b/src/app/components/playlist_tabs.rs @@ -17,6 +17,7 @@ impl AppComponent for PlaylistTabs { egui::ScrollArea::horizontal() .id_salt("playlist_tabs_scroll") .auto_shrink([false, true]) + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) .show(ui, |ui| { ui.horizontal(|ui| { Self::show_tabs(ctx, ui); diff --git a/src/app/style/mod.rs b/src/app/style/mod.rs index 9cb9c1b..c83934c 100644 --- a/src/app/style/mod.rs +++ b/src/app/style/mod.rs @@ -57,6 +57,22 @@ pub fn apply_compact_spacing(spacing: &mut eframe::egui::style::Spacing) { spacing.interact_size.y = 22.0; } +/// Keep scrollbars in the quiet overlay style macOS users expect. egui's +/// default floating bar expands to 10 px on hover, which reads as a heavy +/// border in compact headers and tables. +pub fn apply_light_scrollbars(spacing: &mut eframe::egui::style::Spacing) { + spacing.scroll = eframe::egui::style::ScrollStyle::floating(); + spacing.scroll.bar_width = 5.0; + spacing.scroll.floating_width = 2.0; + spacing.scroll.floating_allocated_width = 0.0; + spacing.scroll.bar_inner_margin = 0.0; + spacing.scroll.bar_outer_margin = 0.0; + spacing.scroll.active_background_opacity = 0.0; + spacing.scroll.interact_background_opacity = 0.0; + spacing.scroll.active_handle_opacity = 0.45; + spacing.scroll.interact_handle_opacity = 0.75; +} + pub trait SliderExt { fn volume_style(self) -> Self; } From 1a51e9304d0170b294cb0530286ae68794ee567e Mon Sep 17 00:00:00 2001 From: RetricSu Date: Thu, 2 Jul 2026 15:21:11 +0800 Subject: [PATCH 03/30] Fix library sync and playlist removal polish --- src/app/components/footer.rs | 44 +++- src/app/components/library_component.rs | 212 +++++++++--------- src/app/components/playlist_table/services.rs | 2 +- src/app/components/playlist_table/view.rs | 1 + src/lib/library.rs | 80 ++++++- 5 files changed, 215 insertions(+), 124 deletions(-) diff --git a/src/app/components/footer.rs b/src/app/components/footer.rs index b96ee0c..87eba66 100644 --- a/src/app/components/footer.rs +++ b/src/app/components/footer.rs @@ -1,6 +1,7 @@ use super::AppComponent; -use crate::app::style::icons; +use crate::app::style::{icons, tokens, ButtonExt}; use crate::app::App; +use eframe::egui::{Button, Frame, Margin, RichText, Stroke, TextEdit}; pub struct Footer; @@ -52,7 +53,7 @@ impl AppComponent for Footer { ui.horizontal(|ui| { // Add the search text field let response = ui.add( - eframe::egui::TextEdit::singleline(&mut search_text) + TextEdit::singleline(&mut search_text) .id(editor_id) .desired_width(200.0) .hint_text("Type to search..."), @@ -64,7 +65,10 @@ impl AppComponent for Footer { }); // Add a search button that only triggers when clicked - if ui.button(icons::SEARCH).clicked() + if ui + .add(Button::new(icons::SEARCH).player_style()) + .on_hover_text("Search") + .clicked() || (response.lost_focus() && ui.input(|i| i.key_pressed(eframe::egui::Key::Enter))) { @@ -141,7 +145,11 @@ impl AppComponent for Footer { } // Close button to exit search mode - if ui.button(icons::CLOSE).clicked() { + if ui + .add(Button::new(icons::CLOSE).player_style()) + .on_hover_text("Close search") + .clicked() + { search_active = false; search_text.clear(); ui.memory_mut(|mem| { @@ -165,16 +173,23 @@ impl AppComponent for Footer { }) { if !results.is_empty() { // Container for results with scrolling - eframe::egui::Frame::popup(ui.style()) - .stroke(eframe::egui::Stroke::new( - 1.0, - ui.style().visuals.widgets.active.bg_fill, + Frame::popup(ui.style()) + .corner_radius(tokens::radius::SM) + .inner_margin(Margin::symmetric( + tokens::spacing::SM as i8, + tokens::spacing::XS as i8, + )) + .stroke(Stroke::new( + tokens::size::STROKE_WIDTH, + ui.visuals().widgets.noninteractive.bg_stroke.color, )) .show(ui, |ui| { ui.set_max_width(400.0); ui.set_max_height(200.0); - eframe::egui::ScrollArea::vertical().show(ui, |ui| { + eframe::egui::ScrollArea::vertical() + .scroll_bar_visibility(eframe::egui::scroll_area::ScrollBarVisibility::AlwaysHidden) + .show(ui, |ui| { let playlist = &mut ctx.playlists[current_playlist_idx]; let mut fetch_lyrics = false; @@ -192,7 +207,8 @@ impl AppComponent for Footer { // Create a selectable label for each result let result_response = ui.selectable_label( playlist.is_selected(idx), - result_text, + RichText::new(result_text) + .size(tokens::text::SM), ); // When clicked, scroll to that track and play it @@ -234,7 +250,7 @@ impl AppComponent for Footer { if fetch_lyrics { ctx.auto_fetch_lyrics_for_current_track(); } - }); + }); }); } } @@ -253,7 +269,11 @@ impl AppComponent for Footer { ); } }); - } else if ui.button(format!("{} Search", icons::SEARCH)).clicked() { + } else if ui + .add(Button::new(icons::SEARCH).player_style()) + .on_hover_text("Search") + .clicked() + { search_active = true; // Reset the first frame flag when search is activated ui.memory_mut(|mem| { diff --git a/src/app/components/library_component.rs b/src/app/components/library_component.rs index 39e370e..a4596e3 100644 --- a/src/app/components/library_component.rs +++ b/src/app/components/library_component.rs @@ -111,86 +111,75 @@ impl AppComponent for LibraryComponent { } }); - eframe::egui::ScrollArea::both().show(ui, |ui| { - // Group library items by their library_id (which corresponds to folder paths) - let mut folder_items: HashMap> = HashMap::new(); - - // Collect all library items and group them by path id - for item in ctx.library.items() { - folder_items - .entry(item.library_id()) - .or_default() - .push(item); - } + eframe::egui::ScrollArea::both() + .scroll_bar_visibility(eframe::egui::scroll_area::ScrollBarVisibility::AlwaysHidden) + .show(ui, |ui| { + // Group library items by their library_id (which corresponds to folder paths) + let mut folder_items: HashMap> = HashMap::new(); + + // Collect all library items and group them by path id + for item in ctx.library.items() { + folder_items + .entry(item.library_id()) + .or_default() + .push(item); + } - // Iterate through library paths and display as folders - for lib_path in ctx.library.paths() { - if lib_path.status() == crate::app::library::LibraryPathStatus::Imported { - let path_id = lib_path.id(); - let folder_name = lib_path.display_name(); - - // Create a header with default behavior that allows individual control - // but is also affected by the global expand/collapse actions - let header = CollapsingHeader::new( - RichText::new(folder_name).size(crate::app::style::tokens::text::SM), - ) - .default_open(ctx.ui_state.library_folders_expanded); // Use the global setting after memory clear - - // Show the header and get its response - let section = header.show(ui, |ui| { - // Only show contents if the header is expanded - if let Some(items) = folder_items.get(&path_id) { - // Create a sorted copy for display - let mut sorted_items = items.clone(); - sorted_items.sort_by(|a, b| { - a.title() - .unwrap_or_default() - .cmp(&b.title().unwrap_or_default()) - }); - - for item in sorted_items { - // Format display with title and artist if available - let display_text = match (item.title(), item.artist()) { - (Some(title), Some(artist)) => { - format!("{} - {}", title, artist) - } - (Some(title), None) => title, - (None, Some(artist)) => { - format!("{} - {}", t("unknown_title"), artist) - } - (None, None) => t("unknown_track"), - }; - - // Create a clickable label for each track - let item_label = ui.add( - Label::new( - RichText::new(display_text) - .size(crate::app::style::tokens::text::SM), - ) - .sense(Sense::click()) - .wrap_mode(TextWrapMode::Truncate), - ); - if item_label.hovered() { - ui.ctx() - .set_cursor_icon(eframe::egui::CursorIcon::PointingHand); - } + // Iterate through library paths and display as folders + for lib_path in ctx.library.paths() { + if lib_path.status() == crate::app::library::LibraryPathStatus::Imported { + let path_id = lib_path.id(); + let folder_name = lib_path.display_name(); + + // Create a header with default behavior that allows individual control + // but is also affected by the global expand/collapse actions + let header = CollapsingHeader::new( + RichText::new(folder_name).size(crate::app::style::tokens::text::SM), + ) + .default_open(ctx.ui_state.library_folders_expanded); // Use the global setting after memory clear + + // Show the header and get its response + let section = header.show(ui, |ui| { + // Only show contents if the header is expanded + if let Some(items) = folder_items.get(&path_id) { + // Create a sorted copy for display + let mut sorted_items = items.clone(); + sorted_items.sort_by(|a, b| { + a.title() + .unwrap_or_default() + .cmp(&b.title().unwrap_or_default()) + }); - // Handle click to add to current playlist - if item_label.clicked() { - if let Some(current_playlist_idx) = - &ctx.app_settings.current_playlist_idx - { - let current_playlist = - &mut ctx.playlists[*current_playlist_idx]; - if !current_playlist.tracks.contains(item) { - current_playlist.add((*item).clone()); + for item in sorted_items { + // Format display with title and artist if available + let display_text = match (item.title(), item.artist()) { + (Some(title), Some(artist)) => { + format!("{} - {}", title, artist) + } + (Some(title), None) => title, + (None, Some(artist)) => { + format!("{} - {}", t("unknown_title"), artist) } + (None, None) => t("unknown_track"), + }; + + // Create a clickable label for each track + let item_label = ui.add( + Label::new( + RichText::new(display_text) + .size(crate::app::style::tokens::text::SM), + ) + .sense(Sense::click()) + .wrap_mode(TextWrapMode::Truncate), + ); + if item_label.hovered() { + ui.ctx().set_cursor_icon( + eframe::egui::CursorIcon::PointingHand, + ); } - } - // Add context menu for individual tracks - item_label.context_menu(|ui| { - if ui.button(t("add_to_playlist")).clicked() { + // Handle click to add to current playlist + if item_label.clicked() { if let Some(current_playlist_idx) = &ctx.app_settings.current_playlist_idx { @@ -199,44 +188,59 @@ impl AppComponent for LibraryComponent { if !current_playlist.tracks.contains(item) { current_playlist.add((*item).clone()); } - ui.close_menu(); } } - }); - } - } - }); - // Add context menu to the header response - section.header_response.context_menu(|ui| { - // Add context menu for the folder header - if ui.button(t("add_all_to_playlist")).clicked() { - if let Some(current_playlist_idx) = - &ctx.app_settings.current_playlist_idx - { - let current_playlist = &mut ctx.playlists[*current_playlist_idx]; - - // Add all tracks from this folder to the playlist - if let Some(items) = folder_items.get(&path_id) { - for item in items { - if !current_playlist.tracks.contains(item) { - current_playlist.add((*item).clone()); + // Add context menu for individual tracks + item_label.context_menu(|ui| { + if ui.button(t("add_to_playlist")).clicked() { + if let Some(current_playlist_idx) = + &ctx.app_settings.current_playlist_idx + { + let current_playlist = + &mut ctx.playlists[*current_playlist_idx]; + if !current_playlist.tracks.contains(item) { + current_playlist.add((*item).clone()); + } + ui.close_menu(); + } + } + }); + } + } + }); + + // Add context menu to the header response + section.header_response.context_menu(|ui| { + // Add context menu for the folder header + if ui.button(t("add_all_to_playlist")).clicked() { + if let Some(current_playlist_idx) = + &ctx.app_settings.current_playlist_idx + { + let current_playlist = + &mut ctx.playlists[*current_playlist_idx]; + + // Add all tracks from this folder to the playlist + if let Some(items) = folder_items.get(&path_id) { + for item in items { + if !current_playlist.tracks.contains(item) { + current_playlist.add((*item).clone()); + } } } + ui.close_menu(); } - ui.close_menu(); } - } - if ui.button(t("remove_from_library")).clicked() { - // Mark this path for removal after the loop - path_to_remove = Some(path_id); - ui.close_menu(); - } - }); + if ui.button(t("remove_from_library")).clicked() { + // Mark this path for removal after the loop + path_to_remove = Some(path_id); + ui.close_menu(); + } + }); + } } - } - }); + }); // Process any path removal after rendering the UI if let Some(path_id) = path_to_remove { diff --git a/src/app/components/playlist_table/services.rs b/src/app/components/playlist_table/services.rs index 5554f41..568f884 100644 --- a/src/app/components/playlist_table/services.rs +++ b/src/app/components/playlist_table/services.rs @@ -86,7 +86,7 @@ impl<'a> PlaylistTableService<'a> { pub(crate) fn remove_track(&mut self, idx: usize) { if let Some(playlist) = self.playlist_mut() { if idx < playlist.tracks.len() { - playlist.tracks.remove(idx); + playlist.remove(idx); } } } diff --git a/src/app/components/playlist_table/view.rs b/src/app/components/playlist_table/view.rs index b00d301..8922936 100644 --- a/src/app/components/playlist_table/view.rs +++ b/src/app/components/playlist_table/view.rs @@ -76,6 +76,7 @@ pub(super) fn render(ctx: &mut App, ui: &mut egui::Ui) { let scroll_area_id = base_id.with("scroll_area"); egui::ScrollArea::both() .id_salt(scroll_area_id) + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) .auto_shrink([false, false]) .show(ui, |ui| { ui.set_min_width(available_width); diff --git a/src/lib/library.rs b/src/lib/library.rs index cb0c70c..8c35159 100644 --- a/src/lib/library.rs +++ b/src/lib/library.rs @@ -1,5 +1,6 @@ use rusqlite::{Connection, Result as SqlResult}; use serde::{Deserialize, Serialize}; +use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; @@ -110,24 +111,55 @@ impl Library { } pub fn add_item(&mut self, library_item: LibraryItem) { - // Check if an item with this file_hash already exists - if let Some(idx) = self + let existing_idx = self .items .iter() - .position(|item| item.file_hash() == library_item.file_hash()) - { - // Update the existing item but preserve its key + .position(|item| item.path_ref() == library_item.path_ref()) + .or_else(|| { + self.items + .iter() + .position(|item| item.file_hash() == library_item.file_hash()) + }); + + if let Some(idx) = existing_idx { let existing_key = self.items[idx].key(); + let existing_lyrics = self.items[idx].lyrics(); let mut updated_item = library_item; updated_item.set_key(existing_key); + if updated_item.lyrics().is_none() { + updated_item.replace_lyrics(existing_lyrics); + } self.items[idx] = updated_item; + + let updated_path = self.items[idx].path(); + let updated_hash = self.items[idx].file_hash().to_string(); + let updated_key = self.items[idx].key(); + self.items.retain(|item| { + item.key_str() == updated_key + || (item.path_ref() != updated_path.as_path() + && item.file_hash() != updated_hash) + }); } else { - // Add as a new item self.items.push(library_item); } } pub fn add_view(&mut self, library_view: LibraryView) { + let incoming_path_ids: HashSet = library_view + .containers + .iter() + .flat_map(|container| container.items.iter().map(LibraryItem::library_id)) + .collect(); + + if !incoming_path_ids.is_empty() { + self.library_view.containers.retain(|container| { + !container + .items + .iter() + .any(|item| incoming_path_ids.contains(&item.library_id())) + }); + } + let mut new = library_view.containers.clone(); self.library_view.containers.append(&mut new); @@ -180,6 +212,34 @@ impl Library { )?; } + let live_item_keys: HashSet = self.items.iter().map(LibraryItem::key).collect(); + let existing_item_keys = { + let mut stmt = tx.prepare("SELECT key FROM library_items")?; + let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; + let mut keys = Vec::new(); + for row in rows { + keys.push(row?); + } + keys + }; + + for key in existing_item_keys { + if !live_item_keys.contains(&key) { + tx.execute( + "DELETE FROM playlist_items WHERE library_item_id = ?1", + rusqlite::params![key], + )?; + tx.execute( + "DELETE FROM pictures WHERE library_item_id = ?1", + rusqlite::params![key], + )?; + tx.execute( + "DELETE FROM library_items WHERE key = ?1", + rusqlite::params![key], + )?; + } + } + // Save all library items for item in &mut self.items { if !item.is_dirty() { @@ -305,7 +365,13 @@ impl Library { let mut items = Vec::new(); for item_result in item_rows { - items.push(item_result?); + let item = item_result?; + if !items + .iter() + .any(|existing: &LibraryItem| existing.path_ref() == item.path_ref()) + { + items.push(item); + } } // Load all pictures at once to prevent N+1 queries From d3662983d56819a928f9a4e8b139cd22f244b900 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Thu, 2 Jul 2026 15:28:15 +0800 Subject: [PATCH 04/30] Fix playlist continuation and lyrics controls --- src/app/components/player_component.rs | 13 +++++++++++++ src/app/components/window_chrome.rs | 3 +++ src/app/core.rs | 2 +- src/app/style/icons.rs | 2 ++ 4 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/app/components/player_component.rs b/src/app/components/player_component.rs index 417ec6a..5646e5d 100644 --- a/src/app/components/player_component.rs +++ b/src/app/components/player_component.rs @@ -22,6 +22,7 @@ enum PlayerAction { Previous, Next, ToggleMode, + ToggleLyricsPanel, ToggleDesktopLyrics, } @@ -83,6 +84,7 @@ impl AppComponent for PlayerComponent { }; let has_selected_track = selected_track.is_some(); + let lyrics_panel_visible = ctx.ui_state.show_lyrics_panel; let desktop_lyrics_enabled = ctx.ui_state.desktop_lyrics_enabled; let is_muted = ctx.ui_state.volume_before_mute.is_some(); @@ -236,6 +238,8 @@ impl AppComponent for PlayerComponent { ui.add_enabled(has_selected_track, player_button(mode_icon, mode_active)); ui.add_space(tokens::spacing::SM); + let lyrics_panel_btn = + ui.add(player_button(icons::LYRICS_PANEL, lyrics_panel_visible)); let lyrics_btn = ui.add(player_button(icons::LYRICS_TOGGLE, desktop_lyrics_enabled)); @@ -278,6 +282,8 @@ impl AppComponent for PlayerComponent { && ctx.app_settings.playing_playlist_idx.is_some() { Some(PlayerAction::Next) + } else if lyrics_panel_btn.clicked() { + Some(PlayerAction::ToggleLyricsPanel) } else if lyrics_btn.clicked() { Some(PlayerAction::ToggleDesktopLyrics) } else { @@ -305,6 +311,13 @@ impl AppComponent for PlayerComponent { ctx.play_next_track(); fetch_lyrics = true; } + PlayerAction::ToggleLyricsPanel => { + let will_show = !ctx.ui_state.show_lyrics_panel; + ctx.ui_state.show_lyrics_panel = will_show; + if will_show { + ctx.fetch_lyrics_for_current_track(); + } + } PlayerAction::ToggleDesktopLyrics => { ctx.ui_state.desktop_lyrics_enabled = !ctx.ui_state.desktop_lyrics_enabled; diff --git a/src/app/components/window_chrome.rs b/src/app/components/window_chrome.rs index d24104e..b77413c 100644 --- a/src/app/components/window_chrome.rs +++ b/src/app/components/window_chrome.rs @@ -19,6 +19,9 @@ impl AppComponent for WindowChrome { // window control buttons on the right edge of the same row. crate::app::style::borderless_button_visuals(ui.visuals_mut()); + ui.label(RichText::new("Bird").strong()); + ui.separator(); + // Menu list ui.menu_button(t("file"), |ui| { if ui.button(t("open")).clicked() { diff --git a/src/app/core.rs b/src/app/core.rs index 8ba65eb..8dc23e3 100644 --- a/src/app/core.rs +++ b/src/app/core.rs @@ -512,7 +512,7 @@ impl App { tracing::info!("Track finished, getting next..."); let playlist_clone = self .app_settings - .current_playlist_idx + .playing_playlist_idx .and_then(|idx| self.playlists.get(idx).cloned()); if let Some(playlist) = playlist_clone { PlayerService::next_track(self.player_mut_ref(), &playlist); diff --git a/src/app/style/icons.rs b/src/app/style/icons.rs index 1e1cf19..c0e34a6 100644 --- a/src/app/style/icons.rs +++ b/src/app/style/icons.rs @@ -24,6 +24,8 @@ pub const VOLUME: &str = p::SPEAKER_HIGH; pub const VOLUME_MUTE: &str = p::SPEAKER_X; /// Toggles the desktop-lyrics floating viewport. pub const LYRICS_TOGGLE: &str = p::MICROPHONE_STAGE; +/// Toggles the in-app lyrics side panel. +pub const LYRICS_PANEL: &str = p::SUBTITLES; pub const SEARCH: &str = p::MAGNIFYING_GLASS; pub const CLOSE: &str = p::X; pub const DOWNLOAD: &str = p::DOWNLOAD_SIMPLE; From c4f9807d02da0f2399031435fce7aca6aa049d3f Mon Sep 17 00:00:00 2001 From: RetricSu Date: Thu, 2 Jul 2026 15:47:41 +0800 Subject: [PATCH 05/30] Move search to player tools and add folder resync --- src/app/components/footer.rs | 287 ------------------ src/app/components/library_component.rs | 20 ++ src/app/components/playback_info_panel.rs | 342 ++++++++++++++-------- src/app/i18n.rs | 16 + 4 files changed, 255 insertions(+), 410 deletions(-) diff --git a/src/app/components/footer.rs b/src/app/components/footer.rs index 87eba66..63f3ac0 100644 --- a/src/app/components/footer.rs +++ b/src/app/components/footer.rs @@ -1,7 +1,5 @@ use super::AppComponent; -use crate::app::style::{icons, tokens, ButtonExt}; use crate::app::App; -use eframe::egui::{Button, Frame, Margin, RichText, Stroke, TextEdit}; pub struct Footer; @@ -10,296 +8,11 @@ impl AppComponent for Footer { fn add(ctx: &mut Self::Context, ui: &mut eframe::egui::Ui) { ui.horizontal(|ui| { - // Playlist operation buttons if let Some(current_playlist_idx) = ctx.app_settings.current_playlist_idx { let selection_count = ctx.playlists[current_playlist_idx].selected_indices.len(); - - // Track search state in memory - let search_active_id = ui.id().with("search_active"); - let search_text_id = ui.id().with("search_text"); - - let mut search_active = ui - .memory_mut(|mem| mem.data.get_temp::(search_active_id)) - .unwrap_or(false); - - let mut search_text = ui - .memory_mut(|mem| mem.data.get_temp::(search_text_id)) - .unwrap_or_default(); - - // Search button or search box - if search_active { - // Create a unique ID for the editor to track focus - let editor_id = ui.id().with("search_editor"); - - // Request focus on the first frame when search becomes active - let is_first_frame_id = ui.id().with("is_first_search_frame"); - let is_first_frame = ui - .memory_mut(|mem| mem.data.get_temp::(is_first_frame_id)) - .unwrap_or(true); - - if is_first_frame { - ui.memory_mut(|mem| { - mem.request_focus(editor_id); - mem.data.insert_temp(is_first_frame_id, false); - }); - } - - // Define the search results storage type - let search_results_id = ui.id().with("search_results"); - let show_dropdown_id = ui.id().with("show_search_dropdown"); - - ui.vertical(|ui| { - let playlist = &mut ctx.playlists[current_playlist_idx]; - ui.horizontal(|ui| { - // Add the search text field - let response = ui.add( - TextEdit::singleline(&mut search_text) - .id(editor_id) - .desired_width(200.0) - .hint_text("Type to search..."), - ); - - // Always save the current text value to memory as user types - ui.memory_mut(|mem| { - mem.data.insert_temp(search_text_id, search_text.clone()) - }); - - // Add a search button that only triggers when clicked - if ui - .add(Button::new(icons::SEARCH).player_style()) - .on_hover_text("Search") - .clicked() - || (response.lost_focus() - && ui.input(|i| i.key_pressed(eframe::egui::Key::Enter))) - { - // Only search if text is not empty - if !search_text.is_empty() { - // Clear previous selection - playlist.clear_selection(); - - // Select items that match the search text - let search_lower = search_text.to_lowercase(); - tracing::info!("Searching for: {}", search_lower); - - let mut match_count = 0; - let mut search_results: Vec<(usize, String, String, String)> = - Vec::new(); - - for (idx, track) in playlist.tracks.iter().enumerate() { - let title = track.title().unwrap_or_default(); - let artist = track.artist().unwrap_or_default(); - let album = track.album().unwrap_or_default(); - let genre = track.genre().unwrap_or_default(); - - let title_lower = title.to_lowercase(); - let artist_lower = artist.to_lowercase(); - let album_lower = album.to_lowercase(); - let genre_lower = genre.to_lowercase(); - - if title_lower.contains(&search_lower) - || artist_lower.contains(&search_lower) - || album_lower.contains(&search_lower) - || genre_lower.contains(&search_lower) - { - playlist.selected_indices.insert(idx); - match_count += 1; - search_results.push(( - idx, - title.clone(), - artist.clone(), - album.clone(), - )); - tracing::info!("Match found: {} ({})", title, idx); - } - } - - tracing::info!( - "Search completed. Found {} matches", - match_count - ); - - // Store the search results in memory - ui.memory_mut(|mem| { - mem.data.insert_temp(search_results_id, search_results); - mem.data.insert_temp(show_dropdown_id, match_count > 0); - }); - - // Show a message if no matches found - if match_count == 0 { - // Store a "no results" message to display - ui.memory_mut(|mem| { - mem.data.insert_temp( - ui.id().with("search_no_results"), - true, - ) - }); - } else { - ui.memory_mut(|mem| { - mem.data.insert_temp( - ui.id().with("search_no_results"), - false, - ) - }); - } - } - } - - // Close button to exit search mode - if ui - .add(Button::new(icons::CLOSE).player_style()) - .on_hover_text("Close search") - .clicked() - { - search_active = false; - search_text.clear(); - ui.memory_mut(|mem| { - mem.data.insert_temp(search_text_id, String::new()); - mem.data.insert_temp(show_dropdown_id, false); - }); - } - }); - - // Get search results from memory and show dropdown if we have results - let show_dropdown = ui - .memory_mut(|mem| mem.data.get_temp::(show_dropdown_id)) - .unwrap_or(false); - - if show_dropdown { - // Retrieve the search results - if let Some(results) = ui.memory_mut(|mem| { - mem.data.get_temp::>( - search_results_id, - ) - }) { - if !results.is_empty() { - // Container for results with scrolling - Frame::popup(ui.style()) - .corner_radius(tokens::radius::SM) - .inner_margin(Margin::symmetric( - tokens::spacing::SM as i8, - tokens::spacing::XS as i8, - )) - .stroke(Stroke::new( - tokens::size::STROKE_WIDTH, - ui.visuals().widgets.noninteractive.bg_stroke.color, - )) - .show(ui, |ui| { - ui.set_max_width(400.0); - ui.set_max_height(200.0); - - eframe::egui::ScrollArea::vertical() - .scroll_bar_visibility(eframe::egui::scroll_area::ScrollBarVisibility::AlwaysHidden) - .show(ui, |ui| { - let playlist = - &mut ctx.playlists[current_playlist_idx]; - let mut fetch_lyrics = false; - let mut track_to_play: Option<( - usize, - crate::app::library::LibraryItem, - )> = None; - - for (idx, title, artist, album) in results { - let result_text = format!( - "{} - {} ({})", - title, artist, album - ); - - // Create a selectable label for each result - let result_response = ui.selectable_label( - playlist.is_selected(idx), - RichText::new(result_text) - .size(tokens::text::SM), - ); - - // When clicked, scroll to that track and play it - if result_response.clicked() { - // Store the index to scroll to in memory - ui.memory_mut(|mem| { - mem.data.insert_temp( - ui.id().with("scroll_to_idx"), - idx, - ); - }); - - // Keep only this track selected - playlist.clear_selection(); - playlist.toggle_selection(idx); - - // Store track to play later - let track = playlist.tracks[idx].clone(); - track_to_play = Some((idx, track)); - fetch_lyrics = true; - - // Hide the dropdown - ui.memory_mut(|mem| { - mem.data.insert_temp( - show_dropdown_id, - false, - ); - }); - } - } - - // Play the track after releasing the playlist borrow - if let Some((_idx, track)) = track_to_play { - let player = ctx.player_mut_ref(); - player.select_track(Some(track)); - player.play(); - } - - if fetch_lyrics { - ctx.auto_fetch_lyrics_for_current_track(); - } - }); - }); - } - } - } - - // Show "No results" message if appropriate - if ui - .memory_mut(|mem| { - mem.data.get_temp::(ui.id().with("search_no_results")) - }) - .unwrap_or(false) - { - ui.label( - eframe::egui::RichText::new("No matches found") - .color(crate::app::style::tokens::color::LYRICS_FAILED), - ); - } - }); - } else if ui - .add(Button::new(icons::SEARCH).player_style()) - .on_hover_text("Search") - .clicked() - { - search_active = true; - // Reset the first frame flag when search is activated - ui.memory_mut(|mem| { - mem.data - .insert_temp(ui.id().with("is_first_search_frame"), true); - // Also clear any previous search text - mem.data.insert_temp(search_text_id, String::new()); - // Hide dropdown - mem.data - .insert_temp(ui.id().with("show_search_dropdown"), false); - }); - } - - // Save search state - ui.memory_mut(|mem| mem.data.insert_temp(search_active_id, search_active)); - - // Select All button - // if ui.button("- Select All").clicked() && has_tracks { - // playlist.select_all(); - // } - - // Show selection count if any if selection_count > 0 { ui.label(format!("{} selected", selection_count)); - // Clear Selection button (disabled if no selection) if ui.button("Clear Selection").clicked() { let playlist = &mut ctx.playlists[current_playlist_idx]; playlist.clear_selection(); diff --git a/src/app/components/library_component.rs b/src/app/components/library_component.rs index a4596e3..da4c368 100644 --- a/src/app/components/library_component.rs +++ b/src/app/components/library_component.rs @@ -12,6 +12,7 @@ impl AppComponent for LibraryComponent { fn add(ctx: &mut Self::Context, ui: &mut eframe::egui::Ui) { // Keep track of paths to remove (if any) let mut path_to_remove: Option = None; + let mut path_to_resync: Option = None; // Header is rendered OUTSIDE the ScrollArea so it stays pinned and // its bottom rule lines up with the playlist tabs / lyrics panel @@ -232,6 +233,11 @@ impl AppComponent for LibraryComponent { } } + if ui.button(t("resync_folder")).clicked() { + path_to_resync = Some(path_id); + ui.close_menu(); + } + if ui.button(t("remove_from_library")).clicked() { // Mark this path for removal after the loop path_to_remove = Some(path_id); @@ -242,6 +248,20 @@ impl AppComponent for LibraryComponent { } }); + if let Some(path_id) = path_to_resync { + ctx.library.set_path_to_not_imported(path_id); + let path_to_import = ctx + .library + .paths() + .iter() + .find(|path| path.id() == path_id) + .cloned(); + + if let Some(path) = path_to_import { + ctx.import_library_paths(&path); + } + } + // Process any path removal after rendering the UI if let Some(path_id) = path_to_remove { ctx.library.remove_path(path_id); diff --git a/src/app/components/playback_info_panel.rs b/src/app/components/playback_info_panel.rs index f52ce94..4487ad6 100644 --- a/src/app/components/playback_info_panel.rs +++ b/src/app/components/playback_info_panel.rs @@ -1,10 +1,18 @@ use super::AppComponent; +use crate::app::library::{Library, LibraryItem}; use crate::app::style::{icons, player_button, tokens, ButtonExt}; use crate::app::t; use crate::app::App; -use eframe::egui::{self, Align, Layout, RichText, TextEdit}; +use eframe::egui::{self, Button, Frame, Margin, RichText, Stroke, TextEdit}; -const DESCRIPTION_PREVIEW_LENGTH: usize = 30; +#[derive(Clone)] +struct LibrarySearchResult { + track: LibraryItem, + title: String, + artist: String, + album: String, + source: String, +} pub struct PlaybackInfoPanel; @@ -12,18 +20,12 @@ impl AppComponent for PlaybackInfoPanel { type Context = App; fn add(ctx: &mut Self::Context, ui: &mut eframe::egui::Ui) { - Self::render_download_entry(ctx, ui); - - // Check if we have a playing playlist - let playing_playlist_idx = ctx.app_settings.playing_playlist_idx; - - if let Some(playlist_idx) = playing_playlist_idx { - // Case 1: Playlist is playing - Self::render_playlist_info(ctx, ui, playlist_idx); - } else { - // Case 2: Single track (no playlist) - Self::render_track_info(ctx, ui); - } + ui.vertical(|ui| { + ui.horizontal(|ui| { + Self::render_download_entry(ctx, ui); + Self::render_library_search(ctx, ui); + }); + }); } } @@ -155,124 +157,218 @@ impl PlaybackInfoPanel { ctx.ui_state.show_youtube_download_dialog = open; } - fn render_playlist_info(ctx: &App, ui: &mut egui::Ui, playlist_idx: usize) { - if let Some(playlist) = ctx.playlists.get(playlist_idx) { - ui.with_layout(Layout::top_down(Align::RIGHT), |ui| { - let weak_color = ui.visuals().weak_text_color(); - - // Playlist name - if let Some(name) = playlist.get_name() { - ui.label( - RichText::new(&name) - .size(tokens::text::SM) - .color(weak_color), - ); - } + fn render_library_search(ctx: &mut App, ui: &mut egui::Ui) { + let search_active_id = ui.id().with("library_search_active"); + let search_text_id = ui.id().with("library_search_text"); + let search_results_id = ui.id().with("library_search_results"); + let show_results_id = ui.id().with("library_search_show_results"); + let no_results_id = ui.id().with("library_search_no_results"); - // Track position - if let Some(selected_track) = &ctx.player_ref().selected_track { - if let Some(pos) = playlist.get_pos(selected_track) { - let total = playlist.tracks.len(); - ui.label( - RichText::new(format!("Track {:02}/{:02}", pos + 1, total)) - .size(tokens::text::SM) - .color(weak_color), - ); - } - } + let mut search_active = ui + .memory_mut(|mem| mem.data.get_temp::(search_active_id)) + .unwrap_or(false); + let mut search_text = ui + .memory_mut(|mem| mem.data.get_temp::(search_text_id)) + .unwrap_or_default(); - // Description preview (first 30 chars) - if let Some(desc) = playlist.description() { - let preview = if desc.chars().count() > DESCRIPTION_PREVIEW_LENGTH { - format!( - "{}...", - desc.chars() - .take(DESCRIPTION_PREVIEW_LENGTH) - .collect::() - ) - } else { - desc.to_string() - }; - ui.label( - RichText::new(preview) - .size(tokens::text::SM) - .color(weak_color), - ); - } + let mut should_search = false; + + if search_active { + let editor_id = ui.id().with("library_search_editor"); + let first_frame_id = ui.id().with("library_search_first_frame"); + let is_first_frame = ui + .memory_mut(|mem| mem.data.get_temp::(first_frame_id)) + .unwrap_or(true); + + if is_first_frame { + ui.memory_mut(|mem| { + mem.request_focus(editor_id); + mem.data.insert_temp(first_frame_id, false); + }); + } + + let response = ui.add( + TextEdit::singleline(&mut search_text) + .id(editor_id) + .desired_width(180.0) + .hint_text(t("type_to_search")), + ); + ui.memory_mut(|mem| mem.data.insert_temp(search_text_id, search_text.clone())); + + should_search = ui + .add(Button::new(icons::SEARCH).player_style()) + .on_hover_text(t("library_search")) + .clicked() + || (response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))); + + if ui + .add(Button::new(icons::CLOSE).player_style()) + .on_hover_text(t("close_search")) + .clicked() + { + search_active = false; + search_text.clear(); + ui.memory_mut(|mem| { + mem.data.insert_temp(search_text_id, String::new()); + mem.data.insert_temp(show_results_id, false); + mem.data.insert_temp(no_results_id, false); + }); + } + } else if ui + .add(Button::new(icons::SEARCH).player_style()) + .on_hover_text(t("library_search")) + .clicked() + { + search_active = true; + ui.memory_mut(|mem| { + mem.data + .insert_temp(ui.id().with("library_search_first_frame"), true); + mem.data.insert_temp(search_text_id, String::new()); + mem.data.insert_temp(show_results_id, false); + mem.data.insert_temp(no_results_id, false); }); } - } - fn render_track_info(ctx: &App, ui: &mut egui::Ui) { - let player = ctx.player_ref(); + if should_search { + let query = search_text.trim(); + let results = if query.is_empty() { + Vec::new() + } else { + Self::search_library(&ctx.library, query) + }; + let has_results = !results.is_empty(); + ui.memory_mut(|mem| { + mem.data.insert_temp(search_results_id, results); + mem.data.insert_temp(show_results_id, has_results); + mem.data + .insert_temp(no_results_id, !has_results && !query.is_empty()); + }); + } - if let Some(track) = &player.selected_track { - ui.with_layout(Layout::top_down(Align::RIGHT), |ui| { - let weak_color = ui.visuals().weak_text_color(); + ui.memory_mut(|mem| mem.data.insert_temp(search_active_id, search_active)); - // Album · Year - let mut album_line = String::new(); - if let Some(album) = track.album() { - album_line.push_str(&album); - } - if let Some(year) = track.year() { - if !album_line.is_empty() { - album_line.push_str(" · "); - } - album_line.push_str(&year.to_string()); - } - if !album_line.is_empty() { - ui.label( - RichText::new(album_line) - .size(tokens::text::SM) - .color(weak_color), - ); - } + let show_results = ui + .memory_mut(|mem| mem.data.get_temp::(show_results_id)) + .unwrap_or(false); + let mut track_to_play: Option = None; - // Genre - if let Some(genre) = track.genre() { - ui.label( - RichText::new(genre) - .size(tokens::text::SM) - .color(weak_color), - ); - } + if show_results { + if let Some(results) = ui.memory_mut(|mem| { + mem.data + .get_temp::>(search_results_id) + }) { + Frame::popup(ui.style()) + .corner_radius(tokens::radius::SM) + .inner_margin(Margin::symmetric( + tokens::spacing::SM as i8, + tokens::spacing::XS as i8, + )) + .stroke(Stroke::new( + tokens::size::STROKE_WIDTH, + ui.visuals().widgets.noninteractive.bg_stroke.color, + )) + .show(ui, |ui| { + ui.set_max_width(380.0); + ui.set_max_height(220.0); - // Format · Sample Rate · Channels - let mut tech_line = String::new(); - if let Some(codec) = &player.codec { - tech_line.push_str(codec); - } - if let Some(sample_rate) = player.sample_rate { - if !tech_line.is_empty() { - tech_line.push_str(" · "); - } - tech_line.push_str(&format!("{:.1}kHz", sample_rate as f32 / 1000.0)); - } - if let Some(channels) = player.channels { - if !tech_line.is_empty() { - tech_line.push_str(" · "); - } - let channel_str = match channels { - 1 => "Mono", - 2 => "Stereo", - _ => { - tech_line.push_str(&format!("{}ch", channels)); - "" - } - }; - if !channel_str.is_empty() { - tech_line.push_str(channel_str); - } - } - if !tech_line.is_empty() { - ui.label( - RichText::new(tech_line) - .size(tokens::text::SM) - .color(weak_color), - ); - } - }); + egui::ScrollArea::vertical() + .scroll_bar_visibility( + egui::scroll_area::ScrollBarVisibility::AlwaysHidden, + ) + .show(ui, |ui| { + for result in results { + let title = if result.title.is_empty() { + t("unknown_track") + } else { + result.title.clone() + }; + let detail = [result.artist, result.album, result.source] + .into_iter() + .filter(|text| !text.is_empty()) + .collect::>() + .join(" "); + let label = if detail.is_empty() { + title + } else { + format!("{}\n{}", title, detail) + }; + + if ui + .add_sized( + [360.0, 36.0], + Button::new( + RichText::new(label).size(tokens::text::SM), + ) + .frame(false), + ) + .clicked() + { + track_to_play = Some(result.track); + ui.memory_mut(|mem| { + mem.data.insert_temp(show_results_id, false); + }); + } + } + }); + }); + } + } else if ui + .memory_mut(|mem| mem.data.get_temp::(no_results_id)) + .unwrap_or(false) + { + ui.label( + RichText::new(t("no_matches_found")) + .size(tokens::text::SM) + .color(tokens::color::LYRICS_FAILED), + ); + } + + if let Some(track) = track_to_play { + { + let player = ctx.player_mut_ref(); + player.select_track(Some(track)); + player.play(); + } + ctx.app_settings.playing_playlist_idx = None; + ctx.auto_fetch_lyrics_for_current_track(); } } + + fn search_library(library: &Library, query: &str) -> Vec { + let query = query.to_lowercase(); + library + .items() + .iter() + .filter_map(|item| { + let title = item.title().unwrap_or_default(); + let artist = item.artist().unwrap_or_default(); + let album = item.album().unwrap_or_default(); + let genre = item.genre().unwrap_or_default(); + let path = item.path().to_string_lossy().to_string(); + let haystack = + format!("{} {} {} {} {}", title, artist, album, genre, path).to_lowercase(); + + if !haystack.contains(&query) { + return None; + } + + let source = item + .path_ref() + .parent() + .and_then(|path| path.file_name()) + .and_then(|name| name.to_str()) + .unwrap_or("Library") + .to_string(); + + Some(LibrarySearchResult { + track: item.clone(), + title, + artist, + album, + source, + }) + }) + .take(50) + .collect() + } } diff --git a/src/app/i18n.rs b/src/app/i18n.rs index e73ab96..2cfff80 100644 --- a/src/app/i18n.rs +++ b/src/app/i18n.rs @@ -87,6 +87,16 @@ pub fn init() { en.insert("mini".to_string(), "Mini".to_string()); en.insert("playlist_btn".to_string(), "Playlist".to_string()); en.insert("lyrics".to_string(), "Lyrics".to_string()); + en.insert("library_search".to_string(), "Search library".to_string()); + en.insert( + "type_to_search".to_string(), + "Type to search...".to_string(), + ); + en.insert("close_search".to_string(), "Close search".to_string()); + en.insert( + "no_matches_found".to_string(), + "No matches found".to_string(), + ); // Lyrics component en.insert("upload_lyrics".to_string(), "Upload Lyrics".to_string()); @@ -110,6 +120,7 @@ pub fn init() { "add_all_to_playlist".to_string(), "Add all to playlist".to_string(), ); + en.insert("resync_folder".to_string(), "Re-sync folder".to_string()); en.insert( "remove_from_library".to_string(), "Remove from library".to_string(), @@ -240,6 +251,10 @@ pub fn init() { zh.insert("mini".to_string(), "迷你".to_string()); zh.insert("playlist_btn".to_string(), "列表".to_string()); zh.insert("lyrics".to_string(), "歌词".to_string()); + zh.insert("library_search".to_string(), "搜索音乐库".to_string()); + zh.insert("type_to_search".to_string(), "输入搜索...".to_string()); + zh.insert("close_search".to_string(), "关闭搜索".to_string()); + zh.insert("no_matches_found".to_string(), "没有匹配结果".to_string()); // Lyrics component zh.insert("upload_lyrics".to_string(), "上传歌词".to_string()); @@ -257,6 +272,7 @@ pub fn init() { "add_all_to_playlist".to_string(), "全部添加到播放列表".to_string(), ); + zh.insert("resync_folder".to_string(), "重新同步此文件夹".to_string()); zh.insert("remove_from_library".to_string(), "从库中移除".to_string()); zh.insert( "download_authorized_audio".to_string(), From 2330eb73c116ea768a69640adf88ddc6c31f9c31 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 3 Jul 2026 05:55:53 +0800 Subject: [PATCH 06/30] Polish player tool search layout --- src/app/components/playback_info_panel.rs | 218 +++++++++++++--------- 1 file changed, 134 insertions(+), 84 deletions(-) diff --git a/src/app/components/playback_info_panel.rs b/src/app/components/playback_info_panel.rs index 4487ad6..7148812 100644 --- a/src/app/components/playback_info_panel.rs +++ b/src/app/components/playback_info_panel.rs @@ -3,7 +3,10 @@ use crate::app::library::{Library, LibraryItem}; use crate::app::style::{icons, player_button, tokens, ButtonExt}; use crate::app::t; use crate::app::App; -use eframe::egui::{self, Button, Frame, Margin, RichText, Stroke, TextEdit}; +use eframe::egui::{self, Button, Frame, Margin, Order, RichText, Stroke, TextEdit}; + +const SEARCH_PANEL_WIDTH: f32 = 360.0; +const SEARCH_PANEL_HEIGHT: f32 = 220.0; #[derive(Clone)] struct LibrarySearchResult { @@ -20,11 +23,18 @@ impl AppComponent for PlaybackInfoPanel { type Context = App; fn add(ctx: &mut Self::Context, ui: &mut eframe::egui::Ui) { - ui.vertical(|ui| { - ui.horizontal(|ui| { - Self::render_download_entry(ctx, ui); - Self::render_library_search(ctx, ui); - }); + ui.with_layout(egui::Layout::top_down(egui::Align::RIGHT), |ui| { + let search_anchor = ui + .with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let search_anchor = Self::render_library_search_controls(ctx, ui); + Self::render_download_entry(ctx, ui); + search_anchor + }) + .inner; + + if let Some(anchor) = search_anchor { + Self::render_library_search_results(ctx, ui, anchor); + } }); } } @@ -157,12 +167,13 @@ impl PlaybackInfoPanel { ctx.ui_state.show_youtube_download_dialog = open; } - fn render_library_search(ctx: &mut App, ui: &mut egui::Ui) { + fn render_library_search_controls(ctx: &mut App, ui: &mut egui::Ui) -> Option { let search_active_id = ui.id().with("library_search_active"); let search_text_id = ui.id().with("library_search_text"); let search_results_id = ui.id().with("library_search_results"); let show_results_id = ui.id().with("library_search_show_results"); let no_results_id = ui.id().with("library_search_no_results"); + let mut anchor_rect = None; let mut search_active = ui .memory_mut(|mem| mem.data.get_temp::(search_active_id)) @@ -174,6 +185,11 @@ impl PlaybackInfoPanel { let mut should_search = false; if search_active { + let close_response = ui + .add(Button::new(icons::CLOSE).player_style()) + .on_hover_text(t("close_search")); + anchor_rect = Some(close_response.rect); + let editor_id = ui.id().with("library_search_editor"); let first_frame_id = ui.id().with("library_search_first_frame"); let is_first_frame = ui @@ -193,19 +209,20 @@ impl PlaybackInfoPanel { .desired_width(180.0) .hint_text(t("type_to_search")), ); + anchor_rect = Some(anchor_rect.map_or(response.rect, |rect| rect.union(response.rect))); ui.memory_mut(|mem| mem.data.insert_temp(search_text_id, search_text.clone())); - should_search = ui + let search_response = ui .add(Button::new(icons::SEARCH).player_style()) - .on_hover_text(t("library_search")) - .clicked() + .on_hover_text(t("library_search")); + anchor_rect = Some(anchor_rect.map_or(search_response.rect, |rect| { + rect.union(search_response.rect) + })); + + should_search = search_response.clicked() || (response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))); - if ui - .add(Button::new(icons::CLOSE).player_style()) - .on_hover_text(t("close_search")) - .clicked() - { + if close_response.clicked() { search_active = false; search_text.clear(); ui.memory_mut(|mem| { @@ -247,80 +264,65 @@ impl PlaybackInfoPanel { ui.memory_mut(|mem| mem.data.insert_temp(search_active_id, search_active)); + anchor_rect + } + + fn render_library_search_results(ctx: &mut App, ui: &mut egui::Ui, anchor: egui::Rect) { + let search_results_id = ui.id().with("library_search_results"); + let show_results_id = ui.id().with("library_search_show_results"); + let no_results_id = ui.id().with("library_search_no_results"); + let show_results = ui .memory_mut(|mem| mem.data.get_temp::(show_results_id)) .unwrap_or(false); let mut track_to_play: Option = None; - - if show_results { - if let Some(results) = ui.memory_mut(|mem| { - mem.data - .get_temp::>(search_results_id) - }) { - Frame::popup(ui.style()) - .corner_radius(tokens::radius::SM) - .inner_margin(Margin::symmetric( - tokens::spacing::SM as i8, - tokens::spacing::XS as i8, - )) - .stroke(Stroke::new( - tokens::size::STROKE_WIDTH, - ui.visuals().widgets.noninteractive.bg_stroke.color, - )) - .show(ui, |ui| { - ui.set_max_width(380.0); - ui.set_max_height(220.0); - - egui::ScrollArea::vertical() - .scroll_bar_visibility( - egui::scroll_area::ScrollBarVisibility::AlwaysHidden, - ) - .show(ui, |ui| { - for result in results { - let title = if result.title.is_empty() { - t("unknown_track") - } else { - result.title.clone() - }; - let detail = [result.artist, result.album, result.source] - .into_iter() - .filter(|text| !text.is_empty()) - .collect::>() - .join(" "); - let label = if detail.is_empty() { - title - } else { - format!("{}\n{}", title, detail) - }; - - if ui - .add_sized( - [360.0, 36.0], - Button::new( - RichText::new(label).size(tokens::text::SM), - ) - .frame(false), - ) - .clicked() - { - track_to_play = Some(result.track); - ui.memory_mut(|mem| { - mem.data.insert_temp(show_results_id, false); - }); - } - } - }); - }); - } - } else if ui + let no_results = ui .memory_mut(|mem| mem.data.get_temp::(no_results_id)) - .unwrap_or(false) - { - ui.label( - RichText::new(t("no_matches_found")) - .size(tokens::text::SM) - .color(tokens::color::LYRICS_FAILED), - ); + .unwrap_or(false); + + if show_results || no_results { + let screen_rect = ui.ctx().screen_rect(); + let x = (anchor.right() - SEARCH_PANEL_WIDTH) + .max(screen_rect.left() + tokens::spacing::SM) + .min(screen_rect.right() - SEARCH_PANEL_WIDTH - tokens::spacing::SM); + let y = (anchor.bottom() + tokens::spacing::XS) + .min(screen_rect.bottom() - SEARCH_PANEL_HEIGHT - tokens::spacing::SM); + + egui::Area::new(ui.id().with("library_search_panel_area")) + .order(Order::Foreground) + .fixed_pos(egui::pos2(x, y)) + .show(ui.ctx(), |ui| { + Frame::popup(ui.style()) + .corner_radius(tokens::radius::SM) + .inner_margin(Margin::symmetric( + tokens::spacing::SM as i8, + tokens::spacing::XS as i8, + )) + .stroke(Stroke::new( + tokens::size::STROKE_WIDTH, + ui.visuals().widgets.noninteractive.bg_stroke.color, + )) + .show(ui, |ui| { + ui.set_width(SEARCH_PANEL_WIDTH); + ui.set_max_height(SEARCH_PANEL_HEIGHT); + + if no_results { + ui.label( + RichText::new(t("no_matches_found")) + .size(tokens::text::SM) + .color(tokens::color::LYRICS_FAILED), + ); + return; + } + + Self::render_library_search_result_list( + ui, + search_results_id, + show_results_id, + &mut track_to_play, + ); + }); + }); } if let Some(track) = track_to_play { @@ -334,6 +336,54 @@ impl PlaybackInfoPanel { } } + fn render_library_search_result_list( + ui: &mut egui::Ui, + search_results_id: egui::Id, + show_results_id: egui::Id, + track_to_play: &mut Option, + ) { + if let Some(results) = ui.memory_mut(|mem| { + mem.data + .get_temp::>(search_results_id) + }) { + egui::ScrollArea::vertical() + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) + .show(ui, |ui| { + for result in results { + let title = if result.title.is_empty() { + t("unknown_track") + } else { + result.title.clone() + }; + let detail = [result.artist, result.album, result.source] + .into_iter() + .filter(|text| !text.is_empty()) + .collect::>() + .join(" "); + let label = if detail.is_empty() { + title + } else { + format!("{}\n{}", title, detail) + }; + + if ui + .add_sized( + [SEARCH_PANEL_WIDTH - tokens::spacing::MD, 36.0], + Button::new(RichText::new(label).size(tokens::text::SM)) + .frame(false), + ) + .clicked() + { + *track_to_play = Some(result.track); + ui.memory_mut(|mem| { + mem.data.insert_temp(show_results_id, false); + }); + } + } + }); + } + } + fn search_library(library: &Library, query: &str) -> Vec { let query = query.to_lowercase(); library From d696044218024a203a49306b4e77a1536c654e88 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 3 Jul 2026 05:59:32 +0800 Subject: [PATCH 07/30] Fix library search enter trigger --- src/app/components/playback_info_panel.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/components/playback_info_panel.rs b/src/app/components/playback_info_panel.rs index 7148812..c4cae1b 100644 --- a/src/app/components/playback_info_panel.rs +++ b/src/app/components/playback_info_panel.rs @@ -219,8 +219,9 @@ impl PlaybackInfoPanel { rect.union(search_response.rect) })); - should_search = search_response.clicked() - || (response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))); + should_search = response.changed() + || search_response.clicked() + || (response.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))); if close_response.clicked() { search_active = false; From 4f2373759de80c30f0792d303651b94dc5f1e877 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 3 Jul 2026 06:02:33 +0800 Subject: [PATCH 08/30] Fix library search result state ids --- src/app/components/playback_info_panel.rs | 31 +++++++++++++---------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/app/components/playback_info_panel.rs b/src/app/components/playback_info_panel.rs index c4cae1b..f97aa6e 100644 --- a/src/app/components/playback_info_panel.rs +++ b/src/app/components/playback_info_panel.rs @@ -3,7 +3,7 @@ use crate::app::library::{Library, LibraryItem}; use crate::app::style::{icons, player_button, tokens, ButtonExt}; use crate::app::t; use crate::app::App; -use eframe::egui::{self, Button, Frame, Margin, Order, RichText, Stroke, TextEdit}; +use eframe::egui::{self, Button, Frame, Id, Margin, Order, RichText, Stroke, TextEdit}; const SEARCH_PANEL_WIDTH: f32 = 360.0; const SEARCH_PANEL_HEIGHT: f32 = 220.0; @@ -40,6 +40,10 @@ impl AppComponent for PlaybackInfoPanel { } impl PlaybackInfoPanel { + fn search_id(key: &'static str) -> Id { + Id::new(("playback_info_panel_library_search", key)) + } + fn render_download_entry(ctx: &mut App, ui: &mut egui::Ui) { let active = ctx.ui_state.show_youtube_download_dialog || ctx.ui_state.youtube_download_in_progress; @@ -168,11 +172,11 @@ impl PlaybackInfoPanel { } fn render_library_search_controls(ctx: &mut App, ui: &mut egui::Ui) -> Option { - let search_active_id = ui.id().with("library_search_active"); - let search_text_id = ui.id().with("library_search_text"); - let search_results_id = ui.id().with("library_search_results"); - let show_results_id = ui.id().with("library_search_show_results"); - let no_results_id = ui.id().with("library_search_no_results"); + let search_active_id = Self::search_id("active"); + let search_text_id = Self::search_id("text"); + let search_results_id = Self::search_id("results"); + let show_results_id = Self::search_id("show_results"); + let no_results_id = Self::search_id("no_results"); let mut anchor_rect = None; let mut search_active = ui @@ -190,8 +194,8 @@ impl PlaybackInfoPanel { .on_hover_text(t("close_search")); anchor_rect = Some(close_response.rect); - let editor_id = ui.id().with("library_search_editor"); - let first_frame_id = ui.id().with("library_search_first_frame"); + let editor_id = Self::search_id("editor"); + let first_frame_id = Self::search_id("first_frame"); let is_first_frame = ui .memory_mut(|mem| mem.data.get_temp::(first_frame_id)) .unwrap_or(true); @@ -239,8 +243,7 @@ impl PlaybackInfoPanel { { search_active = true; ui.memory_mut(|mem| { - mem.data - .insert_temp(ui.id().with("library_search_first_frame"), true); + mem.data.insert_temp(Self::search_id("first_frame"), true); mem.data.insert_temp(search_text_id, String::new()); mem.data.insert_temp(show_results_id, false); mem.data.insert_temp(no_results_id, false); @@ -269,9 +272,9 @@ impl PlaybackInfoPanel { } fn render_library_search_results(ctx: &mut App, ui: &mut egui::Ui, anchor: egui::Rect) { - let search_results_id = ui.id().with("library_search_results"); - let show_results_id = ui.id().with("library_search_show_results"); - let no_results_id = ui.id().with("library_search_no_results"); + let search_results_id = Self::search_id("results"); + let show_results_id = Self::search_id("show_results"); + let no_results_id = Self::search_id("no_results"); let show_results = ui .memory_mut(|mem| mem.data.get_temp::(show_results_id)) @@ -289,7 +292,7 @@ impl PlaybackInfoPanel { let y = (anchor.bottom() + tokens::spacing::XS) .min(screen_rect.bottom() - SEARCH_PANEL_HEIGHT - tokens::spacing::SM); - egui::Area::new(ui.id().with("library_search_panel_area")) + egui::Area::new(Self::search_id("panel_area")) .order(Order::Foreground) .fixed_pos(egui::pos2(x, y)) .show(ui.ctx(), |ui| { From 645ad9312edc1fbc3ddb5ffcb073c3e25afe47b9 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 3 Jul 2026 06:10:51 +0800 Subject: [PATCH 09/30] Polish search results list UI --- src/app/components/playback_info_panel.rs | 65 +++++++++++++++++------ src/app/components/player_component.rs | 2 +- 2 files changed, 51 insertions(+), 16 deletions(-) diff --git a/src/app/components/playback_info_panel.rs b/src/app/components/playback_info_panel.rs index f97aa6e..ba991fa 100644 --- a/src/app/components/playback_info_panel.rs +++ b/src/app/components/playback_info_panel.rs @@ -3,7 +3,9 @@ use crate::app::library::{Library, LibraryItem}; use crate::app::style::{icons, player_button, tokens, ButtonExt}; use crate::app::t; use crate::app::App; -use eframe::egui::{self, Button, Frame, Id, Margin, Order, RichText, Stroke, TextEdit}; +use eframe::egui::{ + self, Button, Frame, Id, Label, Margin, Order, RichText, Sense, Stroke, TextEdit, TextWrapMode, +}; const SEARCH_PANEL_WIDTH: f32 = 360.0; const SEARCH_PANEL_HEIGHT: f32 = 220.0; @@ -275,6 +277,7 @@ impl PlaybackInfoPanel { let search_results_id = Self::search_id("results"); let show_results_id = Self::search_id("show_results"); let no_results_id = Self::search_id("no_results"); + let selected_key_id = Self::search_id("selected_key"); let show_results = ui .memory_mut(|mem| mem.data.get_temp::(show_results_id)) @@ -322,7 +325,7 @@ impl PlaybackInfoPanel { Self::render_library_search_result_list( ui, search_results_id, - show_results_id, + selected_key_id, &mut track_to_play, ); }); @@ -343,7 +346,7 @@ impl PlaybackInfoPanel { fn render_library_search_result_list( ui: &mut egui::Ui, search_results_id: egui::Id, - show_results_id: egui::Id, + selected_key_id: egui::Id, track_to_play: &mut Option, ) { if let Some(results) = ui.memory_mut(|mem| { @@ -363,25 +366,57 @@ impl PlaybackInfoPanel { .into_iter() .filter(|text| !text.is_empty()) .collect::>() - .join(" "); - let label = if detail.is_empty() { + .join(" · "); + let row_text = if detail.is_empty() { title } else { - format!("{}\n{}", title, detail) + format!("{} {}", title, detail) }; - if ui - .add_sized( - [SEARCH_PANEL_WIDTH - tokens::spacing::MD, 36.0], - Button::new(RichText::new(label).size(tokens::text::SM)) - .frame(false), + let track_key = result.track.key(); + let selected = ui + .memory_mut(|mem| mem.data.get_temp::(selected_key_id)) + .is_some_and(|key| key == track_key); + + let row_size = + egui::vec2(ui.available_width(), tokens::size::ICON_BTN - 4.0); + let (rect, response) = ui.allocate_exact_size(row_size, Sense::click()); + let visuals = ui.visuals(); + if selected { + ui.painter().rect_filled( + rect, + tokens::radius::SM, + tokens::color::BRAND, + ); + } else if response.hovered() { + ui.painter().rect_filled( + rect, + tokens::radius::SM, + visuals.widgets.hovered.weak_bg_fill, + ); + } + + let text_color = if selected { + egui::Color32::from_rgb(245, 248, 252) + } else { + visuals.text_color() + }; + ui.put( + rect.shrink2(egui::vec2(tokens::spacing::SM, 0.0)), + Label::new( + RichText::new(row_text) + .size(tokens::text::SM) + .color(text_color), ) - .clicked() - { - *track_to_play = Some(result.track); + .sense(Sense::hover()) + .wrap_mode(TextWrapMode::Truncate), + ); + + if response.clicked() { ui.memory_mut(|mem| { - mem.data.insert_temp(show_results_id, false); + mem.data.insert_temp(selected_key_id, track_key); }); + *track_to_play = Some(result.track); } } }); diff --git a/src/app/components/player_component.rs b/src/app/components/player_component.rs index 5646e5d..f944bb7 100644 --- a/src/app/components/player_component.rs +++ b/src/app/components/player_component.rs @@ -97,7 +97,7 @@ impl AppComponent for PlayerComponent { // Now render UI without borrowing ctx in closures that also borrow ctx ui.vertical(|ui| { // ── Top row: cover + track info + playback info ──────────────── - ui.horizontal(|ui| { + ui.horizontal_top(|ui| { CassetteComponent::add(ctx, ui); // Calculate available width for middle section (60% of remaining space) From 69409115cd0749bf5f5c75651f89df31330452bc Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 3 Jul 2026 06:14:05 +0800 Subject: [PATCH 10/30] Fix player top row height regression --- src/app/components/player_component.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/components/player_component.rs b/src/app/components/player_component.rs index f944bb7..fe6dd68 100644 --- a/src/app/components/player_component.rs +++ b/src/app/components/player_component.rs @@ -98,6 +98,7 @@ impl AppComponent for PlayerComponent { ui.vertical(|ui| { // ── Top row: cover + track info + playback info ──────────────── ui.horizontal_top(|ui| { + let top_row_height = tokens::size::ALBUM.max(64.0); CassetteComponent::add(ctx, ui); // Calculate available width for middle section (60% of remaining space) @@ -105,7 +106,7 @@ impl AppComponent for PlayerComponent { let middle_width = remaining_width * 0.6; ui.allocate_ui_with_layout( - vec2(middle_width, ui.available_height()), + vec2(middle_width, top_row_height), egui::Layout::top_down(egui::Align::LEFT), |ui| { if let Some(track) = &selected_track { @@ -174,7 +175,7 @@ impl AppComponent for PlayerComponent { // Add playback info panel on the right ui.allocate_ui_with_layout( - vec2(ui.available_width(), ui.available_height()), + vec2(ui.available_width(), top_row_height), egui::Layout::top_down(egui::Align::RIGHT), |ui| { PlaybackInfoPanel::add(ctx, ui); From 393855047c36f4d37041ab6902343e5fca7fb6d5 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 3 Jul 2026 11:23:07 +0800 Subject: [PATCH 11/30] Fix search result row interactions --- src/app/components/playback_info_panel.rs | 35 ++++++++++++++--------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/src/app/components/playback_info_panel.rs b/src/app/components/playback_info_panel.rs index ba991fa..5115e2c 100644 --- a/src/app/components/playback_info_panel.rs +++ b/src/app/components/playback_info_panel.rs @@ -3,9 +3,7 @@ use crate::app::library::{Library, LibraryItem}; use crate::app::style::{icons, player_button, tokens, ButtonExt}; use crate::app::t; use crate::app::App; -use eframe::egui::{ - self, Button, Frame, Id, Label, Margin, Order, RichText, Sense, Stroke, TextEdit, TextWrapMode, -}; +use eframe::egui::{self, Button, Frame, Id, Margin, Order, RichText, Sense, Stroke, TextEdit}; const SEARCH_PANEL_WIDTH: f32 = 360.0; const SEARCH_PANEL_HEIGHT: f32 = 220.0; @@ -27,7 +25,7 @@ impl AppComponent for PlaybackInfoPanel { fn add(ctx: &mut Self::Context, ui: &mut eframe::egui::Ui) { ui.with_layout(egui::Layout::top_down(egui::Align::RIGHT), |ui| { let search_anchor = ui - .with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + .with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { let search_anchor = Self::render_library_search_controls(ctx, ui); Self::render_download_entry(ctx, ui); search_anchor @@ -381,7 +379,7 @@ impl PlaybackInfoPanel { let row_size = egui::vec2(ui.available_width(), tokens::size::ICON_BTN - 4.0); let (rect, response) = ui.allocate_exact_size(row_size, Sense::click()); - let visuals = ui.visuals(); + let visuals = ui.visuals().clone(); if selected { ui.painter().rect_filled( rect, @@ -401,15 +399,13 @@ impl PlaybackInfoPanel { } else { visuals.text_color() }; - ui.put( - rect.shrink2(egui::vec2(tokens::spacing::SM, 0.0)), - Label::new( - RichText::new(row_text) - .size(tokens::text::SM) - .color(text_color), - ) - .sense(Sense::hover()) - .wrap_mode(TextWrapMode::Truncate), + let text_rect = rect.shrink2(egui::vec2(tokens::spacing::SM, 0.0)); + ui.painter().text( + text_rect.left_center(), + egui::Align2::LEFT_CENTER, + Self::elide_to_width(&row_text, text_rect.width()), + egui::FontId::proportional(tokens::text::SM), + text_color, ); if response.clicked() { @@ -460,4 +456,15 @@ impl PlaybackInfoPanel { .take(50) .collect() } + + fn elide_to_width(text: &str, width: f32) -> String { + let max_chars = (width / 8.0).floor().max(8.0) as usize; + let char_count = text.chars().count(); + if char_count <= max_chars { + return text.to_string(); + } + + let keep = max_chars.saturating_sub(3); + format!("{}...", text.chars().take(keep).collect::()) + } } From 60500615858331dac587a9d33f19fade9d430509 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 3 Jul 2026 11:35:29 +0800 Subject: [PATCH 12/30] Center player tool icons --- src/app/components/playback_info_panel.rs | 32 +++++++++++------------ 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/app/components/playback_info_panel.rs b/src/app/components/playback_info_panel.rs index 5115e2c..41ca839 100644 --- a/src/app/components/playback_info_panel.rs +++ b/src/app/components/playback_info_panel.rs @@ -3,7 +3,7 @@ use crate::app::library::{Library, LibraryItem}; use crate::app::style::{icons, player_button, tokens, ButtonExt}; use crate::app::t; use crate::app::App; -use eframe::egui::{self, Button, Frame, Id, Margin, Order, RichText, Sense, Stroke, TextEdit}; +use eframe::egui::{self, Frame, Id, Margin, Order, RichText, Sense, Stroke, TextEdit}; const SEARCH_PANEL_WIDTH: f32 = 360.0; const SEARCH_PANEL_HEIGHT: f32 = 220.0; @@ -44,16 +44,19 @@ impl PlaybackInfoPanel { Id::new(("playback_info_panel_library_search", key)) } + fn tool_button(ui: &mut egui::Ui, icon: &str, active: bool) -> egui::Response { + ui.allocate_ui_with_layout( + egui::vec2(tokens::size::ICON_BTN, tokens::size::ICON_BTN), + egui::Layout::centered_and_justified(egui::Direction::TopDown), + |ui| ui.add(player_button(icon, active)), + ) + .inner + } + fn render_download_entry(ctx: &mut App, ui: &mut egui::Ui) { let active = ctx.ui_state.show_youtube_download_dialog || ctx.ui_state.youtube_download_in_progress; - let button = ui - .allocate_ui_with_layout( - egui::vec2(tokens::size::ICON_BTN, tokens::size::ICON_BTN), - egui::Layout::centered_and_justified(egui::Direction::TopDown), - |ui| ui.add(player_button(icons::DOWNLOAD, active)), - ) - .inner + let button = Self::tool_button(ui, icons::DOWNLOAD, active) .on_hover_text(t("download_authorized_audio")); if button.clicked() { @@ -189,9 +192,8 @@ impl PlaybackInfoPanel { let mut should_search = false; if search_active { - let close_response = ui - .add(Button::new(icons::CLOSE).player_style()) - .on_hover_text(t("close_search")); + let close_response = + Self::tool_button(ui, icons::CLOSE, false).on_hover_text(t("close_search")); anchor_rect = Some(close_response.rect); let editor_id = Self::search_id("editor"); @@ -216,9 +218,8 @@ impl PlaybackInfoPanel { anchor_rect = Some(anchor_rect.map_or(response.rect, |rect| rect.union(response.rect))); ui.memory_mut(|mem| mem.data.insert_temp(search_text_id, search_text.clone())); - let search_response = ui - .add(Button::new(icons::SEARCH).player_style()) - .on_hover_text(t("library_search")); + let search_response = + Self::tool_button(ui, icons::SEARCH, false).on_hover_text(t("library_search")); anchor_rect = Some(anchor_rect.map_or(search_response.rect, |rect| { rect.union(search_response.rect) })); @@ -236,8 +237,7 @@ impl PlaybackInfoPanel { mem.data.insert_temp(no_results_id, false); }); } - } else if ui - .add(Button::new(icons::SEARCH).player_style()) + } else if Self::tool_button(ui, icons::SEARCH, false) .on_hover_text(t("library_search")) .clicked() { From cee0f43c495cd58c8743d012e5702a983b51cdce Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 3 Jul 2026 11:39:50 +0800 Subject: [PATCH 13/30] Align search input height with tool buttons --- src/app/components/playback_info_panel.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/components/playback_info_panel.rs b/src/app/components/playback_info_panel.rs index 41ca839..ae4a9ae 100644 --- a/src/app/components/playback_info_panel.rs +++ b/src/app/components/playback_info_panel.rs @@ -209,10 +209,10 @@ impl PlaybackInfoPanel { }); } - let response = ui.add( + let response = ui.add_sized( + [180.0, tokens::size::ICON_BTN], TextEdit::singleline(&mut search_text) .id(editor_id) - .desired_width(180.0) .hint_text(t("type_to_search")), ); anchor_rect = Some(anchor_rect.map_or(response.rect, |rect| rect.union(response.rect))); From de86455e70ff2ca469a6c991fbb97d81098e22c7 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Sat, 4 Jul 2026 15:20:51 +0800 Subject: [PATCH 14/30] Unify active search control styling --- src/app/components/playback_info_panel.rs | 70 ++++++++++++++++------- 1 file changed, 50 insertions(+), 20 deletions(-) diff --git a/src/app/components/playback_info_panel.rs b/src/app/components/playback_info_panel.rs index ae4a9ae..fb8c961 100644 --- a/src/app/components/playback_info_panel.rs +++ b/src/app/components/playback_info_panel.rs @@ -3,7 +3,7 @@ use crate::app::library::{Library, LibraryItem}; use crate::app::style::{icons, player_button, tokens, ButtonExt}; use crate::app::t; use crate::app::App; -use eframe::egui::{self, Frame, Id, Margin, Order, RichText, Sense, Stroke, TextEdit}; +use eframe::egui::{self, Button, Frame, Id, Margin, Order, RichText, Sense, Stroke, TextEdit}; const SEARCH_PANEL_WIDTH: f32 = 360.0; const SEARCH_PANEL_HEIGHT: f32 = 220.0; @@ -192,10 +192,6 @@ impl PlaybackInfoPanel { let mut should_search = false; if search_active { - let close_response = - Self::tool_button(ui, icons::CLOSE, false).on_hover_text(t("close_search")); - anchor_rect = Some(close_response.rect); - let editor_id = Self::search_id("editor"); let first_frame_id = Self::search_id("first_frame"); let is_first_frame = ui @@ -209,26 +205,60 @@ impl PlaybackInfoPanel { }); } - let response = ui.add_sized( - [180.0, tokens::size::ICON_BTN], - TextEdit::singleline(&mut search_text) - .id(editor_id) - .hint_text(t("type_to_search")), - ); - anchor_rect = Some(anchor_rect.map_or(response.rect, |rect| rect.union(response.rect))); - ui.memory_mut(|mem| mem.data.insert_temp(search_text_id, search_text.clone())); + let mut text_response: Option = None; + let mut search_clicked = false; + let mut close_clicked = false; + + let frame_response = Frame::new() + .corner_radius(tokens::radius::SM) + .inner_margin(Margin::symmetric(tokens::spacing::SM as i8, 0)) + .stroke(ui.visuals().widgets.active.bg_stroke) + .show(ui, |ui| { + ui.set_min_size(egui::vec2(260.0, tokens::size::ICON_BTN)); + ui.set_max_height(tokens::size::ICON_BTN); + ui.spacing_mut().item_spacing.x = tokens::spacing::XS; + + ui.horizontal_centered(|ui| { + search_clicked = ui + .add( + Button::new(icons::SEARCH) + .frame(false) + .min_size(egui::vec2(24.0, 24.0)), + ) + .on_hover_text(t("library_search")) + .clicked(); + + text_response = Some( + ui.add_sized( + [196.0, tokens::size::ICON_BTN], + TextEdit::singleline(&mut search_text) + .id(editor_id) + .frame(false) + .hint_text(t("type_to_search")), + ), + ); + + close_clicked = ui + .add( + Button::new(icons::CLOSE) + .frame(false) + .min_size(egui::vec2(24.0, 24.0)), + ) + .on_hover_text(t("close_search")) + .clicked(); + }); + }) + .response; - let search_response = - Self::tool_button(ui, icons::SEARCH, false).on_hover_text(t("library_search")); - anchor_rect = Some(anchor_rect.map_or(search_response.rect, |rect| { - rect.union(search_response.rect) - })); + anchor_rect = Some(frame_response.rect); + let response = text_response.expect("search text edit should render"); + ui.memory_mut(|mem| mem.data.insert_temp(search_text_id, search_text.clone())); should_search = response.changed() - || search_response.clicked() + || search_clicked || (response.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))); - if close_response.clicked() { + if close_clicked { search_active = false; search_text.clear(); ui.memory_mut(|mem| { From c7b12dce73253a89088a281ae6e9db8bd9113aa0 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Sat, 4 Jul 2026 15:25:56 +0800 Subject: [PATCH 15/30] Refine active search field alignment --- src/app/components/playback_info_panel.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/components/playback_info_panel.rs b/src/app/components/playback_info_panel.rs index fb8c961..4a583d4 100644 --- a/src/app/components/playback_info_panel.rs +++ b/src/app/components/playback_info_panel.rs @@ -218,7 +218,7 @@ impl PlaybackInfoPanel { ui.set_max_height(tokens::size::ICON_BTN); ui.spacing_mut().item_spacing.x = tokens::spacing::XS; - ui.horizontal_centered(|ui| { + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { search_clicked = ui .add( Button::new(icons::SEARCH) @@ -230,7 +230,7 @@ impl PlaybackInfoPanel { text_response = Some( ui.add_sized( - [196.0, tokens::size::ICON_BTN], + [196.0, tokens::size::ICON_BTN - 6.0], TextEdit::singleline(&mut search_text) .id(editor_id) .frame(false) From 5d49531a64f39fb8c5a6033dd5d6d1a93a77bb36 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Sat, 4 Jul 2026 15:30:54 +0800 Subject: [PATCH 16/30] Expand active search field --- src/app/components/playback_info_panel.rs | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/src/app/components/playback_info_panel.rs b/src/app/components/playback_info_panel.rs index 4a583d4..6de3508 100644 --- a/src/app/components/playback_info_panel.rs +++ b/src/app/components/playback_info_panel.rs @@ -206,7 +206,6 @@ impl PlaybackInfoPanel { } let mut text_response: Option = None; - let mut search_clicked = false; let mut close_clicked = false; let frame_response = Frame::new() @@ -214,23 +213,14 @@ impl PlaybackInfoPanel { .inner_margin(Margin::symmetric(tokens::spacing::SM as i8, 0)) .stroke(ui.visuals().widgets.active.bg_stroke) .show(ui, |ui| { - ui.set_min_size(egui::vec2(260.0, tokens::size::ICON_BTN)); + ui.set_min_size(egui::vec2(SEARCH_PANEL_WIDTH, tokens::size::ICON_BTN)); ui.set_max_height(tokens::size::ICON_BTN); ui.spacing_mut().item_spacing.x = tokens::spacing::XS; ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { - search_clicked = ui - .add( - Button::new(icons::SEARCH) - .frame(false) - .min_size(egui::vec2(24.0, 24.0)), - ) - .on_hover_text(t("library_search")) - .clicked(); - text_response = Some( ui.add_sized( - [196.0, tokens::size::ICON_BTN - 6.0], + [SEARCH_PANEL_WIDTH - 54.0, tokens::size::ICON_BTN - 6.0], TextEdit::singleline(&mut search_text) .id(editor_id) .frame(false) @@ -255,7 +245,6 @@ impl PlaybackInfoPanel { ui.memory_mut(|mem| mem.data.insert_temp(search_text_id, search_text.clone())); should_search = response.changed() - || search_clicked || (response.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))); if close_clicked { From 21c618d677c16193f757e2f1d0084abd295a74ff Mon Sep 17 00:00:00 2001 From: RetricSu Date: Sat, 4 Jul 2026 15:34:47 +0800 Subject: [PATCH 17/30] Constrain active search layout --- src/app/components/playback_info_panel.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/app/components/playback_info_panel.rs b/src/app/components/playback_info_panel.rs index 6de3508..033e663 100644 --- a/src/app/components/playback_info_panel.rs +++ b/src/app/components/playback_info_panel.rs @@ -5,7 +5,7 @@ use crate::app::t; use crate::app::App; use eframe::egui::{self, Button, Frame, Id, Margin, Order, RichText, Sense, Stroke, TextEdit}; -const SEARCH_PANEL_WIDTH: f32 = 360.0; +const SEARCH_PANEL_WIDTH: f32 = 560.0; const SEARCH_PANEL_HEIGHT: f32 = 220.0; #[derive(Clone)] @@ -192,6 +192,7 @@ impl PlaybackInfoPanel { let mut should_search = false; if search_active { + let search_width = ui.available_width().min(SEARCH_PANEL_WIDTH); let editor_id = Self::search_id("editor"); let first_frame_id = Self::search_id("first_frame"); let is_first_frame = ui @@ -213,14 +214,18 @@ impl PlaybackInfoPanel { .inner_margin(Margin::symmetric(tokens::spacing::SM as i8, 0)) .stroke(ui.visuals().widgets.active.bg_stroke) .show(ui, |ui| { - ui.set_min_size(egui::vec2(SEARCH_PANEL_WIDTH, tokens::size::ICON_BTN)); + ui.set_min_size(egui::vec2(search_width, tokens::size::ICON_BTN)); + ui.set_max_width(search_width); ui.set_max_height(tokens::size::ICON_BTN); ui.spacing_mut().item_spacing.x = tokens::spacing::XS; ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { text_response = Some( ui.add_sized( - [SEARCH_PANEL_WIDTH - 54.0, tokens::size::ICON_BTN - 6.0], + [ + (search_width - 54.0).max(120.0), + tokens::size::ICON_BTN - 6.0, + ], TextEdit::singleline(&mut search_text) .id(editor_id) .frame(false) @@ -306,9 +311,10 @@ impl PlaybackInfoPanel { if show_results || no_results { let screen_rect = ui.ctx().screen_rect(); - let x = (anchor.right() - SEARCH_PANEL_WIDTH) + let panel_width = anchor.width().min(SEARCH_PANEL_WIDTH); + let x = (anchor.right() - panel_width) .max(screen_rect.left() + tokens::spacing::SM) - .min(screen_rect.right() - SEARCH_PANEL_WIDTH - tokens::spacing::SM); + .min(screen_rect.right() - panel_width - tokens::spacing::SM); let y = (anchor.bottom() + tokens::spacing::XS) .min(screen_rect.bottom() - SEARCH_PANEL_HEIGHT - tokens::spacing::SM); @@ -327,7 +333,7 @@ impl PlaybackInfoPanel { ui.visuals().widgets.noninteractive.bg_stroke.color, )) .show(ui, |ui| { - ui.set_width(SEARCH_PANEL_WIDTH); + ui.set_width(panel_width); ui.set_max_height(SEARCH_PANEL_HEIGHT); if no_results { From 26461f752853e38bb0db1837b0799375d03b73e2 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Sat, 4 Jul 2026 15:37:51 +0800 Subject: [PATCH 18/30] Reduce active search width --- src/app/components/playback_info_panel.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/components/playback_info_panel.rs b/src/app/components/playback_info_panel.rs index 033e663..fd0052f 100644 --- a/src/app/components/playback_info_panel.rs +++ b/src/app/components/playback_info_panel.rs @@ -5,7 +5,7 @@ use crate::app::t; use crate::app::App; use eframe::egui::{self, Button, Frame, Id, Margin, Order, RichText, Sense, Stroke, TextEdit}; -const SEARCH_PANEL_WIDTH: f32 = 560.0; +const SEARCH_PANEL_WIDTH: f32 = 280.0; const SEARCH_PANEL_HEIGHT: f32 = 220.0; #[derive(Clone)] From c22546305de69f1154922cd58e20c51a20bac658 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Sat, 4 Jul 2026 15:41:32 +0800 Subject: [PATCH 19/30] Right align search close button --- src/app/components/playback_info_panel.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/app/components/playback_info_panel.rs b/src/app/components/playback_info_panel.rs index fd0052f..04f8f56 100644 --- a/src/app/components/playback_info_panel.rs +++ b/src/app/components/playback_info_panel.rs @@ -220,12 +220,14 @@ impl PlaybackInfoPanel { ui.spacing_mut().item_spacing.x = tokens::spacing::XS; ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + let close_button_size = egui::vec2(24.0, 24.0); + let text_width = (ui.available_width() + - close_button_size.x + - ui.spacing().item_spacing.x) + .max(120.0); text_response = Some( ui.add_sized( - [ - (search_width - 54.0).max(120.0), - tokens::size::ICON_BTN - 6.0, - ], + [text_width, tokens::size::ICON_BTN - 6.0], TextEdit::singleline(&mut search_text) .id(editor_id) .frame(false) @@ -237,7 +239,7 @@ impl PlaybackInfoPanel { .add( Button::new(icons::CLOSE) .frame(false) - .min_size(egui::vec2(24.0, 24.0)), + .min_size(close_button_size), ) .on_hover_text(t("close_search")) .clicked(); From 784280ef53d6a3a746c7bd82f5e1095d67e829b0 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Sat, 4 Jul 2026 15:52:04 +0800 Subject: [PATCH 20/30] Add YouTube discovery search --- src/app/components/playback_info_panel.rs | 140 ++++++++++++++++++++++ src/app/core.rs | 32 +++++ src/app/i18n.rs | 57 +++++++++ src/app/state/ui_state.rs | 22 +++- src/app/style/icons.rs | 1 + src/app/ui.rs | 5 +- src/lib/services/mod.rs | 4 +- src/lib/services/youtube_download.rs | 116 ++++++++++++++++++ 8 files changed, 374 insertions(+), 3 deletions(-) diff --git a/src/app/components/playback_info_panel.rs b/src/app/components/playback_info_panel.rs index 04f8f56..ba79574 100644 --- a/src/app/components/playback_info_panel.rs +++ b/src/app/components/playback_info_panel.rs @@ -1,4 +1,5 @@ use super::AppComponent; +use crate::app::lib_services::YoutubeSearchResult; use crate::app::library::{Library, LibraryItem}; use crate::app::style::{icons, player_button, tokens, ButtonExt}; use crate::app::t; @@ -28,6 +29,7 @@ impl AppComponent for PlaybackInfoPanel { .with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { let search_anchor = Self::render_library_search_controls(ctx, ui); Self::render_download_entry(ctx, ui); + Self::render_discover_entry(ctx, ui); search_anchor }) .inner; @@ -66,6 +68,144 @@ impl PlaybackInfoPanel { Self::render_download_dialog(ctx, ui.ctx().clone()); } + fn render_discover_entry(ctx: &mut App, ui: &mut egui::Ui) { + let active = + ctx.ui_state.show_youtube_discover_dialog || ctx.ui_state.youtube_discover_in_progress; + let button = + Self::tool_button(ui, icons::DISCOVER, active).on_hover_text(t("youtube_discover")); + + if button.clicked() { + ctx.ui_state.show_youtube_discover_dialog = true; + } + + Self::render_discover_dialog(ctx, ui.ctx().clone()); + } + + fn render_discover_dialog(ctx: &mut App, egui_ctx: egui::Context) { + if !ctx.ui_state.show_youtube_discover_dialog { + return; + } + + let mut open = ctx.ui_state.show_youtube_discover_dialog; + egui::Window::new(t("youtube_discover")) + .id(egui::Id::new("youtube_discover_dialog")) + .open(&mut open) + .collapsible(false) + .resizable(false) + .show(&egui_ctx, |ui| { + ui.set_min_width(440.0); + ui.label(RichText::new(t("youtube_discover_notice")).weak()); + ui.add_space(tokens::spacing::XS); + + ui.label(t("youtube_discover_query")); + let query_response = ui.add_enabled( + !ctx.ui_state.youtube_discover_in_progress, + TextEdit::singleline(&mut ctx.ui_state.youtube_discover_query) + .desired_width(420.0) + .hint_text(t("youtube_discover_placeholder")), + ); + let enter_pressed = + query_response.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + + ui.add_space(tokens::spacing::XS); + if let Some(status) = &ctx.ui_state.youtube_discover_status { + ui.label(RichText::new(status).weak()); + ui.add_space(tokens::spacing::XS); + } + + ui.horizontal(|ui| { + let can_search = !ctx.ui_state.youtube_discover_in_progress + && !ctx.ui_state.youtube_discover_query.trim().is_empty(); + if ui + .add_enabled(can_search, egui::Button::new(t("search"))) + .clicked() + || (can_search && enter_pressed) + { + ctx.start_youtube_discover_search(); + } + + if ui + .add_enabled( + !ctx.ui_state.youtube_discover_in_progress, + egui::Button::new(t("clear")), + ) + .clicked() + { + ctx.ui_state.youtube_discover_query.clear(); + ctx.ui_state.youtube_discover_results.clear(); + ctx.ui_state.youtube_discover_status = None; + } + }); + + if ctx.ui_state.youtube_discover_in_progress { + ui.add_space(tokens::spacing::XS); + ui.spinner(); + } + + Self::render_discover_results(ctx, ui); + }); + + ctx.ui_state.show_youtube_discover_dialog = open; + } + + fn render_discover_results(ctx: &mut App, ui: &mut egui::Ui) { + if ctx.ui_state.youtube_discover_results.is_empty() { + return; + } + + ui.add_space(tokens::spacing::SM); + egui::ScrollArea::vertical() + .max_height(260.0) + .auto_shrink([false, false]) + .show(ui, |ui| { + let results = ctx.ui_state.youtube_discover_results.clone(); + for result in results { + Self::render_discover_result_row(ctx, ui, &result); + } + }); + } + + fn render_discover_result_row(ctx: &mut App, ui: &mut egui::Ui, result: &YoutubeSearchResult) { + Frame::new() + .inner_margin(Margin::symmetric( + tokens::spacing::SM as i8, + tokens::spacing::XS as i8, + )) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.set_width(340.0); + ui.label(RichText::new(&result.title).strong()); + ui.label( + RichText::new(Self::discover_result_meta(result)) + .size(tokens::text::SM) + .weak(), + ); + }); + + if ui.button(t("use_result")).clicked() { + ctx.ui_state.youtube_download_url = result.url.clone(); + ctx.ui_state.show_youtube_download_dialog = true; + ctx.ui_state.show_youtube_discover_dialog = false; + ctx.ui_state.youtube_download_status = None; + } + }); + }); + } + + fn discover_result_meta(result: &YoutubeSearchResult) -> String { + match result.duration { + Some(duration) => format!("{} · {}", result.channel, Self::format_duration(duration)), + None => result.channel.clone(), + } + } + + fn format_duration(seconds: u64) -> String { + let minutes = seconds / 60; + let seconds = seconds % 60; + format!("{}:{:02}", minutes, seconds) + } + fn render_download_dialog(ctx: &mut App, egui_ctx: egui::Context) { if !ctx.ui_state.show_youtube_download_dialog { return; diff --git a/src/app/core.rs b/src/app/core.rs index 8dc23e3..5272f2c 100644 --- a/src/app/core.rs +++ b/src/app/core.rs @@ -315,6 +315,24 @@ impl App { ); } + pub fn start_youtube_discover_search(&mut self) { + if self.ui_state.youtube_discover_in_progress { + return; + } + + let query = self.ui_state.youtube_discover_query.trim().to_string(); + if query.is_empty() { + self.ui_state.youtube_discover_status = Some(i18n::t("search_query_required")); + return; + } + + self.ui_state.youtube_discover_in_progress = true; + self.ui_state.youtube_discover_results.clear(); + self.ui_state.youtube_discover_status = Some(i18n::t("youtube_discover_searching")); + + YoutubeDownloadService::search_youtube(query, 8, self.youtube_download_tx().clone()); + } + pub fn handle_youtube_download_event(&mut self, event: YoutubeDownloadEvent) { match event { YoutubeDownloadEvent::Progress(progress) => { @@ -357,6 +375,20 @@ impl App { self.ui_state.youtube_download_last_file_count = None; self.ui_state.youtube_download_status = Some(err); } + YoutubeDownloadEvent::SearchFinished(Ok(results)) => { + self.ui_state.youtube_discover_in_progress = false; + self.ui_state.youtube_discover_status = Some(if results.is_empty() { + i18n::t("no_matches_found") + } else { + i18n::tf("youtube_discover_results", &[&results.len().to_string()]) + }); + self.ui_state.youtube_discover_results = results; + } + YoutubeDownloadEvent::SearchFinished(Err(err)) => { + self.ui_state.youtube_discover_in_progress = false; + self.ui_state.youtube_discover_results.clear(); + self.ui_state.youtube_discover_status = Some(err); + } } } diff --git a/src/app/i18n.rs b/src/app/i18n.rs index 2cfff80..12904cf 100644 --- a/src/app/i18n.rs +++ b/src/app/i18n.rs @@ -172,6 +172,36 @@ pub fn init() { "downloaded_files_done".to_string(), "Downloaded {} file(s). Folder is synced.".to_string(), ); + en.insert( + "youtube_discover".to_string(), + "Discover YouTube".to_string(), + ); + en.insert( + "youtube_discover_notice".to_string(), + "Search YouTube, then use a result as the download URL.".to_string(), + ); + en.insert( + "youtube_discover_query".to_string(), + "Search YouTube".to_string(), + ); + en.insert( + "youtube_discover_placeholder".to_string(), + "Song, artist, live session...".to_string(), + ); + en.insert("search".to_string(), "Search".to_string()); + en.insert("use_result".to_string(), "Use".to_string()); + en.insert( + "search_query_required".to_string(), + "Enter a search query".to_string(), + ); + en.insert( + "youtube_discover_searching".to_string(), + "Searching YouTube...".to_string(), + ); + en.insert( + "youtube_discover_results".to_string(), + "{} result(s) found".to_string(), + ); // Playlist tabs component en.insert("rename".to_string(), "Rename".to_string()); @@ -320,6 +350,33 @@ pub fn init() { "downloaded_files_done".to_string(), "已下载 {} 个文件,文件夹已同步。".to_string(), ); + zh.insert("youtube_discover".to_string(), "发现 YouTube".to_string()); + zh.insert( + "youtube_discover_notice".to_string(), + "搜索 YouTube,然后把结果作为下载 URL 使用。".to_string(), + ); + zh.insert( + "youtube_discover_query".to_string(), + "搜索 YouTube".to_string(), + ); + zh.insert( + "youtube_discover_placeholder".to_string(), + "歌曲、艺人、现场版本...".to_string(), + ); + zh.insert("search".to_string(), "搜索".to_string()); + zh.insert("use_result".to_string(), "使用".to_string()); + zh.insert( + "search_query_required".to_string(), + "请输入搜索关键词".to_string(), + ); + zh.insert( + "youtube_discover_searching".to_string(), + "正在搜索 YouTube...".to_string(), + ); + zh.insert( + "youtube_discover_results".to_string(), + "找到 {} 个结果".to_string(), + ); // Playlist tabs component zh.insert("rename".to_string(), "重命名".to_string()); diff --git a/src/app/state/ui_state.rs b/src/app/state/ui_state.rs index 84416d2..6e38e70 100644 --- a/src/app/state/ui_state.rs +++ b/src/app/state/ui_state.rs @@ -1,4 +1,4 @@ -use crate::app::lib_services::YoutubeDownloadService; +use crate::app::lib_services::{YoutubeDownloadService, YoutubeSearchResult}; use crate::app::libstate::lyrics_state::LyricsFetchState; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -73,6 +73,21 @@ pub struct UiState { /// Last user-facing download status or error message. pub youtube_download_status: Option, + /// Whether the YouTube discovery window is open. + pub show_youtube_discover_dialog: bool, + + /// Query entered in the YouTube discovery form. + pub youtube_discover_query: String, + + /// Whether a YouTube discovery search is currently running. + pub youtube_discover_in_progress: bool, + + /// Latest YouTube discovery search results. + pub youtube_discover_results: Vec, + + /// Last user-facing discovery status or error message. + pub youtube_discover_status: Option, + /// Volume value to restore when the user un-mutes via the speaker icon. /// `None` while not muted. Not serialized: a fresh launch always starts /// with whatever volume the player itself remembers. @@ -117,6 +132,11 @@ impl Default for UiState { youtube_download_last_file_count: None, youtube_download_include_playlist: false, youtube_download_status: None, + show_youtube_discover_dialog: false, + youtube_discover_query: String::new(), + youtube_discover_in_progress: false, + youtube_discover_results: Vec::new(), + youtube_discover_status: None, volume_before_mute: None, last_persistence_save: Instant::now(), desktop_lyrics_font_size: 48.0, diff --git a/src/app/style/icons.rs b/src/app/style/icons.rs index c0e34a6..1a41650 100644 --- a/src/app/style/icons.rs +++ b/src/app/style/icons.rs @@ -29,6 +29,7 @@ pub const LYRICS_PANEL: &str = p::SUBTITLES; pub const SEARCH: &str = p::MAGNIFYING_GLASS; pub const CLOSE: &str = p::X; pub const DOWNLOAD: &str = p::DOWNLOAD_SIMPLE; +pub const DISCOVER: &str = p::COMPASS; pub const FOLDER: &str = p::FOLDER_OPEN; /// Generic "add / new" affordance — used by library/playlist "+" buttons. pub const PLUS: &str = p::PLUS; diff --git a/src/app/ui.rs b/src/app/ui.rs index cf1087c..2218ecc 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -190,7 +190,10 @@ impl eframe::App for App { self.refresh_youtube_download_processor(); self.pump_audio_events(); - if self.ui_state.is_importing || self.ui_state.youtube_download_in_progress { + if self.ui_state.is_importing + || self.ui_state.youtube_download_in_progress + || self.ui_state.youtube_discover_in_progress + { ctx.request_repaint_after(std::time::Duration::from_millis(100)); } self.refresh_lyrics_display(); diff --git a/src/lib/services/mod.rs b/src/lib/services/mod.rs index 33cbbb8..53c6504 100644 --- a/src/lib/services/mod.rs +++ b/src/lib/services/mod.rs @@ -13,4 +13,6 @@ pub use library_import::LibraryImportService; pub use lyrics_manager::LyricsManager; pub use metadata_editor::MetadataEditor; pub use player_restore::PlayerRestoreService; -pub use youtube_download::{YoutubeDownloadEvent, YoutubeDownloadResult, YoutubeDownloadService}; +pub use youtube_download::{ + YoutubeDownloadEvent, YoutubeDownloadResult, YoutubeDownloadService, YoutubeSearchResult, +}; diff --git a/src/lib/services/youtube_download.rs b/src/lib/services/youtube_download.rs index be89b15..69fa3c1 100644 --- a/src/lib/services/youtube_download.rs +++ b/src/lib/services/youtube_download.rs @@ -8,6 +8,7 @@ use std::time::Duration; pub enum YoutubeDownloadEvent { Progress(f32), Finished(Result), + SearchFinished(Result, String>), } #[derive(Debug, Clone)] @@ -16,6 +17,14 @@ pub struct YoutubeDownloadResult { pub downloaded_files: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct YoutubeSearchResult { + pub title: String, + pub channel: String, + pub url: String, + pub duration: Option, +} + pub struct YoutubeDownloadService; const PROGRESS_PREFIX: &str = "BIRD_PLAYER_PROGRESS:"; @@ -42,6 +51,49 @@ impl YoutubeDownloadService { }); } + pub fn search_youtube(query: String, limit: usize, event_tx: Sender) { + std::thread::spawn(move || { + let result = Self::run_search(&query, limit); + let _ = event_tx.send(YoutubeDownloadEvent::SearchFinished(result)); + }); + } + + fn run_search(query: &str, limit: usize) -> Result, String> { + let query = query.trim(); + if query.is_empty() { + return Err("Search query is required".to_string()); + } + + if Self::yt_dlp_command().arg("--version").output().is_err() { + return Err( + "yt-dlp was not found. Install yt-dlp and make sure it is available in PATH." + .to_string(), + ); + } + + let search_target = format!("ytsearch{}:{}", limit.max(1), query); + let output = Self::yt_dlp_command() + .arg("--dump-json") + .arg("--flat-playlist") + .arg("--no-warnings") + .arg("--") + .arg(search_target) + .output() + .map_err(|err| format!("Failed to start yt-dlp: {}", err))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(if stderr.is_empty() { + format!("yt-dlp failed with status {}", output.status) + } else { + stderr + }); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(Self::parse_search_results(&stdout)) + } + fn run_download( url: &str, output_dir: &Path, @@ -314,6 +366,54 @@ impl YoutubeDownloadService { } rebuilt } + + fn parse_search_results(output: &str) -> Vec { + output + .lines() + .filter_map(Self::parse_search_result_line) + .collect() + } + + fn parse_search_result_line(line: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(line).ok()?; + let title = value.get("title")?.as_str()?.trim().to_string(); + if title.is_empty() { + return None; + } + + let channel = value + .get("channel") + .or_else(|| value.get("uploader")) + .and_then(|value| value.as_str()) + .unwrap_or("YouTube") + .trim() + .to_string(); + + let url = value + .get("webpage_url") + .or_else(|| value.get("url")) + .and_then(|value| value.as_str()) + .map(|url| { + if url.starts_with("http://") || url.starts_with("https://") { + url.to_string() + } else { + format!("https://www.youtube.com/watch?v={}", url) + } + }) + .or_else(|| { + value + .get("id") + .and_then(|value| value.as_str()) + .map(|id| format!("https://www.youtube.com/watch?v={}", id)) + })?; + + Some(YoutubeSearchResult { + title, + channel, + url, + duration: value.get("duration").and_then(|value| value.as_u64()), + }) + } } #[cfg(test)] @@ -367,4 +467,20 @@ mod tests { assert!(paths.contains(&std::path::PathBuf::from("/opt/homebrew/bin"))); assert!(paths.contains(&std::path::PathBuf::from("/usr/local/bin"))); } + + #[test] + fn parse_search_results_reads_yt_dlp_json_lines() { + let output = r#"{"id":"abc123","title":"Bird Song","channel":"Bird Channel","duration":245} +{"title":"No URL"} +{"webpage_url":"https://www.youtube.com/watch?v=def456","title":"Second Song","uploader":"Uploader","duration":60}"#; + + let results = YoutubeDownloadService::parse_search_results(output); + + assert_eq!(results.len(), 2); + assert_eq!(results[0].title, "Bird Song"); + assert_eq!(results[0].channel, "Bird Channel"); + assert_eq!(results[0].url, "https://www.youtube.com/watch?v=abc123"); + assert_eq!(results[0].duration, Some(245)); + assert_eq!(results[1].url, "https://www.youtube.com/watch?v=def456"); + } } From a8376fa42e082b342ba4ae539ca70974959571af Mon Sep 17 00:00:00 2001 From: RetricSu Date: Sun, 5 Jul 2026 13:51:16 +0800 Subject: [PATCH 21/30] Show thumbnails in YouTube discovery --- src/app/components/playback_info_panel.rs | 32 ++++++++++++++++++++--- src/app/core.rs | 2 +- src/app/style/icons.rs | 1 + src/lib/services/youtube_download.rs | 32 ++++++++++++++++++++++- 4 files changed, 61 insertions(+), 6 deletions(-) diff --git a/src/app/components/playback_info_panel.rs b/src/app/components/playback_info_panel.rs index ba79574..07c899a 100644 --- a/src/app/components/playback_info_panel.rs +++ b/src/app/components/playback_info_panel.rs @@ -93,7 +93,7 @@ impl PlaybackInfoPanel { .collapsible(false) .resizable(false) .show(&egui_ctx, |ui| { - ui.set_min_width(440.0); + ui.set_min_width(560.0); ui.label(RichText::new(t("youtube_discover_notice")).weak()); ui.add_space(tokens::spacing::XS); @@ -101,7 +101,7 @@ impl PlaybackInfoPanel { let query_response = ui.add_enabled( !ctx.ui_state.youtube_discover_in_progress, TextEdit::singleline(&mut ctx.ui_state.youtube_discover_query) - .desired_width(420.0) + .desired_width(540.0) .hint_text(t("youtube_discover_placeholder")), ); let enter_pressed = @@ -155,7 +155,7 @@ impl PlaybackInfoPanel { ui.add_space(tokens::spacing::SM); egui::ScrollArea::vertical() - .max_height(260.0) + .max_height(360.0) .auto_shrink([false, false]) .show(ui, |ui| { let results = ctx.ui_state.youtube_discover_results.clone(); @@ -173,8 +173,10 @@ impl PlaybackInfoPanel { )) .show(ui, |ui| { ui.horizontal(|ui| { + Self::render_discover_thumbnail(ui, result); + ui.vertical(|ui| { - ui.set_width(340.0); + ui.set_width(392.0); ui.label(RichText::new(&result.title).strong()); ui.label( RichText::new(Self::discover_result_meta(result)) @@ -193,6 +195,28 @@ impl PlaybackInfoPanel { }); } + fn render_discover_thumbnail(ui: &mut egui::Ui, result: &YoutubeSearchResult) { + let size = egui::vec2(56.0, 56.0); + if let Some(url) = &result.thumbnail_url { + ui.add( + egui::Image::from_uri(url) + .fit_to_exact_size(size) + .corner_radius(tokens::radius::SM), + ); + } else { + let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); + ui.painter() + .rect_filled(rect, tokens::radius::SM, ui.visuals().faint_bg_color); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + icons::YOUTUBE, + egui::FontId::proportional(22.0), + ui.visuals().weak_text_color(), + ); + } + } + fn discover_result_meta(result: &YoutubeSearchResult) -> String { match result.duration { Some(duration) => format!("{} · {}", result.channel, Self::format_duration(duration)), diff --git a/src/app/core.rs b/src/app/core.rs index 5272f2c..0a1490f 100644 --- a/src/app/core.rs +++ b/src/app/core.rs @@ -330,7 +330,7 @@ impl App { self.ui_state.youtube_discover_results.clear(); self.ui_state.youtube_discover_status = Some(i18n::t("youtube_discover_searching")); - YoutubeDownloadService::search_youtube(query, 8, self.youtube_download_tx().clone()); + YoutubeDownloadService::search_youtube(query, 20, self.youtube_download_tx().clone()); } pub fn handle_youtube_download_event(&mut self, event: YoutubeDownloadEvent) { diff --git a/src/app/style/icons.rs b/src/app/style/icons.rs index 1a41650..87392f7 100644 --- a/src/app/style/icons.rs +++ b/src/app/style/icons.rs @@ -30,6 +30,7 @@ pub const SEARCH: &str = p::MAGNIFYING_GLASS; pub const CLOSE: &str = p::X; pub const DOWNLOAD: &str = p::DOWNLOAD_SIMPLE; pub const DISCOVER: &str = p::COMPASS; +pub const YOUTUBE: &str = p::YOUTUBE_LOGO; pub const FOLDER: &str = p::FOLDER_OPEN; /// Generic "add / new" affordance — used by library/playlist "+" buttons. pub const PLUS: &str = p::PLUS; diff --git a/src/lib/services/youtube_download.rs b/src/lib/services/youtube_download.rs index 69fa3c1..e6b48c1 100644 --- a/src/lib/services/youtube_download.rs +++ b/src/lib/services/youtube_download.rs @@ -23,6 +23,7 @@ pub struct YoutubeSearchResult { pub channel: String, pub url: String, pub duration: Option, + pub thumbnail_url: Option, } pub struct YoutubeDownloadService; @@ -412,8 +413,33 @@ impl YoutubeDownloadService { channel, url, duration: value.get("duration").and_then(|value| value.as_u64()), + thumbnail_url: Self::search_result_thumbnail_url(&value), }) } + + fn search_result_thumbnail_url(value: &serde_json::Value) -> Option { + value + .get("thumbnail") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|url| !url.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| { + value + .get("thumbnails") + .and_then(|value| value.as_array()) + .and_then(|thumbnails| thumbnails.iter().rev().find_map(Self::thumbnail_url)) + }) + } + + fn thumbnail_url(value: &serde_json::Value) -> Option { + value + .get("url") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|url| !url.is_empty()) + .map(ToOwned::to_owned) + } } #[cfg(test)] @@ -470,7 +496,7 @@ mod tests { #[test] fn parse_search_results_reads_yt_dlp_json_lines() { - let output = r#"{"id":"abc123","title":"Bird Song","channel":"Bird Channel","duration":245} + let output = r#"{"id":"abc123","title":"Bird Song","channel":"Bird Channel","duration":245,"thumbnails":[{"url":"https://img.example/small.jpg"},{"url":"https://img.example/large.jpg"}]} {"title":"No URL"} {"webpage_url":"https://www.youtube.com/watch?v=def456","title":"Second Song","uploader":"Uploader","duration":60}"#; @@ -481,6 +507,10 @@ mod tests { assert_eq!(results[0].channel, "Bird Channel"); assert_eq!(results[0].url, "https://www.youtube.com/watch?v=abc123"); assert_eq!(results[0].duration, Some(245)); + assert_eq!( + results[0].thumbnail_url.as_deref(), + Some("https://img.example/large.jpg") + ); assert_eq!(results[1].url, "https://www.youtube.com/watch?v=def456"); } } From 97634853fa1ec2bc886b5da4c11cb8338a995814 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Sun, 5 Jul 2026 13:57:50 +0800 Subject: [PATCH 22/30] Persist YouTube download folder --- src/app/components/playback_info_panel.rs | 1 + src/app/state/ui_state.rs | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/src/app/components/playback_info_panel.rs b/src/app/components/playback_info_panel.rs index 07c899a..cace66e 100644 --- a/src/app/components/playback_info_panel.rs +++ b/src/app/components/playback_info_panel.rs @@ -283,6 +283,7 @@ impl PlaybackInfoPanel { .pick_folder() { ctx.ui_state.youtube_download_dir = folder; + ctx.save_state(); } } }); diff --git a/src/app/state/ui_state.rs b/src/app/state/ui_state.rs index 6e38e70..ef5cd37 100644 --- a/src/app/state/ui_state.rs +++ b/src/app/state/ui_state.rs @@ -152,6 +152,8 @@ pub struct UiSettings { pub library_folders_expanded: bool, pub default_window_height: f64, pub show_lyrics_panel: bool, + #[serde(default = "default_youtube_download_dir")] + pub youtube_download_dir: PathBuf, #[serde(default = "default_auto_fetch_missing_lyrics")] pub auto_fetch_missing_lyrics: bool, pub desktop_lyrics_enabled: bool, @@ -167,6 +169,10 @@ fn default_desktop_lyrics_font_size() -> f32 { 48.0 } +fn default_youtube_download_dir() -> PathBuf { + YoutubeDownloadService::default_download_dir() +} + fn default_auto_fetch_missing_lyrics() -> bool { true } @@ -181,6 +187,7 @@ impl Default for UiSettings { library_folders_expanded: false, default_window_height: crate::app::constants::DEFAULT_WINDOW_HEIGHT as f64, show_lyrics_panel: false, + youtube_download_dir: default_youtube_download_dir(), auto_fetch_missing_lyrics: true, desktop_lyrics_enabled: false, desktop_lyrics_font_size: default_desktop_lyrics_font_size(), @@ -197,6 +204,7 @@ impl UiState { library_folders_expanded: self.library_folders_expanded, default_window_height: self.default_window_height, show_lyrics_panel: self.show_lyrics_panel, + youtube_download_dir: self.youtube_download_dir.clone(), auto_fetch_missing_lyrics: self.auto_fetch_missing_lyrics, desktop_lyrics_enabled: self.desktop_lyrics_enabled, desktop_lyrics_font_size: self.desktop_lyrics_font_size, @@ -210,6 +218,7 @@ impl UiState { self.library_folders_expanded = settings.library_folders_expanded; self.default_window_height = settings.default_window_height; self.show_lyrics_panel = settings.show_lyrics_panel; + self.youtube_download_dir = settings.youtube_download_dir; self.auto_fetch_missing_lyrics = settings.auto_fetch_missing_lyrics; self.desktop_lyrics_enabled = settings.desktop_lyrics_enabled; self.desktop_lyrics_font_size = settings.desktop_lyrics_font_size; From 79c64fc2c4c66f792eea803680ce30843affb449 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Sun, 5 Jul 2026 14:07:35 +0800 Subject: [PATCH 23/30] Make footer draggable and show version --- src/app/components/footer.rs | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/src/app/components/footer.rs b/src/app/components/footer.rs index 63f3ac0..fcca9d3 100644 --- a/src/app/components/footer.rs +++ b/src/app/components/footer.rs @@ -1,5 +1,8 @@ use super::AppComponent; +use crate::app::style::tokens; +use crate::app::version::version_info; use crate::app::App; +use eframe::egui::{self, RichText, Sense}; pub struct Footer; @@ -7,11 +10,23 @@ impl AppComponent for Footer { type Context = App; fn add(ctx: &mut Self::Context, ui: &mut eframe::egui::Ui) { - ui.horizontal(|ui| { + ui.set_min_height(tokens::size::HEADER_HEIGHT); + ui.horizontal_centered(|ui| { + let version_text = format!("v{} ({})", version_info::VERSION, version_info::GIT_HASH); + let version_response = ui.add( + egui::Label::new(RichText::new(version_text).size(tokens::text::SM).weak()) + .sense(Sense::click_and_drag()), + ); + Self::start_drag_from_response(ctx, ui, &version_response); + + ui.add_space(tokens::spacing::MD); + if let Some(current_playlist_idx) = ctx.app_settings.current_playlist_idx { let selection_count = ctx.playlists[current_playlist_idx].selected_indices.len(); if selection_count > 0 { - ui.label(format!("{} selected", selection_count)); + let selection_response = + ui.label(RichText::new(format!("{} selected", selection_count)).weak()); + Self::start_drag_from_response(ctx, ui, &selection_response); if ui.button("Clear Selection").clicked() { let playlist = &mut ctx.playlists[current_playlist_idx]; @@ -19,6 +34,22 @@ impl AppComponent for Footer { } } } + + let drag_rect = ui.available_rect_before_wrap(); + let drag_response = ui.interact( + drag_rect, + ui.id().with("footer_drag_area"), + Sense::click_and_drag(), + ); + Self::start_drag_from_response(ctx, ui, &drag_response); }); } } + +impl Footer { + fn start_drag_from_response(ctx: &App, ui: &egui::Ui, response: &egui::Response) { + if response.drag_started_by(egui::PointerButton::Primary) && !ctx.ui_state.is_maximized { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); + } + } +} From 26620f2ae4538e8e3d31d990451146b9a84e0b5b Mon Sep 17 00:00:00 2001 From: RetricSu Date: Sun, 5 Jul 2026 14:12:42 +0800 Subject: [PATCH 24/30] Slim footer version bar --- src/app/components/footer.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/components/footer.rs b/src/app/components/footer.rs index fcca9d3..c3ef046 100644 --- a/src/app/components/footer.rs +++ b/src/app/components/footer.rs @@ -10,16 +10,16 @@ impl AppComponent for Footer { type Context = App; fn add(ctx: &mut Self::Context, ui: &mut eframe::egui::Ui) { - ui.set_min_height(tokens::size::HEADER_HEIGHT); + ui.set_min_height(16.0); ui.horizontal_centered(|ui| { let version_text = format!("v{} ({})", version_info::VERSION, version_info::GIT_HASH); let version_response = ui.add( - egui::Label::new(RichText::new(version_text).size(tokens::text::SM).weak()) + egui::Label::new(RichText::new(version_text).size(10.0).weak()) .sense(Sense::click_and_drag()), ); Self::start_drag_from_response(ctx, ui, &version_response); - ui.add_space(tokens::spacing::MD); + ui.add_space(tokens::spacing::SM); if let Some(current_playlist_idx) = ctx.app_settings.current_playlist_idx { let selection_count = ctx.playlists[current_playlist_idx].selected_indices.len(); From bb3b46deac5d5540a496bf148508e857698af559 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Sun, 5 Jul 2026 14:19:29 +0800 Subject: [PATCH 25/30] Add bird mark to window chrome --- src/app/components/window_chrome.rs | 55 ++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/src/app/components/window_chrome.rs b/src/app/components/window_chrome.rs index b77413c..fd6ec06 100644 --- a/src/app/components/window_chrome.rs +++ b/src/app/components/window_chrome.rs @@ -1,10 +1,11 @@ use super::language_selector::LanguageSelector; use super::AppComponent; use crate::app::constants::{DEFAULT_WINDOW_HEIGHT, DEFAULT_WINDOW_WIDTH}; +use crate::app::style::tokens; use crate::app::t; use crate::app::version::version_info; use crate::app::App; -use eframe::egui::{self, Color32, RichText, Window}; +use eframe::egui::{self, Color32, RichText, Stroke, Window}; use rfd; pub struct WindowChrome; @@ -19,6 +20,7 @@ impl AppComponent for WindowChrome { // window control buttons on the right edge of the same row. crate::app::style::borderless_button_visuals(ui.visuals_mut()); + Self::render_brand_mark(ui); ui.label(RichText::new("Bird").strong()); ui.separator(); @@ -340,3 +342,54 @@ impl AppComponent for WindowChrome { } } } + +impl WindowChrome { + fn render_brand_mark(ui: &mut egui::Ui) { + let size = egui::vec2(18.0, 18.0); + let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover()); + let to_pos = |x: f32, y: f32| { + egui::pos2( + egui::lerp(rect.left()..=rect.right(), x), + egui::lerp(rect.top()..=rect.bottom(), y), + ) + }; + + let stroke = Stroke::new(1.35, tokens::color::BRAND); + let painter = ui.painter(); + + painter.add(egui::Shape::CubicBezier( + egui::epaint::CubicBezierShape::from_points_stroke( + [ + to_pos(0.30, 0.42), + to_pos(0.50, 0.10), + to_pos(0.78, 0.25), + to_pos(0.98, 0.39), + ], + false, + Color32::TRANSPARENT, + stroke, + ), + )); + + painter.line( + vec![to_pos(0.30, 0.42), to_pos(0.06, 0.52), to_pos(0.38, 0.63)], + stroke, + ); + + painter.add(egui::Shape::CubicBezier( + egui::epaint::CubicBezierShape::from_points_stroke( + [ + to_pos(0.38, 0.63), + to_pos(0.52, 0.69), + to_pos(0.56, 0.80), + to_pos(0.56, 0.98), + ], + false, + Color32::TRANSPARENT, + stroke, + ), + )); + + painter.circle_stroke(to_pos(0.48, 0.39), 2.1, stroke); + } +} From 9f3499199f294c2b08a27d191e8eb969f821f21a Mon Sep 17 00:00:00 2001 From: RetricSu Date: Sun, 5 Jul 2026 14:23:36 +0800 Subject: [PATCH 26/30] Refine bird mark proportions --- src/app/components/window_chrome.rs | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/app/components/window_chrome.rs b/src/app/components/window_chrome.rs index fd6ec06..e19618b 100644 --- a/src/app/components/window_chrome.rs +++ b/src/app/components/window_chrome.rs @@ -345,7 +345,7 @@ impl AppComponent for WindowChrome { impl WindowChrome { fn render_brand_mark(ui: &mut egui::Ui) { - let size = egui::vec2(18.0, 18.0); + let size = egui::vec2(20.0, 18.0); let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover()); let to_pos = |x: f32, y: f32| { egui::pos2( @@ -354,16 +354,16 @@ impl WindowChrome { ) }; - let stroke = Stroke::new(1.35, tokens::color::BRAND); + let stroke = Stroke::new(1.55, tokens::color::BRAND); let painter = ui.painter(); painter.add(egui::Shape::CubicBezier( egui::epaint::CubicBezierShape::from_points_stroke( [ - to_pos(0.30, 0.42), - to_pos(0.50, 0.10), - to_pos(0.78, 0.25), - to_pos(0.98, 0.39), + to_pos(0.34, 0.44), + to_pos(0.48, 0.08), + to_pos(0.78, 0.18), + to_pos(0.96, 0.34), ], false, Color32::TRANSPARENT, @@ -372,17 +372,17 @@ impl WindowChrome { )); painter.line( - vec![to_pos(0.30, 0.42), to_pos(0.06, 0.52), to_pos(0.38, 0.63)], + vec![to_pos(0.34, 0.44), to_pos(0.05, 0.55), to_pos(0.39, 0.68)], stroke, ); painter.add(egui::Shape::CubicBezier( egui::epaint::CubicBezierShape::from_points_stroke( [ - to_pos(0.38, 0.63), - to_pos(0.52, 0.69), - to_pos(0.56, 0.80), - to_pos(0.56, 0.98), + to_pos(0.39, 0.68), + to_pos(0.53, 0.72), + to_pos(0.57, 0.82), + to_pos(0.57, 0.98), ], false, Color32::TRANSPARENT, @@ -390,6 +390,7 @@ impl WindowChrome { ), )); - painter.circle_stroke(to_pos(0.48, 0.39), 2.1, stroke); + painter.line(vec![to_pos(0.05, 0.55), to_pos(0.31, 0.45)], stroke); + painter.circle_stroke(to_pos(0.50, 0.34), 1.95, stroke); } } From 900fce59f54df173380c5962197fb4eb670eeb0c Mon Sep 17 00:00:00 2001 From: RetricSu Date: Sun, 5 Jul 2026 14:26:58 +0800 Subject: [PATCH 27/30] Remove Bird title from chrome --- src/app/components/window_chrome.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/components/window_chrome.rs b/src/app/components/window_chrome.rs index e19618b..200cc6b 100644 --- a/src/app/components/window_chrome.rs +++ b/src/app/components/window_chrome.rs @@ -21,7 +21,6 @@ impl AppComponent for WindowChrome { crate::app::style::borderless_button_visuals(ui.visuals_mut()); Self::render_brand_mark(ui); - ui.label(RichText::new("Bird").strong()); ui.separator(); // Menu list From fbc2736e3b9ca7d002488ba5fd34c50ec7dcb2ab Mon Sep 17 00:00:00 2001 From: RetricSu Date: Sun, 5 Jul 2026 14:31:50 +0800 Subject: [PATCH 28/30] Refresh bundled git version info --- build.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/build.rs b/build.rs index c9f12f6..7fa891c 100644 --- a/build.rs +++ b/build.rs @@ -4,8 +4,13 @@ use std::path::Path; use std::process::Command; fn main() { - // Make cargo track changes to Cargo.toml + // Make cargo track changes to Cargo.toml and the current git ref. println!("cargo:rerun-if-changed=Cargo.toml"); + println!("cargo:rerun-if-changed=.git/HEAD"); + println!("cargo:rerun-if-changed=.git/packed-refs"); + if let Some(ref_path) = current_git_ref_path() { + println!("cargo:rerun-if-changed={ref_path}"); + } // Configure Windows to use the windows subsystem (no console window) if env::var("CARGO_CFG_TARGET_OS").unwrap_or_default() == "windows" { @@ -39,3 +44,9 @@ fn main() { ) .unwrap(); } + +fn current_git_ref_path() -> Option { + let head = fs::read_to_string(".git/HEAD").ok()?; + let ref_name = head.strip_prefix("ref: ")?.trim(); + Some(format!(".git/{ref_name}")) +} From 210824917ee20f83ae9b022dd37bd34bf12de844 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Sun, 5 Jul 2026 14:50:06 +0800 Subject: [PATCH 29/30] Add playlist archive export --- Cargo.lock | 196 ++++++++++++++++++++++ Cargo.toml | 1 + src/app/components/playback_info_panel.rs | 119 +++++++++++++ src/app/core.rs | 39 ++++- src/app/i18n.rs | 46 +++++ src/app/state/ui_state.rs | 12 ++ src/app/style/icons.rs | 1 + src/lib/services/mod.rs | 2 + src/lib/services/playlist_export.rs | 182 ++++++++++++++++++++ 9 files changed, 596 insertions(+), 2 deletions(-) create mode 100644 src/lib/services/playlist_export.rs diff --git a/Cargo.lock b/Cargo.lock index a1d6c14..9467e9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -122,6 +122,17 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.8.11" @@ -483,6 +494,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bindgen" version = "0.69.4" @@ -536,6 +553,7 @@ dependencies = [ "urlencoding", "uuid", "walkdir", + "zip", ] [[package]] @@ -655,6 +673,26 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "cairo-sys-rs" version = "0.15.1" @@ -748,6 +786,16 @@ dependencies = [ "libc", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clang-sys" version = "1.7.0" @@ -814,6 +862,12 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + [[package]] name = "core-foundation" version = "0.9.4" @@ -1000,6 +1054,15 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c297a1c74b71ae29df00c3e22dd9534821d60eb9af5a0192823fa2acea70c2a" +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "digest" version = "0.10.7" @@ -1008,6 +1071,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -1931,6 +1995,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "home" version = "0.5.9" @@ -2026,6 +2099,15 @@ dependencies = [ "serde", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "itertools" version = "0.12.1" @@ -2563,6 +2645,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-derive" version = "0.3.3" @@ -3005,6 +3093,17 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -3030,6 +3129,18 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest", + "hmac", + "password-hash", + "sha2", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -3119,6 +3230,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.17" @@ -3677,6 +3794,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -4141,6 +4269,25 @@ dependencies = [ "weezl", ] +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + [[package]] name = "tiny-skia" version = "0.11.4" @@ -5624,6 +5771,55 @@ version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "aes", + "byteorder", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "flate2", + "hmac", + "pbkdf2", + "sha1", + "time", + "zstd", +] + +[[package]] +name = "zstd" +version = "0.11.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "5.0.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" +dependencies = [ + "libc", + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zune-inflate" version = "0.2.54" diff --git a/Cargo.toml b/Cargo.toml index f56a265..93f634f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ tracing = "0.1.29" tracing-subscriber = "0.3.3" log = { version = "0.4", features = ["release_max_level_info"] } walkdir = "2.5" +zip = "0.6" rubato = "0.12.0" rand = "0.8.5" symphonia = { version = "0.5.4", features = ["mp3", "flac", "wav", "aac", "ogg", "vorbis"] } diff --git a/src/app/components/playback_info_panel.rs b/src/app/components/playback_info_panel.rs index cace66e..5f9cd7e 100644 --- a/src/app/components/playback_info_panel.rs +++ b/src/app/components/playback_info_panel.rs @@ -27,6 +27,7 @@ impl AppComponent for PlaybackInfoPanel { ui.with_layout(egui::Layout::top_down(egui::Align::RIGHT), |ui| { let search_anchor = ui .with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + Self::render_playlist_export_entry(ctx, ui); let search_anchor = Self::render_library_search_controls(ctx, ui); Self::render_download_entry(ctx, ui); Self::render_discover_entry(ctx, ui); @@ -55,6 +56,124 @@ impl PlaybackInfoPanel { .inner } + fn render_playlist_export_entry(ctx: &mut App, ui: &mut egui::Ui) { + let active = ctx.ui_state.show_playlist_export_dialog; + let button = + Self::tool_button(ui, icons::EXPORT, active).on_hover_text(t("export_playlist")); + + if button.clicked() { + ctx.ui_state.show_playlist_export_dialog = true; + if ctx.ui_state.playlist_export_selected_idx.is_none() { + ctx.ui_state.playlist_export_selected_idx = ctx.app_settings.current_playlist_idx; + } + } + + Self::render_playlist_export_dialog(ctx, ui.ctx().clone()); + } + + fn render_playlist_export_dialog(ctx: &mut App, egui_ctx: egui::Context) { + if !ctx.ui_state.show_playlist_export_dialog { + return; + } + + let mut open = ctx.ui_state.show_playlist_export_dialog; + egui::Window::new(t("export_playlist")) + .id(egui::Id::new("playlist_export_dialog")) + .open(&mut open) + .collapsible(false) + .resizable(false) + .show(&egui_ctx, |ui| { + ui.set_min_width(380.0); + ui.label(RichText::new(t("playlist_export_notice")).weak()); + ui.add_space(tokens::spacing::XS); + + let playlist_options = ctx + .playlists + .iter() + .enumerate() + .map(|(idx, playlist)| { + ( + idx, + playlist + .get_name() + .unwrap_or_else(|| t("untitled_playlist")), + ) + }) + .collect::>(); + + let selected_idx = ctx + .ui_state + .playlist_export_selected_idx + .filter(|idx| *idx < ctx.playlists.len()) + .or(ctx.app_settings.current_playlist_idx) + .filter(|idx| *idx < ctx.playlists.len()); + ctx.ui_state.playlist_export_selected_idx = selected_idx; + + let selected_label = selected_idx + .and_then(|idx| { + playlist_options + .iter() + .find(|(option_idx, _)| *option_idx == idx) + }) + .map(|(_, name)| name.clone()) + .unwrap_or_else(|| t("playlist_export_select")); + + egui::ComboBox::from_label(t("playlist_label")) + .selected_text(selected_label) + .show_ui(ui, |ui| { + for (idx, name) in &playlist_options { + ui.selectable_value( + &mut ctx.ui_state.playlist_export_selected_idx, + Some(*idx), + name, + ); + } + }); + + if let Some(idx) = ctx.ui_state.playlist_export_selected_idx { + if let Some(playlist) = ctx.playlists.get(idx) { + ui.label( + RichText::new(format!( + "{} · {}", + playlist.tracks.len(), + t("playlist_export_tracks") + )) + .weak(), + ); + } + } + + if let Some(status) = &ctx.ui_state.playlist_export_status { + ui.add_space(tokens::spacing::XS); + ui.label(RichText::new(status).weak()); + } + + ui.add_space(tokens::spacing::SM); + ui.horizontal(|ui| { + let can_export = ctx + .ui_state + .playlist_export_selected_idx + .and_then(|idx| ctx.playlists.get(idx)) + .is_some_and(|playlist| !playlist.tracks.is_empty()); + + if ui + .add_enabled(can_export, egui::Button::new(t("export"))) + .clicked() + { + if let Some(idx) = ctx.ui_state.playlist_export_selected_idx { + ctx.export_playlist_to_archive(idx); + } + } + + if ui.button(t("clear")).clicked() { + ctx.ui_state.playlist_export_status = None; + } + }); + }); + + ctx.ui_state.show_playlist_export_dialog = open; + } + fn render_download_entry(ctx: &mut App, ui: &mut egui::Ui) { let active = ctx.ui_state.show_youtube_download_dialog || ctx.ui_state.youtube_download_in_progress; diff --git a/src/app/core.rs b/src/app/core.rs index 0a1490f..4b60f02 100644 --- a/src/app/core.rs +++ b/src/app/core.rs @@ -8,8 +8,8 @@ use serde::{Deserialize, Serialize}; use super::error::AppLoadError; use super::i18n; use super::lib_services::{ - LibraryImportService, LyricsManager, PlayerRestoreService, YoutubeDownloadEvent, - YoutubeDownloadService, + LibraryImportService, LyricsManager, PlayerRestoreService, PlaylistExportService, + YoutubeDownloadEvent, YoutubeDownloadService, }; use super::library::{Library, LibraryCommand, LibraryPath}; pub use super::library::{LibraryItem, LibraryPathId}; @@ -333,6 +333,41 @@ impl App { YoutubeDownloadService::search_youtube(query, 20, self.youtube_download_tx().clone()); } + pub fn export_playlist_to_archive(&mut self, playlist_idx: usize) { + let Some(playlist) = self.playlists.get(playlist_idx) else { + self.ui_state.playlist_export_status = Some(i18n::t("playlist_export_select")); + return; + }; + + if playlist.tracks.is_empty() { + self.ui_state.playlist_export_status = Some(i18n::t("playlist_export_empty")); + return; + } + + let Some(output_path) = rfd::FileDialog::new() + .set_file_name(&PlaylistExportService::default_file_name(playlist)) + .add_filter("Bird Playlist", &["zip"]) + .save_file() + else { + return; + }; + + match PlaylistExportService::export_playlist(playlist, &output_path) { + Ok(result) => { + self.ui_state.playlist_export_status = Some(i18n::tf( + "playlist_export_done", + &[ + &result.track_count.to_string(), + &result.output_path.display().to_string(), + ], + )); + } + Err(err) => { + self.ui_state.playlist_export_status = Some(err); + } + } + } + pub fn handle_youtube_download_event(&mut self, event: YoutubeDownloadEvent) { match event { YoutubeDownloadEvent::Progress(progress) => { diff --git a/src/app/i18n.rs b/src/app/i18n.rs index 12904cf..dbbfcd6 100644 --- a/src/app/i18n.rs +++ b/src/app/i18n.rs @@ -202,6 +202,29 @@ pub fn init() { "youtube_discover_results".to_string(), "{} result(s) found".to_string(), ); + en.insert("export_playlist".to_string(), "Export playlist".to_string()); + en.insert( + "playlist_export_notice".to_string(), + "Create a portable archive with this playlist's music files and metadata.".to_string(), + ); + en.insert( + "playlist_export_select".to_string(), + "Choose a playlist".to_string(), + ); + en.insert( + "playlist_export_empty".to_string(), + "This playlist has no tracks to export.".to_string(), + ); + en.insert("playlist_export_tracks".to_string(), "tracks".to_string()); + en.insert( + "playlist_export_done".to_string(), + "Exported {} track(s) to {}".to_string(), + ); + en.insert("export".to_string(), "Export".to_string()); + en.insert( + "untitled_playlist".to_string(), + "Untitled Playlist".to_string(), + ); // Playlist tabs component en.insert("rename".to_string(), "Rename".to_string()); @@ -377,6 +400,29 @@ pub fn init() { "youtube_discover_results".to_string(), "找到 {} 个结果".to_string(), ); + zh.insert("export_playlist".to_string(), "导出播放列表".to_string()); + zh.insert( + "playlist_export_notice".to_string(), + "把播放列表的音乐文件和元信息打包成可导入的压缩包。".to_string(), + ); + zh.insert( + "playlist_export_select".to_string(), + "选择播放列表".to_string(), + ); + zh.insert( + "playlist_export_empty".to_string(), + "这个播放列表没有可导出的歌曲。".to_string(), + ); + zh.insert("playlist_export_tracks".to_string(), "首歌曲".to_string()); + zh.insert( + "playlist_export_done".to_string(), + "已导出 {} 首歌曲到 {}".to_string(), + ); + zh.insert("export".to_string(), "导出".to_string()); + zh.insert( + "untitled_playlist".to_string(), + "未命名播放列表".to_string(), + ); // Playlist tabs component zh.insert("rename".to_string(), "重命名".to_string()); diff --git a/src/app/state/ui_state.rs b/src/app/state/ui_state.rs index ef5cd37..a212c7e 100644 --- a/src/app/state/ui_state.rs +++ b/src/app/state/ui_state.rs @@ -88,6 +88,15 @@ pub struct UiState { /// Last user-facing discovery status or error message. pub youtube_discover_status: Option, + /// Whether the playlist export window is open. + pub show_playlist_export_dialog: bool, + + /// Playlist selected in the export window. + pub playlist_export_selected_idx: Option, + + /// Last user-facing playlist export status or error message. + pub playlist_export_status: Option, + /// Volume value to restore when the user un-mutes via the speaker icon. /// `None` while not muted. Not serialized: a fresh launch always starts /// with whatever volume the player itself remembers. @@ -137,6 +146,9 @@ impl Default for UiState { youtube_discover_in_progress: false, youtube_discover_results: Vec::new(), youtube_discover_status: None, + show_playlist_export_dialog: false, + playlist_export_selected_idx: None, + playlist_export_status: None, volume_before_mute: None, last_persistence_save: Instant::now(), desktop_lyrics_font_size: 48.0, diff --git a/src/app/style/icons.rs b/src/app/style/icons.rs index 87392f7..6e7ed1b 100644 --- a/src/app/style/icons.rs +++ b/src/app/style/icons.rs @@ -30,6 +30,7 @@ pub const SEARCH: &str = p::MAGNIFYING_GLASS; pub const CLOSE: &str = p::X; pub const DOWNLOAD: &str = p::DOWNLOAD_SIMPLE; pub const DISCOVER: &str = p::COMPASS; +pub const EXPORT: &str = p::EXPORT; pub const YOUTUBE: &str = p::YOUTUBE_LOGO; pub const FOLDER: &str = p::FOLDER_OPEN; /// Generic "add / new" affordance — used by library/playlist "+" buttons. diff --git a/src/lib/services/mod.rs b/src/lib/services/mod.rs index 53c6504..c529237 100644 --- a/src/lib/services/mod.rs +++ b/src/lib/services/mod.rs @@ -6,6 +6,7 @@ pub mod library_import; pub mod lyrics_manager; pub mod metadata_editor; pub mod player_restore; +pub mod playlist_export; pub mod youtube_download; // Re-export commonly used types @@ -13,6 +14,7 @@ pub use library_import::LibraryImportService; pub use lyrics_manager::LyricsManager; pub use metadata_editor::MetadataEditor; pub use player_restore::PlayerRestoreService; +pub use playlist_export::{PlaylistExportResult, PlaylistExportService}; pub use youtube_download::{ YoutubeDownloadEvent, YoutubeDownloadResult, YoutubeDownloadService, YoutubeSearchResult, }; diff --git a/src/lib/services/playlist_export.rs b/src/lib/services/playlist_export.rs new file mode 100644 index 0000000..d3a300b --- /dev/null +++ b/src/lib/services/playlist_export.rs @@ -0,0 +1,182 @@ +use crate::playlist::Playlist; +use serde::Serialize; +use std::fs::File; +use std::io::{BufWriter, Write}; +use std::path::{Path, PathBuf}; +use zip::write::FileOptions; +use zip::CompressionMethod; +use zip::ZipWriter; + +#[derive(Debug, Clone)] +pub struct PlaylistExportResult { + pub output_path: PathBuf, + pub track_count: usize, +} + +#[derive(Debug, Serialize)] +struct PlaylistExportManifest { + format: String, + format_version: u32, + name: Option, + description: Option, + avatar: Option, + created_at: i64, + updated_at: i64, + tracks: Vec, +} + +#[derive(Debug, Serialize)] +struct PlaylistExportTrack { + position: usize, + file: String, + original_path: String, + key: String, + file_hash: String, + title: Option, + artist: Option, + album: Option, + year: Option, + genre: Option, + track_number: Option, + lyrics: Option, +} + +pub struct PlaylistExportService; + +impl PlaylistExportService { + pub fn default_file_name(playlist: &Playlist) -> String { + format!( + "{}.birdplaylist.zip", + Self::safe_file_stem( + playlist + .get_name() + .as_deref() + .unwrap_or("Untitled Playlist") + ) + ) + } + + pub fn export_playlist( + playlist: &Playlist, + output_path: &Path, + ) -> Result { + if playlist.tracks.is_empty() { + return Err("Playlist has no tracks to export.".to_string()); + } + + let file = File::create(output_path) + .map_err(|err| format!("Failed to create export file: {}", err))?; + let writer = BufWriter::new(file); + let mut zip = ZipWriter::new(writer); + let options = FileOptions::default() + .compression_method(CompressionMethod::Deflated) + .unix_permissions(0o644); + + let mut manifest_tracks = Vec::new(); + + for (idx, track) in playlist.tracks.iter().enumerate() { + let source_path = track.path(); + if !source_path.is_file() { + return Err(format!( + "Track file was not found: {}", + source_path.display() + )); + } + + let archive_path = format!( + "tracks/{:03}-{}", + idx + 1, + Self::safe_file_name(&source_path, idx + 1) + ); + + zip.start_file(&archive_path, options) + .map_err(|err| format!("Failed to add track to export: {}", err))?; + let mut source = File::open(&source_path) + .map_err(|err| format!("Failed to read {}: {}", source_path.display(), err))?; + std::io::copy(&mut source, &mut zip) + .map_err(|err| format!("Failed to copy {}: {}", source_path.display(), err))?; + + manifest_tracks.push(PlaylistExportTrack { + position: idx + 1, + file: archive_path, + original_path: source_path.to_string_lossy().to_string(), + key: track.key(), + file_hash: track.file_hash().to_string(), + title: track.title(), + artist: track.artist(), + album: track.album(), + year: track.year(), + genre: track.genre(), + track_number: track.track_number(), + lyrics: track.lyrics(), + }); + } + + let manifest = PlaylistExportManifest { + format: "bird-player-playlist".to_string(), + format_version: 1, + name: playlist.get_name(), + description: playlist.description(), + avatar: None, + created_at: playlist.created_at(), + updated_at: playlist.updated_at(), + tracks: manifest_tracks, + }; + + zip.start_file("playlist.json", options) + .map_err(|err| format!("Failed to add playlist metadata: {}", err))?; + let manifest_json = serde_json::to_vec_pretty(&manifest) + .map_err(|err| format!("Failed to serialize playlist metadata: {}", err))?; + zip.write_all(&manifest_json) + .map_err(|err| format!("Failed to write playlist metadata: {}", err))?; + zip.finish() + .map_err(|err| format!("Failed to finish export zip: {}", err))?; + + Ok(PlaylistExportResult { + output_path: output_path.to_path_buf(), + track_count: playlist.tracks.len(), + }) + } + + fn safe_file_stem(name: &str) -> String { + let sanitized = name + .chars() + .map(|ch| match ch { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-', + ch if ch.is_control() => '-', + ch => ch, + }) + .collect::(); + let trimmed = sanitized.trim().trim_matches('.').to_string(); + if trimmed.is_empty() { + "Untitled Playlist".to_string() + } else { + trimmed + } + } + + fn safe_file_name(source_path: &Path, fallback_position: usize) -> String { + source_path + .file_name() + .and_then(|name| name.to_str()) + .map(Self::safe_file_stem) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| format!("track-{}", fallback_position)) + } +} + +#[cfg(test)] +mod tests { + use super::PlaylistExportService; + + #[test] + fn default_file_name_sanitizes_playlist_name() { + let mut playlist = crate::playlist::Playlist::new(); + playlist.set_name("a/b:c*?".to_string()); + + assert_eq!( + PlaylistExportService::default_file_name(&playlist), + "a-b-c--.birdplaylist.zip" + ); + } +} From 0af4242a7810f57e6771fd0226a3e6845248e541 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Mon, 6 Jul 2026 12:58:07 +0800 Subject: [PATCH 30/30] Address PR review feedback --- build.rs | 12 +++- src/app/components/footer.rs | 21 +++--- src/app/components/library_component.rs | 38 ++++++----- src/app/components/playback_info_panel.rs | 27 ++++---- src/app/components/player_component.rs | 6 +- src/app/components/playlist_table/view.rs | 4 ++ src/lib/library.rs | 6 +- src/lib/services/playlist_export.rs | 21 +++++- src/lib/services/youtube_download.rs | 83 +++++++++++++---------- 9 files changed, 131 insertions(+), 87 deletions(-) diff --git a/build.rs b/build.rs index 7fa891c..40278eb 100644 --- a/build.rs +++ b/build.rs @@ -6,10 +6,10 @@ use std::process::Command; fn main() { // Make cargo track changes to Cargo.toml and the current git ref. println!("cargo:rerun-if-changed=Cargo.toml"); - println!("cargo:rerun-if-changed=.git/HEAD"); - println!("cargo:rerun-if-changed=.git/packed-refs"); + print_rerun_if_exists(".git/HEAD"); + print_rerun_if_exists(".git/packed-refs"); if let Some(ref_path) = current_git_ref_path() { - println!("cargo:rerun-if-changed={ref_path}"); + print_rerun_if_exists(&ref_path); } // Configure Windows to use the windows subsystem (no console window) @@ -45,6 +45,12 @@ fn main() { .unwrap(); } +fn print_rerun_if_exists(path: &str) { + if Path::new(path).exists() { + println!("cargo:rerun-if-changed={path}"); + } +} + fn current_git_ref_path() -> Option { let head = fs::read_to_string(".git/HEAD").ok()?; let ref_name = head.strip_prefix("ref: ")?.trim(); diff --git a/src/app/components/footer.rs b/src/app/components/footer.rs index c3ef046..76e1c9c 100644 --- a/src/app/components/footer.rs +++ b/src/app/components/footer.rs @@ -22,15 +22,18 @@ impl AppComponent for Footer { ui.add_space(tokens::spacing::SM); if let Some(current_playlist_idx) = ctx.app_settings.current_playlist_idx { - let selection_count = ctx.playlists[current_playlist_idx].selected_indices.len(); - if selection_count > 0 { - let selection_response = - ui.label(RichText::new(format!("{} selected", selection_count)).weak()); - Self::start_drag_from_response(ctx, ui, &selection_response); - - if ui.button("Clear Selection").clicked() { - let playlist = &mut ctx.playlists[current_playlist_idx]; - playlist.clear_selection(); + if let Some(playlist) = ctx.playlists.get(current_playlist_idx) { + let selection_count = playlist.selected_indices.len(); + if selection_count > 0 { + let selection_response = + ui.label(RichText::new(format!("{} selected", selection_count)).weak()); + Self::start_drag_from_response(ctx, ui, &selection_response); + + if ui.button("Clear Selection").clicked() { + if let Some(playlist) = ctx.playlists.get_mut(current_playlist_idx) { + playlist.clear_selection(); + } + } } } } diff --git a/src/app/components/library_component.rs b/src/app/components/library_component.rs index da4c368..ee3ae47 100644 --- a/src/app/components/library_component.rs +++ b/src/app/components/library_component.rs @@ -2,7 +2,7 @@ use super::AppComponent; use crate::app::t; use crate::app::{App, LibraryItem, LibraryPathId}; use eframe::egui::{CollapsingHeader, Label, RichText, Sense, TextWrapMode}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; pub struct LibraryComponent; @@ -181,11 +181,11 @@ impl AppComponent for LibraryComponent { // Handle click to add to current playlist if item_label.clicked() { - if let Some(current_playlist_idx) = - &ctx.app_settings.current_playlist_idx + if let Some(current_playlist) = ctx + .app_settings + .current_playlist_idx + .and_then(|idx| ctx.playlists.get_mut(idx)) { - let current_playlist = - &mut ctx.playlists[*current_playlist_idx]; if !current_playlist.tracks.contains(item) { current_playlist.add((*item).clone()); } @@ -195,16 +195,16 @@ impl AppComponent for LibraryComponent { // Add context menu for individual tracks item_label.context_menu(|ui| { if ui.button(t("add_to_playlist")).clicked() { - if let Some(current_playlist_idx) = - &ctx.app_settings.current_playlist_idx + if let Some(current_playlist) = ctx + .app_settings + .current_playlist_idx + .and_then(|idx| ctx.playlists.get_mut(idx)) { - let current_playlist = - &mut ctx.playlists[*current_playlist_idx]; if !current_playlist.tracks.contains(item) { current_playlist.add((*item).clone()); } - ui.close_menu(); } + ui.close_menu(); } }); } @@ -215,22 +215,26 @@ impl AppComponent for LibraryComponent { section.header_response.context_menu(|ui| { // Add context menu for the folder header if ui.button(t("add_all_to_playlist")).clicked() { - if let Some(current_playlist_idx) = - &ctx.app_settings.current_playlist_idx + if let Some(current_playlist) = ctx + .app_settings + .current_playlist_idx + .and_then(|idx| ctx.playlists.get_mut(idx)) { - let current_playlist = - &mut ctx.playlists[*current_playlist_idx]; - // Add all tracks from this folder to the playlist if let Some(items) = folder_items.get(&path_id) { + let mut existing_keys = current_playlist + .tracks + .iter() + .map(|track| track.key()) + .collect::>(); for item in items { - if !current_playlist.tracks.contains(item) { + if existing_keys.insert(item.key()) { current_playlist.add((*item).clone()); } } } - ui.close_menu(); } + ui.close_menu(); } if ui.button(t("resync_folder")).clicked() { diff --git a/src/app/components/playback_info_panel.rs b/src/app/components/playback_info_panel.rs index 5f9cd7e..c1c9f35 100644 --- a/src/app/components/playback_info_panel.rs +++ b/src/app/components/playback_info_panel.rs @@ -736,15 +736,18 @@ impl PlaybackInfoPanel { .items() .iter() .filter_map(|item| { - let title = item.title().unwrap_or_default(); - let artist = item.artist().unwrap_or_default(); - let album = item.album().unwrap_or_default(); - let genre = item.genre().unwrap_or_default(); - let path = item.path().to_string_lossy().to_string(); - let haystack = - format!("{} {} {} {} {}", title, artist, album, genre, path).to_lowercase(); - - if !haystack.contains(&query) { + let title = item.title_ref().unwrap_or_default(); + let artist = item.artist_ref().unwrap_or_default(); + let album = item.album_ref().unwrap_or_default(); + let genre = item.genre_ref().unwrap_or_default(); + let path = item.path_ref().to_string_lossy(); + + let matches = [title, artist, album, genre] + .iter() + .any(|field| field.to_lowercase().contains(&query)) + || path.to_lowercase().contains(&query); + + if !matches { return None; } @@ -758,9 +761,9 @@ impl PlaybackInfoPanel { Some(LibrarySearchResult { track: item.clone(), - title, - artist, - album, + title: title.to_string(), + artist: artist.to_string(), + album: album.to_string(), source, }) }) diff --git a/src/app/components/player_component.rs b/src/app/components/player_component.rs index fe6dd68..bf58b99 100644 --- a/src/app/components/player_component.rs +++ b/src/app/components/player_component.rs @@ -90,9 +90,9 @@ impl AppComponent for PlayerComponent { // Get playlist tracks info for the current playlist let current_playlist_idx = ctx.app_settings.current_playlist_idx; - // Use is_some_and instead of map_or - let has_tracks_in_playlist = - current_playlist_idx.is_some_and(|idx| !ctx.playlists[idx].tracks.is_empty()); + let has_tracks_in_playlist = current_playlist_idx + .and_then(|idx| ctx.playlists.get(idx)) + .is_some_and(|playlist| !playlist.tracks.is_empty()); // Now render UI without borrowing ctx in closures that also borrow ctx ui.vertical(|ui| { diff --git a/src/app/components/playlist_table/view.rs b/src/app/components/playlist_table/view.rs index 8922936..9e87cf3 100644 --- a/src/app/components/playlist_table/view.rs +++ b/src/app/components/playlist_table/view.rs @@ -20,6 +20,10 @@ pub(super) fn render(ctx: &mut App, ui: &mut egui::Ui) { let Some(current_playlist_idx) = ctx.app_settings.current_playlist_idx else { return; }; + if current_playlist_idx >= ctx.playlists.len() { + ctx.app_settings.current_playlist_idx = None; + return; + } let base_id = ui.id().with(("playlist", current_playlist_idx)); let mut state = PlaylistTableState::load(ui, base_id); diff --git a/src/lib/library.rs b/src/lib/library.rs index 8c35159..85aa2b6 100644 --- a/src/lib/library.rs +++ b/src/lib/library.rs @@ -364,12 +364,10 @@ impl Library { })?; let mut items = Vec::new(); + let mut seen_paths = HashSet::new(); for item_result in item_rows { let item = item_result?; - if !items - .iter() - .any(|existing: &LibraryItem| existing.path_ref() == item.path_ref()) - { + if seen_paths.insert(item.path()) { items.push(item); } } diff --git a/src/lib/services/playlist_export.rs b/src/lib/services/playlist_export.rs index d3a300b..0769b0c 100644 --- a/src/lib/services/playlist_export.rs +++ b/src/lib/services/playlist_export.rs @@ -1,6 +1,6 @@ use crate::playlist::Playlist; use serde::Serialize; -use std::fs::File; +use std::fs::{self, File}; use std::io::{BufWriter, Write}; use std::path::{Path, PathBuf}; use zip::write::FileOptions; @@ -59,6 +59,19 @@ impl PlaylistExportService { pub fn export_playlist( playlist: &Playlist, output_path: &Path, + ) -> Result { + match Self::write_playlist_archive(playlist, output_path) { + Ok(result) => Ok(result), + Err(err) => { + let _ = fs::remove_file(output_path); + Err(err) + } + } + } + + fn write_playlist_archive( + playlist: &Playlist, + output_path: &Path, ) -> Result { if playlist.tracks.is_empty() { return Err("Playlist has no tracks to export.".to_string()); @@ -129,8 +142,12 @@ impl PlaylistExportService { .map_err(|err| format!("Failed to serialize playlist metadata: {}", err))?; zip.write_all(&manifest_json) .map_err(|err| format!("Failed to write playlist metadata: {}", err))?; - zip.finish() + let mut buffered_writer = zip + .finish() .map_err(|err| format!("Failed to finish export zip: {}", err))?; + buffered_writer + .flush() + .map_err(|err| format!("Failed to flush export file: {}", err))?; Ok(PlaylistExportResult { output_path: output_path.to_path_buf(), diff --git a/src/lib/services/youtube_download.rs b/src/lib/services/youtube_download.rs index e6b48c1..ff9f33e 100644 --- a/src/lib/services/youtube_download.rs +++ b/src/lib/services/youtube_download.rs @@ -188,46 +188,43 @@ impl YoutubeDownloadService { } } - match child + if let Some(status) = child .try_wait() .map_err(|err| format!("Failed to wait for yt-dlp: {}", err))? { - Some(status) => { - if let Some(handle) = stdout_handle { - let _ = handle.join(); - } - if let Some(handle) = stderr_handle { - let _ = handle.join(); - } - for line in line_rx.try_iter() { - Self::handle_download_output_line( - &line, - &mut downloaded_files, - &mut output_lines, - event_tx, - ); - } + if let Some(handle) = stdout_handle { + let _ = handle.join(); + } + if let Some(handle) = stderr_handle { + let _ = handle.join(); + } + for line in line_rx.try_iter() { + Self::handle_download_output_line( + &line, + &mut downloaded_files, + &mut output_lines, + event_tx, + ); + } - if !status.success() { - let details = output_lines - .iter() - .rev() - .take(8) - .cloned() - .collect::>() - .into_iter() - .rev() - .collect::>() - .join("\n"); - return Err(if details.trim().is_empty() { - format!("yt-dlp failed with status {}", status) - } else { - details - }); - } - break; + if !status.success() { + let details = output_lines + .iter() + .rev() + .take(8) + .cloned() + .collect::>() + .into_iter() + .rev() + .collect::>() + .join("\n"); + return Err(if details.trim().is_empty() { + format!("yt-dlp failed with status {}", status) + } else { + details + }); } - None => {} + break; } } @@ -283,9 +280,21 @@ impl YoutubeDownloadService { std::thread::spawn(move || { use std::io::BufRead; - let reader = std::io::BufReader::new(stream); - for line in reader.lines().map_while(Result::ok) { + let mut reader = std::io::BufReader::new(stream); + let mut buf = Vec::new(); + while let Ok(n) = reader.read_until(b'\n', &mut buf) { + if n == 0 { + break; + } + if buf.ends_with(b"\n") { + buf.pop(); + if buf.ends_with(b"\r") { + buf.pop(); + } + } + let line = String::from_utf8_lossy(&buf).into_owned(); let _ = line_tx.send(line); + buf.clear(); } }) }