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/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/build.rs b/build.rs index c9f12f6..40278eb 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"); + print_rerun_if_exists(".git/HEAD"); + print_rerun_if_exists(".git/packed-refs"); + if let Some(ref_path) = current_git_ref_path() { + print_rerun_if_exists(&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,15 @@ 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(); + Some(format!(".git/{ref_name}")) +} 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/footer.rs b/src/app/components/footer.rs index 7438793..76e1c9c 100644 --- a/src/app/components/footer.rs +++ b/src/app/components/footer.rs @@ -1,6 +1,8 @@ use super::AppComponent; -use crate::app::style::icons; +use crate::app::style::tokens; +use crate::app::version::version_info; use crate::app::App; +use eframe::egui::{self, RichText, Sense}; pub struct Footer; @@ -8,284 +10,49 @@ impl AppComponent for Footer { type Context = App; 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( - eframe::egui::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.button(icons::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.button(icons::CLOSE).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 - eframe::egui::Frame::popup(ui.style()) - .stroke(eframe::egui::Stroke::new( - 1.0, - ui.style().visuals.widgets.active.bg_fill, - )) - .show(ui, |ui| { - ui.set_max_width(400.0); - ui.set_max_height(200.0); - - eframe::egui::ScrollArea::vertical().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), - result_text, - ); + 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(10.0).weak()) + .sense(Sense::click_and_drag()), + ); + Self::start_drag_from_response(ctx, ui, &version_response); - // 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, - ); - }); + ui.add_space(tokens::spacing::SM); - // 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.fetch_lyrics_for_current_track(); - } - }); - }); - } + if let Some(current_playlist_idx) = ctx.app_settings.current_playlist_idx { + 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(); } } - - // 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.button(format!("{} Search", icons::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(); } } } + + 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); + } + } +} diff --git a/src/app/components/library_component.rs b/src/app/components/library_component.rs index 39e370e..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; @@ -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 @@ -111,132 +112,159 @@ 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(); + 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); - } + // 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(); - // 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()); + // 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, + ); } - } - // 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 + // Handle click to add to current playlist + if item_label.clicked() { + 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(); } } - }); + + // Add context menu for individual tracks + item_label.context_menu(|ui| { + if ui.button(t("add_to_playlist")).clicked() { + if let Some(current_playlist) = ctx + .app_settings + .current_playlist_idx + .and_then(|idx| ctx.playlists.get_mut(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()); + // 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) = ctx + .app_settings + .current_playlist_idx + .and_then(|idx| ctx.playlists.get_mut(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 existing_keys.insert(item.key()) { + current_playlist.add((*item).clone()); + } } } } 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("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); + ui.close_menu(); + } + }); + } } + }); + + 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 { diff --git a/src/app/components/playback_info_panel.rs b/src/app/components/playback_info_panel.rs index 7c81c30..c1c9f35 100644 --- a/src/app/components/playback_info_panel.rs +++ b/src/app/components/playback_info_panel.rs @@ -1,10 +1,22 @@ 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; use crate::app::App; -use eframe::egui::{self, Align, Layout, RichText, TextEdit}; +use eframe::egui::{self, Button, Frame, Id, Margin, Order, RichText, Sense, Stroke, TextEdit}; -const DESCRIPTION_PREVIEW_LENGTH: usize = 30; +const SEARCH_PANEL_WIDTH: f32 = 280.0; +const SEARCH_PANEL_HEIGHT: f32 = 220.0; + +#[derive(Clone)] +struct LibrarySearchResult { + track: LibraryItem, + title: String, + artist: String, + album: String, + source: String, +} pub struct PlaybackInfoPanel; @@ -12,27 +24,160 @@ impl AppComponent for PlaybackInfoPanel { type Context = App; fn add(ctx: &mut Self::Context, ui: &mut eframe::egui::Ui) { - Self::render_download_entry(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::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); + search_anchor + }) + .inner; - // 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); - } + if let Some(anchor) = search_anchor { + Self::render_library_search_results(ctx, ui, anchor); + } + }); } } impl PlaybackInfoPanel { + fn search_id(key: &'static str) -> Id { + 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_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; - let button = ui - .add(player_button(icons::DOWNLOAD, active)) + let button = Self::tool_button(ui, icons::DOWNLOAD, active) .on_hover_text(t("download_authorized_audio")); if button.clicked() { @@ -42,6 +187,168 @@ 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(560.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(540.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(360.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| { + Self::render_discover_thumbnail(ui, result); + + ui.vertical(|ui| { + ui.set_width(392.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 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)), + 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; @@ -65,6 +372,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| { @@ -86,6 +402,7 @@ impl PlaybackInfoPanel { .pick_folder() { ctx.ui_state.youtube_download_dir = folder; + ctx.save_state(); } } }); @@ -95,6 +412,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 +447,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; } }); @@ -122,124 +458,327 @@ 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(); + fn render_library_search_controls(ctx: &mut App, ui: &mut egui::Ui) -> Option { + 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; - // Playlist name - if let Some(name) = playlist.get_name() { - ui.label( - RichText::new(&name) - .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(); - // 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 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 + .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 mut text_response: Option = None; + 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(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| { + 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( + [text_width, tokens::size::ICON_BTN - 6.0], + TextEdit::singleline(&mut search_text) + .id(editor_id) + .frame(false) + .hint_text(t("type_to_search")), + ), ); - } - } - // 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), - ); - } + close_clicked = ui + .add( + Button::new(icons::CLOSE) + .frame(false) + .min_size(close_button_size), + ) + .on_hover_text(t("close_search")) + .clicked(); + }); + }) + .response; + + 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() + || (response.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))); + + if close_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 Self::tool_button(ui, icons::SEARCH, false) + .on_hover_text(t("library_search")) + .clicked() + { + search_active = true; + ui.memory_mut(|mem| { + 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); }); } + + 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()); + }); + } + + ui.memory_mut(|mem| mem.data.insert_temp(search_active_id, search_active)); + + anchor_rect } - fn render_track_info(ctx: &App, ui: &mut egui::Ui) { - let player = ctx.player_ref(); + fn render_library_search_results(ctx: &mut App, ui: &mut egui::Ui, anchor: egui::Rect) { + 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"); - if let Some(track) = &player.selected_track { - ui.with_layout(Layout::top_down(Align::RIGHT), |ui| { - let weak_color = ui.visuals().weak_text_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; + let no_results = ui + .memory_mut(|mem| mem.data.get_temp::(no_results_id)) + .unwrap_or(false); - // 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), - ); - } + if show_results || no_results { + let screen_rect = ui.ctx().screen_rect(); + 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() - panel_width - tokens::spacing::SM); + let y = (anchor.bottom() + tokens::spacing::XS) + .min(screen_rect.bottom() - SEARCH_PANEL_HEIGHT - tokens::spacing::SM); - // Genre - if let Some(genre) = track.genre() { - ui.label( - RichText::new(genre) - .size(tokens::text::SM) - .color(weak_color), - ); - } + egui::Area::new(Self::search_id("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(panel_width); + ui.set_max_height(SEARCH_PANEL_HEIGHT); - // 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 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, + selected_key_id, + &mut track_to_play, + ); + }); + }); + } + + 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 render_library_search_result_list( + ui: &mut egui::Ui, + search_results_id: egui::Id, + selected_key_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 row_text = if detail.is_empty() { + title + } else { + format!("{} {}", title, detail) + }; + + 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().clone(); + 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() + }; + 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() { + ui.memory_mut(|mem| { + mem.data.insert_temp(selected_key_id, track_key); + }); + *track_to_play = Some(result.track); } - }; - if !channel_str.is_empty() { - tech_line.push_str(channel_str); } + }); + } + } + + fn search_library(library: &Library, query: &str) -> Vec { + let query = query.to_lowercase(); + library + .items() + .iter() + .filter_map(|item| { + 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; } - if !tech_line.is_empty() { - ui.label( - RichText::new(tech_line) - .size(tokens::text::SM) - .color(weak_color), - ); - } - }); + + 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: title.to_string(), + artist: artist.to_string(), + album: album.to_string(), + source, + }) + }) + .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::()) } } diff --git a/src/app/components/player_component.rs b/src/app/components/player_component.rs index eedb61e..bf58b99 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,19 +84,21 @@ 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(); // 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| { // ── Top row: cover + track info + playback info ──────────────── - ui.horizontal(|ui| { + 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) @@ -103,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 { @@ -172,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); @@ -236,6 +239,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 +283,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,13 +312,20 @@ 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; } } 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..568f884 100644 --- a/src/app/components/playlist_table/services.rs +++ b/src/app/components/playlist_table/services.rs @@ -80,13 +80,13 @@ 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) { 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..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); @@ -76,6 +80,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/app/components/playlist_tabs.rs b/src/app/components/playlist_tabs.rs index a38ed7e..be2ec2c 100644 --- a/src/app/components/playlist_tabs.rs +++ b/src/app/components/playlist_tabs.rs @@ -12,7 +12,22 @@ 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]) + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) + .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..200cc6b 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,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()); + Self::render_brand_mark(ui); + ui.separator(); + // Menu list ui.menu_button(t("file"), |ui| { if ui.button(t("open")).clicked() { @@ -138,7 +142,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 +153,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(); @@ -329,3 +341,55 @@ impl AppComponent for WindowChrome { } } } + +impl WindowChrome { + fn render_brand_mark(ui: &mut egui::Ui) { + 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( + egui::lerp(rect.left()..=rect.right(), x), + egui::lerp(rect.top()..=rect.bottom(), y), + ) + }; + + 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.34, 0.44), + to_pos(0.48, 0.08), + to_pos(0.78, 0.18), + to_pos(0.96, 0.34), + ], + false, + Color32::TRANSPARENT, + stroke, + ), + )); + + painter.line( + 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.39, 0.68), + to_pos(0.53, 0.72), + to_pos(0.57, 0.82), + to_pos(0.57, 0.98), + ], + false, + Color32::TRANSPARENT, + 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); + } +} diff --git a/src/app/core.rs b/src/app/core.rs index 8285afe..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}; @@ -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,88 @@ 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; + 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, 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) => { + 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,8 +404,26 @@ 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); } + 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); + } } } @@ -494,12 +579,12 @@ 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); } - self.fetch_lyrics_for_current_track(); + self.auto_fetch_lyrics_for_current_track(); } AudioEvent::PlaybackStateChanged(is_playing) => { tracing::info!( @@ -528,6 +613,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 +650,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..dbbfcd6 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()); @@ -83,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()); @@ -106,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(), @@ -119,6 +134,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 +151,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 +168,63 @@ 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(), + ); + 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(), + ); + 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()); @@ -185,6 +274,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()); @@ -211,6 +304,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()); @@ -228,6 +325,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(), @@ -238,6 +336,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 +352,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 +369,60 @@ pub fn init() { "downloaded_files_resync".to_string(), "已下载 {} 个文件,正在重新同步文件夹...".to_string(), ); + zh.insert( + "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(), + ); + 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 b295a0b..a212c7e 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; @@ -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,9 +58,45 @@ 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, + /// 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, + + /// 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. @@ -87,6 +126,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,7 +136,19 @@ 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, + show_youtube_discover_dialog: false, + youtube_discover_query: String::new(), + 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, @@ -112,6 +164,10 @@ 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, #[serde(default = "default_desktop_lyrics_font_size")] pub desktop_lyrics_font_size: f32, @@ -125,6 +181,14 @@ 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 +} + fn default_desktop_lyrics_color() -> [u8; 4] { [0, 255, 255, 255] } @@ -135,6 +199,8 @@ 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(), desktop_lyrics_color: default_desktop_lyrics_color(), @@ -150,6 +216,8 @@ 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, desktop_lyrics_color: self.desktop_lyrics_color, @@ -162,6 +230,8 @@ 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; self.desktop_lyrics_color = settings.desktop_lyrics_color; diff --git a/src/app/style/icons.rs b/src/app/style/icons.rs index 1e1cf19..6e7ed1b 100644 --- a/src/app/style/icons.rs +++ b/src/app/style/icons.rs @@ -24,9 +24,14 @@ 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; +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. pub const PLUS: &str = p::PLUS; 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; } 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/library.rs b/src/lib/library.rs index cb0c70c..85aa2b6 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() { @@ -304,8 +364,12 @@ impl Library { })?; let mut items = Vec::new(); + let mut seen_paths = HashSet::new(); for item_result in item_rows { - items.push(item_result?); + let item = item_result?; + if seen_paths.insert(item.path()) { + items.push(item); + } } // Load all pictures at once to prevent N+1 queries diff --git a/src/lib/services/mod.rs b/src/lib/services/mod.rs index 33cbbb8..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,4 +14,7 @@ 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 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..0769b0c --- /dev/null +++ b/src/lib/services/playlist_export.rs @@ -0,0 +1,199 @@ +use crate::playlist::Playlist; +use serde::Serialize; +use std::fs::{self, 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 { + 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()); + } + + 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))?; + 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(), + 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" + ); + } +} diff --git a/src/lib/services/youtube_download.rs b/src/lib/services/youtube_download.rs index a134a48..ff9f33e 100644 --- a/src/lib/services/youtube_download.rs +++ b/src/lib/services/youtube_download.rs @@ -1,10 +1,14 @@ +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), + SearchFinished(Result, String>), } #[derive(Debug, Clone)] @@ -13,8 +17,19 @@ 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 thumbnail_url: Option, +} + 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 +43,75 @@ 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 { + 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, + 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 +121,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 +141,385 @@ 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, + ); + } + } + + if let Some(status) = child + .try_wait() + .map_err(|err| format!("Failed to wait for yt-dlp: {}", err))? + { + 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; + } + } 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 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(); + } + }) + } + + 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 + } + + 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()), + 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)] +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"))); + } + + #[test] + fn parse_search_results_reads_yt_dlp_json_lines() { + 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}"#; + + 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[0].thumbnail_url.as_deref(), + Some("https://img.example/large.jpg") + ); + assert_eq!(results[1].url, "https://www.youtube.com/watch?v=def456"); + } }