diff --git a/src/app/components/cassette_component.rs b/src/app/components/cassette_component.rs index 419c098..7fffe73 100644 --- a/src/app/components/cassette_component.rs +++ b/src/app/components/cassette_component.rs @@ -1,5 +1,6 @@ use super::AppComponent; use crate::app::style::tokens; +use crate::app::t; use crate::app::App; use ::image::io::Reader as ImageReader; use eframe::egui::epaint::*; @@ -31,7 +32,7 @@ impl AppComponent for CassetteComponent { // the parent panel, not to the row's content height — which used // to push the cover above the visible band entirely. let side = ALBUM_ART_SIZE.max(64.0); - let (rect, _resp) = ui.allocate_exact_size(vec2(side, side), Sense::hover()); + let (rect, resp) = ui.allocate_exact_size(vec2(side, side), Sense::click()); let mut show_default = true; @@ -114,6 +115,20 @@ impl AppComponent for CassetteComponent { if show_default { show_default_album_art(ctx, ui, rect); } + + if ctx.player_ref().selected_track.is_some() { + let resp = resp.on_hover_text(t("change_cover")); + if resp.clicked() { + if let Some(image_path) = rfd::FileDialog::new() + .add_filter("Image", &["jpg", "jpeg", "png", "gif", "bmp", "tiff"]) + .pick_file() + { + if let Some(mut track) = ctx.player_ref().selected_track.clone() { + ctx.update_track_cover(&mut track, &image_path); + } + } + } + } }); } } diff --git a/src/app/components/playback_info_panel.rs b/src/app/components/playback_info_panel.rs index c1c9f35..20a8628 100644 --- a/src/app/components/playback_info_panel.rs +++ b/src/app/components/playback_info_panel.rs @@ -205,66 +205,78 @@ impl PlaybackInfoPanel { 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); + let viewport = egui::ViewportBuilder::default() + .with_title(t("youtube_discover")) + .with_inner_size(egui::vec2(620.0, 560.0)) + .with_min_inner_size(egui::vec2(520.0, 360.0)); + + egui_ctx.show_viewport_immediate( + egui::ViewportId::from_hash_of("youtube_discover_window"), + viewport, + |viewport_ctx, _class| { + if viewport_ctx.input(|input| input.viewport().close_requested()) { + ctx.ui_state.show_youtube_discover_dialog = false; + return; } - 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; - } + egui::CentralPanel::default().show(viewport_ctx, |ui| { + Self::render_discover_contents(ctx, ui); }); + }, + ); + } - if ctx.ui_state.youtube_discover_in_progress { - ui.add_space(tokens::spacing::XS); - ui.spinner(); - } + fn render_discover_contents(ctx: &mut App, ui: &mut egui::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(ui.available_width()) + .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); + } - Self::render_discover_results(ctx, ui); - }); + 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; + } + }); - ctx.ui_state.show_youtube_discover_dialog = open; + if ctx.ui_state.youtube_discover_in_progress { + ui.add_space(tokens::spacing::XS); + ui.spinner(); + } + + Self::render_discover_results(ctx, ui); } fn render_discover_results(ctx: &mut App, ui: &mut egui::Ui) { @@ -354,108 +366,121 @@ impl PlaybackInfoPanel { return; } - let mut open = ctx.ui_state.show_youtube_download_dialog; - egui::Window::new(t("download_authorized_audio")) - .id(egui::Id::new("youtube_download_dialog")) - .open(&mut open) - .collapsible(false) - .resizable(false) - .show(&egui_ctx, |ui| { - ui.set_min_width(420.0); - ui.label(RichText::new(t("authorized_audio_notice")).weak()); - ui.add_space(tokens::spacing::XS); - - ui.label(t("youtube_url")); - ui.add_enabled( - !ctx.ui_state.youtube_download_in_progress, - TextEdit::singleline(&mut ctx.ui_state.youtube_download_url) - .hint_text("https://www.youtube.com/watch?v=..."), - ); + let viewport = egui::ViewportBuilder::default() + .with_title(t("download_authorized_audio")) + .with_inner_size(egui::vec2(500.0, 360.0)) + .with_min_inner_size(egui::vec2(420.0, 300.0)); + + egui_ctx.show_viewport_immediate( + egui::ViewportId::from_hash_of("youtube_download_window"), + viewport, + |viewport_ctx, _class| { + if viewport_ctx.input(|input| input.viewport().close_requested()) { + ctx.ui_state.show_youtube_download_dialog = false; + return; + } - 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")); + egui::CentralPanel::default().show(viewport_ctx, |ui| { + Self::render_download_contents(ctx, ui); }); + }, + ); + } - ui.add_space(tokens::spacing::XS); - ui.label(t("save_to")); - ui.horizontal(|ui| { - let mut path_text = ctx.ui_state.youtube_download_dir.display().to_string(); - ui.add_enabled( - false, - TextEdit::singleline(&mut path_text).desired_width(340.0), - ); - - let choose = ui - .add_enabled( - !ctx.ui_state.youtube_download_in_progress, - egui::Button::new(icons::FOLDER).player_style(), - ) - .on_hover_text(t("choose_download_folder")); - if choose.clicked() { - if let Some(folder) = rfd::FileDialog::new() - .set_directory(&ctx.ui_state.youtube_download_dir) - .pick_folder() - { - ctx.ui_state.youtube_download_dir = folder; - ctx.save_state(); - } - } - }); + fn render_download_contents(ctx: &mut App, ui: &mut egui::Ui) { + ui.set_min_width(420.0); + ui.label(RichText::new(t("authorized_audio_notice")).weak()); + ui.add_space(tokens::spacing::XS); + + ui.label(t("youtube_url")); + ui.add_enabled( + !ctx.ui_state.youtube_download_in_progress, + TextEdit::singleline(&mut ctx.ui_state.youtube_download_url) + .desired_width(ui.available_width()) + .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")); + }); - if let Some(status) = &ctx.ui_state.youtube_download_status { - ui.add_space(tokens::spacing::XS); - ui.label(RichText::new(status).weak()); - } + ui.add_space(tokens::spacing::XS); + ui.label(t("save_to")); + ui.horizontal(|ui| { + let mut path_text = ctx.ui_state.youtube_download_dir.display().to_string(); + ui.add_enabled( + false, + TextEdit::singleline(&mut path_text).desired_width(ui.available_width() - 44.0), + ); - 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()), - ); + let choose = ui + .add_enabled( + !ctx.ui_state.youtube_download_in_progress, + egui::Button::new(icons::FOLDER).player_style(), + ) + .on_hover_text(t("choose_download_folder")); + if choose.clicked() { + if let Some(folder) = rfd::FileDialog::new() + .set_directory(&ctx.ui_state.youtube_download_dir) + .pick_folder() + { + ctx.ui_state.youtube_download_dir = folder; + ctx.save_state(); } + } + }); - ui.add_space(tokens::spacing::SM); - ui.horizontal(|ui| { - let can_download = !ctx.ui_state.youtube_download_in_progress - && !ctx.ui_state.youtube_download_url.trim().is_empty(); - if ui - .add_enabled(can_download, egui::Button::new(t("download"))) - .clicked() - { - ctx.start_youtube_download(); - } + if let Some(status) = &ctx.ui_state.youtube_download_status { + ui.add_space(tokens::spacing::XS); + ui.label(RichText::new(status).weak()); + } - if ui - .add_enabled( - !ctx.ui_state.youtube_download_in_progress, - egui::Button::new(t("clear")), - ) - .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; - } - }); - }); + 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()), + ); + } - ctx.ui_state.show_youtube_download_dialog = open; + ui.add_space(tokens::spacing::SM); + ui.horizontal(|ui| { + let can_download = !ctx.ui_state.youtube_download_in_progress + && !ctx.ui_state.youtube_download_url.trim().is_empty(); + if ui + .add_enabled(can_download, egui::Button::new(t("download"))) + .clicked() + { + ctx.start_youtube_download(); + } + + if ui + .add_enabled( + !ctx.ui_state.youtube_download_in_progress, + egui::Button::new(t("clear")), + ) + .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; + } + }); } fn render_library_search_controls(ctx: &mut App, ui: &mut egui::Ui) -> Option { diff --git a/src/app/components/playlist_tabs.rs b/src/app/components/playlist_tabs.rs index be2ec2c..333302c 100644 --- a/src/app/components/playlist_tabs.rs +++ b/src/app/components/playlist_tabs.rs @@ -29,13 +29,13 @@ impl AppComponent for PlaylistTabs { 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() { + for idx in 0..ctx.playlists.len() { let is_selected = ctx.app_settings.current_playlist_idx == Some(idx); let is_being_renamed = ctx.ui_state.playlist_being_renamed == Some(idx); if is_being_renamed { // Show text input for renaming - let mut name = playlist.get_name().unwrap_or_default(); + let mut name = ctx.playlists[idx].get_name().unwrap_or_default(); let response = ui.add( egui::TextEdit::singleline(&mut name) .desired_width(120.0) @@ -43,12 +43,12 @@ impl PlaylistTabs { ); if response.changed() { - playlist.set_name(name.clone()); + ctx.playlists[idx].set_name(name.clone()); } if response.lost_focus() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { if !name.is_empty() { - playlist.set_name(name); + ctx.playlists[idx].set_name(name); } PlaylistService::finish_renaming_playlist_ui( &mut ctx.ui_state.playlist_being_renamed, @@ -60,8 +60,9 @@ impl PlaylistTabs { // matches the rest of the highlighted-state language used // by the player; unselected tabs render flat to keep the // tab strip from looking like a row of buttons. - let mut tab_text = egui::RichText::new(playlist.get_name().unwrap_or_default()) - .size(tokens::text::SM); + let mut tab_text = + egui::RichText::new(ctx.playlists[idx].get_name().unwrap_or_default()) + .size(tokens::text::SM); if is_selected { tab_text = tab_text.color(egui::Color32::WHITE); } @@ -75,7 +76,7 @@ impl PlaylistTabs { } else { button = button.fill(egui::Color32::TRANSPARENT); } - let tab_response = ui.add(button); + let tab_response = ui.add(button.sense(egui::Sense::click_and_drag())); if tab_response.clicked() { PlaylistService::select_playlist( @@ -84,6 +85,32 @@ impl PlaylistTabs { ); } + if tab_response.drag_started() { + ctx.ui_state.playlist_tab_dragging = Some(idx); + } + + if tab_response.hovered() + && ui.input(|input| input.pointer.any_released()) + && ctx + .ui_state + .playlist_tab_dragging + .is_some_and(|from| from != idx) + { + if let Some(from_idx) = ctx.ui_state.playlist_tab_dragging.take() { + PlaylistService::reorder_playlist( + &mut ctx.playlists, + &mut ctx.app_settings.current_playlist_idx, + &mut ctx.app_settings.playing_playlist_idx, + &mut ctx.ui_state.playlist_being_renamed, + &mut ctx.ui_state.playlist_idx_to_remove, + from_idx, + idx, + ); + ctx.save_state(); + } + return; + } + // Show context menu on right-click tab_response.context_menu(|ui| { if ui.button(t("rename")).clicked() { @@ -105,6 +132,10 @@ impl PlaylistTabs { // match the library's add-folder affordance. let create_btn = ui.add(egui::Button::new(icons::PLUS).frame(false)); + if ui.input(|input| input.pointer.any_released()) { + ctx.ui_state.playlist_tab_dragging = None; + } + if create_btn.clicked() { PlaylistService::create_playlist( &mut ctx.playlists, diff --git a/src/app/core.rs b/src/app/core.rs index 4b60f02..bdfec72 100644 --- a/src/app/core.rs +++ b/src/app/core.rs @@ -477,12 +477,22 @@ impl App { let success = LibraryService::update_track_cover( track, image_path, + &App::get_album_art_dir(), &mut self.library, &mut self.playlists, &db_conn, ); if success { + if let Some(player) = self.runtime.as_mut().map(|rt| &mut rt.player) { + if player + .selected_track + .as_ref() + .is_some_and(|selected_track| selected_track.key() == track.key()) + { + player.selected_track = Some(track.clone()); + } + } self.save_state(); } diff --git a/src/app/db.rs b/src/app/db.rs index 546c715..14f8c74 100644 --- a/src/app/db.rs +++ b/src/app/db.rs @@ -8,7 +8,7 @@ pub struct Database { impl Database { // The current schema version - increment this when making schema changes - const SCHEMA_VERSION: i32 = 5; + const SCHEMA_VERSION: i32 = 6; pub fn new() -> Result { // Get the app's configuration directory @@ -109,10 +109,30 @@ impl Database { [current_time], )?; - // Update schema version - connection.execute("UPDATE schema_version SET version = 5", [])?; - tracing::info!("Database migration to version 5 completed"); + } + + if current_version <= 5 && Self::SCHEMA_VERSION >= 6 { + tracing::info!("Running database migration to version 6"); + + let mut stmt = connection.prepare("PRAGMA table_info(playlists)")?; + let existing_columns: Vec = stmt + .query_map([], |row| row.get::<_, String>(1))? + .collect::, _>>()?; + + if !existing_columns.contains(&"sort_order".to_string()) { + connection.execute( + "ALTER TABLE playlists ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0", + [], + )?; + connection.execute( + "UPDATE playlists SET sort_order = id WHERE sort_order = 0", + [], + )?; + } + + connection.execute("UPDATE schema_version SET version = 6", [])?; + tracing::info!("Database migration to version 6 completed"); return Ok(()); } @@ -177,7 +197,8 @@ impl Database { name TEXT, description TEXT, created_at INTEGER NOT NULL DEFAULT 0, - updated_at INTEGER NOT NULL DEFAULT 0 + updated_at INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0 )", [], )?; diff --git a/src/app/i18n.rs b/src/app/i18n.rs index dbbfcd6..ff730e3 100644 --- a/src/app/i18n.rs +++ b/src/app/i18n.rs @@ -148,6 +148,7 @@ pub fn init() { "choose_download_folder".to_string(), "Choose download folder".to_string(), ); + en.insert("change_cover".to_string(), "Change cover".to_string()); 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()); @@ -349,6 +350,7 @@ pub fn init() { "choose_download_folder".to_string(), "选择下载文件夹".to_string(), ); + zh.insert("change_cover".to_string(), "更换封面".to_string()); zh.insert("download".to_string(), "下载".to_string()); zh.insert("clear".to_string(), "清空".to_string()); zh.insert("url_required".to_string(), "请输入 URL".to_string()); diff --git a/src/app/services/db_persistence.rs b/src/app/services/db_persistence.rs index c6ab52b..04b79c8 100644 --- a/src/app/services/db_persistence.rs +++ b/src/app/services/db_persistence.rs @@ -22,6 +22,19 @@ impl DBPersistence { for playlist in playlists.iter_mut() { playlist.save_to_db_and_update_id(db_conn)?; } + + let mut conn = db_conn.lock().unwrap(); + let tx = conn.transaction()?; + { + let mut stmt = tx.prepare("UPDATE playlists SET sort_order = ?1 WHERE id = ?2")?; + for (sort_order, playlist) in playlists.iter().enumerate() { + if let Some(id) = playlist.id { + stmt.execute(rusqlite::params![sort_order as i64, id])?; + } + } + } + tx.commit()?; + Ok(()) } diff --git a/src/app/services/library_service.rs b/src/app/services/library_service.rs index 8abcc9f..1ad5bb1 100644 --- a/src/app/services/library_service.rs +++ b/src/app/services/library_service.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use std::sync::Mutex; use crate::app::lib_services::MetadataEditor; -use crate::app::library::{Library, LibraryCommand, LibraryItem}; +use crate::app::library::{Library, LibraryCommand, LibraryItem, Picture}; use crate::app::playlist::Playlist; /// Application-level library service that coordinates all library management operations @@ -65,20 +65,83 @@ impl LibraryService { pub fn update_track_cover( track: &mut LibraryItem, image_path: &std::path::PathBuf, + album_art_dir: &std::path::Path, library: &mut Library, - _playlists: &mut [Playlist], + playlists: &mut [Playlist], db_conn: &Arc>, ) -> bool { let success = MetadataEditor::update_track_cover(track, image_path); - // When the cover changes, we simply reload the library from the database - // and playlists just like `update_track_metadata` does because the cover - // path needs to be refreshed from the newly extracted data if success { - if let Ok(updated_library) = Library::load_from_db(db_conn) { - *library = updated_library; + let picture = Self::picture_from_selected_cover(image_path, album_art_dir); + + track.clear_pictures(); + if let Some(picture) = picture { + track.add_picture(picture.clone()); + } + + library.update_item_pictures(track.key_str(), track.pictures().clone()); + + for playlist in playlists.iter_mut() { + for playlist_track in playlist.tracks.iter_mut() { + if playlist_track.key() == track.key() { + playlist_track.clear_pictures(); + for picture in track.pictures() { + playlist_track.add_picture(picture.clone()); + } + } + } + } + + if let Err(err) = library.save_to_db(db_conn) { + tracing::error!("Failed to save updated cover metadata: {}", err); } } success } + + fn picture_from_selected_cover( + image_path: &std::path::Path, + album_art_dir: &std::path::Path, + ) -> Option { + if let Err(err) = std::fs::create_dir_all(album_art_dir) { + tracing::error!("Failed to create album art directory: {}", err); + return None; + } + + let extension = image_path + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or("jpg") + .to_ascii_lowercase(); + let mime_type = match extension.as_str() { + "png" => "image/png", + "gif" => "image/gif", + "bmp" => "image/bmp", + "tiff" | "tif" => "image/tiff", + _ => "image/jpeg", + }; + let file_name = format!( + "cover_{}_{}.{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or_default(), + rand::random::(), + extension + ); + let destination = album_art_dir.join(file_name); + + if let Err(err) = std::fs::copy(image_path, &destination) { + tracing::error!("Failed to copy selected cover image: {}", err); + return None; + } + + Some(Picture::new( + mime_type.to_string(), + lofty::picture::PictureType::CoverFront.as_u8(), + String::new(), + destination, + )) + } } diff --git a/src/app/services/playlist_service.rs b/src/app/services/playlist_service.rs index df6a0b3..125de16 100644 --- a/src/app/services/playlist_service.rs +++ b/src/app/services/playlist_service.rs @@ -64,6 +64,44 @@ impl PlaylistService { *current_playlist_idx = Some(playlist_idx); } + /// Move a playlist tab and keep every UI index pointing at the same logical playlist. + #[allow(clippy::too_many_arguments)] + pub fn reorder_playlist( + playlists: &mut Vec, + current_playlist_idx: &mut Option, + playing_playlist_idx: &mut Option, + playlist_being_renamed: &mut Option, + playlist_idx_to_remove: &mut Option, + from_idx: usize, + to_idx: usize, + ) { + if from_idx >= playlists.len() || to_idx >= playlists.len() || from_idx == to_idx { + return; + } + + let playlist = playlists.remove(from_idx); + playlists.insert(to_idx, playlist); + + *current_playlist_idx = Self::remap_index(*current_playlist_idx, from_idx, to_idx); + *playing_playlist_idx = Self::remap_index(*playing_playlist_idx, from_idx, to_idx); + *playlist_being_renamed = Self::remap_index(*playlist_being_renamed, from_idx, to_idx); + *playlist_idx_to_remove = Self::remap_index(*playlist_idx_to_remove, from_idx, to_idx); + } + + fn remap_index(index: Option, from_idx: usize, to_idx: usize) -> Option { + let idx = index?; + + if idx == from_idx { + Some(to_idx) + } else if from_idx < to_idx && idx > from_idx && idx <= to_idx { + Some(idx - 1) + } else if from_idx > to_idx && idx >= to_idx && idx < from_idx { + Some(idx + 1) + } else { + Some(idx) + } + } + /// Start renaming a playlist pub fn start_renaming_playlist( playlist_being_renamed: &mut Option, diff --git a/src/app/state/ui_state.rs b/src/app/state/ui_state.rs index a212c7e..584a749 100644 --- a/src/app/state/ui_state.rs +++ b/src/app/state/ui_state.rs @@ -19,6 +19,9 @@ pub struct UiState { /// Index of playlist being renamed (if any) pub playlist_being_renamed: Option, + /// Index of playlist tab currently being dragged for reordering. + pub playlist_tab_dragging: Option, + /// Default window height pub default_window_height: f64, @@ -123,6 +126,7 @@ impl Default for UiState { show_about_dialog: false, playlist_idx_to_remove: None, playlist_being_renamed: None, + playlist_tab_dragging: None, default_window_height: crate::app::constants::DEFAULT_WINDOW_HEIGHT as f64, is_maximized: false, show_lyrics_panel: false, diff --git a/src/lib/library.rs b/src/lib/library.rs index 85aa2b6..76d384e 100644 --- a/src/lib/library.rs +++ b/src/lib/library.rs @@ -185,6 +185,28 @@ impl Library { lyrics_owned } + pub fn update_item_pictures(&mut self, key: &str, pictures: Vec) { + for item in self.items.iter_mut() { + if item.key_str() == key { + item.clear_pictures(); + for picture in pictures.iter().cloned() { + item.add_picture(picture); + } + } + } + + for container in self.library_view.containers.iter_mut() { + for item in container.items.iter_mut() { + if item.key_str() == key { + item.clear_pictures(); + for picture in pictures.iter().cloned() { + item.add_picture(picture); + } + } + } + } + } + // Database methods pub fn save_to_db(&mut self, conn: &Arc>) -> SqlResult<()> { diff --git a/src/lib/playlist.rs b/src/lib/playlist.rs index f32e8cb..01ad6bd 100644 --- a/src/lib/playlist.rs +++ b/src/lib/playlist.rs @@ -393,7 +393,8 @@ impl Playlist { // First, get all playlist IDs let playlist_ids = { let conn_guard = conn.lock().unwrap(); - let mut stmt = conn_guard.prepare("SELECT id FROM playlists")?; + let mut stmt = + conn_guard.prepare("SELECT id FROM playlists ORDER BY sort_order, id")?; let id_iter = stmt.query_map([], |row| row.get::<_, i64>(0))?; // Collect IDs into a Vec to release the connection lock