From a16dc446d6615d498fd70cd33c8de9feb232ba8b Mon Sep 17 00:00:00 2001 From: Elias Projahn Date: Sat, 22 Feb 2025 16:07:30 +0100 Subject: [PATCH] editor: Add album editor --- data/ui/album_editor.blp | 78 +++++++ data/ui/library_manager_albums_page.blp | 42 ++++ src/db/models.rs | 43 +++- src/editor/album_editor.rs | 208 ++++++++++++++++++ src/editor/mod.rs | 1 + src/library.rs | 109 ++++++++- src/library_manager/albums_page.rs | 145 ++++++++++++ .../mod.rs} | 128 ++--------- 8 files changed, 638 insertions(+), 116 deletions(-) create mode 100644 data/ui/album_editor.blp create mode 100644 data/ui/library_manager_albums_page.blp create mode 100644 src/editor/album_editor.rs create mode 100644 src/library_manager/albums_page.rs rename src/{library_manager.rs => library_manager/mod.rs} (64%) diff --git a/data/ui/album_editor.blp b/data/ui/album_editor.blp new file mode 100644 index 0000000..788d4a8 --- /dev/null +++ b/data/ui/album_editor.blp @@ -0,0 +1,78 @@ +using Gtk 4.0; +using Adw 1; + +template $MusicusAlbumEditor: Adw.NavigationPage { + title: _("Album"); + + Adw.ToolbarView { + [top] + Adw.HeaderBar header_bar {} + + Adw.Clamp { + Gtk.Box { + orientation: vertical; + + Gtk.Label { + label: _("Name"); + xalign: 0; + margin-top: 24; + + styles [ + "heading" + ] + } + + $MusicusTranslationEditor name_editor { + margin-top: 12; + } + + Gtk.Label { + label: _("Recordings"); + xalign: 0; + margin-top: 24; + + styles [ + "heading" + ] + } + + Gtk.ListBox recordings_list { + selection-mode: none; + margin-top: 12; + margin-bottom: 24; + + styles [ + "boxed-list" + ] + + Adw.ActionRow { + title: _("Add recording"); + activatable: true; + activated => $select_recording() swapped; + + [prefix] + Gtk.Box select_recording_box { + Gtk.Image { + icon-name: "list-add-symbolic"; + } + } + } + } + + Gtk.ListBox { + selection-mode: none; + margin-top: 24; + + styles [ + "boxed-list" + ] + + Adw.ButtonRow save_row { + title: _("Create album"); + activated => $save() swapped; + } + } + } + } + } +} diff --git a/data/ui/library_manager_albums_page.blp b/data/ui/library_manager_albums_page.blp new file mode 100644 index 0000000..0ea2610 --- /dev/null +++ b/data/ui/library_manager_albums_page.blp @@ -0,0 +1,42 @@ +using Gtk 4.0; +using Adw 1; + +template $MusicusLibraryManagerAlbumsPage: Adw.NavigationPage { + title: _("Albums"); + + Adw.ToolbarView { + [top] + Gtk.Box { + orientation: vertical; + + Adw.HeaderBar { + [end] + Gtk.Button { + icon-name: "list-add-symbolic"; + clicked => $create() swapped; + } + } + + Adw.Clamp { + Gtk.SearchEntry search_entry { + placeholder-text: _("Search albums…"); + search-changed => $search_changed() swapped; + } + } + } + + Gtk.ScrolledWindow { + Adw.Clamp { + Gtk.ListBox list { + selection-mode: none; + margin-top: 12; + valign: start; + + styles [ + "boxed-list" + ] + } + } + } + } +} diff --git a/src/db/models.rs b/src/db/models.rs index 6bbabfc..46b5053 100644 --- a/src/db/models.rs +++ b/src/db/models.rs @@ -10,7 +10,7 @@ use gtk::glib::{self, Boxed}; use super::{schema::*, tables, TranslatedString}; // Re-exports for tables that don't need additional information. -pub use tables::{Album, Instrument, Person, Role}; +pub use tables::{Instrument, Person, Role}; #[derive(Boxed, Clone, Debug)] #[boxed_type(name = "MusicusWork")] @@ -68,6 +68,14 @@ pub struct Track { pub works: Vec, } +#[derive(Boxed, Clone, Debug)] +#[boxed_type(name = "MusicusAlbum")] +pub struct Album { + pub album_id: String, + pub name: TranslatedString, + pub recordings: Vec, +} + impl Eq for Person {} impl PartialEq for Person { fn eq(&self, other: &Self) -> bool { @@ -268,6 +276,13 @@ impl Recording { } } +impl Eq for Recording {} +impl PartialEq for Recording { + fn eq(&self, other: &Self) -> bool { + self.recording_id == other.recording_id + } +} + impl Display for Recording { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}; {}", self.work, self.performers_string()) @@ -360,9 +375,35 @@ impl Track { } } +impl Album { + pub fn from_table(data: tables::Album, connection: &mut SqliteConnection) -> Result { + let recordings: Vec = recordings::table + .inner_join(album_recordings::table) + .order(album_recordings::sequence_number) + .filter(album_recordings::album_id.eq(&data.album_id)) + .select(tables::Recording::as_select()) + .load(connection)? + .into_iter() + .map(|r| Recording::from_table(r, connection)) + .collect::>>()?; + + Ok(Self { + album_id: data.album_id, + name: data.name, + recordings, + }) + } +} + impl Eq for Album {} impl PartialEq for Album { fn eq(&self, other: &Self) -> bool { self.album_id == other.album_id } } + +impl Display for Album { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name) + } +} diff --git a/src/editor/album_editor.rs b/src/editor/album_editor.rs new file mode 100644 index 0000000..167775c --- /dev/null +++ b/src/editor/album_editor.rs @@ -0,0 +1,208 @@ +use crate::{ + db::models::{Album, Recording}, + editor::{ + recording_editor::MusicusRecordingEditor, + recording_selector_popover::RecordingSelectorPopover, + translation_editor::MusicusTranslationEditor, + }, + library::MusicusLibrary, +}; + +use adw::{prelude::*, subclass::prelude::*}; +use gettextrs::gettext; +use gtk::glib::{ + clone, Properties, + {self, subclass::Signal}, +}; +use once_cell::sync::Lazy; + +use std::cell::{OnceCell, RefCell}; + +mod imp { + use super::*; + + #[derive(Debug, Default, gtk::CompositeTemplate, Properties)] + #[properties(wrapper_type = super::AlbumEditor)] + #[template(file = "data/ui/album_editor.blp")] + pub struct AlbumEditor { + #[property(get, construct_only)] + pub navigation: OnceCell, + #[property(get, construct_only)] + pub library: OnceCell, + + pub album_id: OnceCell, + pub recordings: RefCell>, + + pub recordings_popover: OnceCell, + + #[template_child] + pub name_editor: TemplateChild, + #[template_child] + pub recordings_list: TemplateChild, + #[template_child] + pub select_recording_box: TemplateChild, + #[template_child] + pub save_row: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for AlbumEditor { + const NAME: &'static str = "MusicusAlbumEditor"; + type Type = super::AlbumEditor; + type ParentType = adw::NavigationPage; + + fn class_init(klass: &mut Self::Class) { + MusicusTranslationEditor::static_type(); + klass.bind_template(); + klass.bind_template_instance_callbacks(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + #[glib::derived_properties] + impl ObjectImpl for AlbumEditor { + fn signals() -> &'static [Signal] { + static SIGNALS: Lazy> = Lazy::new(|| { + vec![Signal::builder("created") + .param_types([Album::static_type()]) + .build()] + }); + + SIGNALS.as_ref() + } + + fn constructed(&self) { + self.parent_constructed(); + + let recordings_popover = RecordingSelectorPopover::new(self.library.get().unwrap()); + + let obj = self.obj().clone(); + recordings_popover.connect_selected(move |_, recording| { + obj.add_recording(recording); + }); + + let obj = self.obj().clone(); + recordings_popover.connect_create(move |_| { + let editor = MusicusRecordingEditor::new(&obj.navigation(), &obj.library(), None); + + editor.connect_created(clone!( + #[weak] + obj, + move |_, recording| { + obj.add_recording(recording); + } + )); + + obj.navigation().push(&editor); + }); + + self.select_recording_box.append(&recordings_popover); + self.recordings_popover.set(recordings_popover).unwrap(); + } + } + + impl WidgetImpl for AlbumEditor {} + impl NavigationPageImpl for AlbumEditor {} +} + +glib::wrapper! { + pub struct AlbumEditor(ObjectSubclass) + @extends gtk::Widget, adw::NavigationPage; +} + +#[gtk::template_callbacks] +impl AlbumEditor { + pub fn new( + navigation: &adw::NavigationView, + library: &MusicusLibrary, + album: Option<&Album>, + ) -> Self { + let obj: Self = glib::Object::builder() + .property("navigation", navigation) + .property("library", library) + .build(); + + if let Some(album) = album { + obj.imp().save_row.set_title(&gettext("Save changes")); + obj.imp().album_id.set(album.album_id.clone()).unwrap(); + obj.imp().name_editor.set_translation(&album.name); + + for recording in &album.recordings { + obj.add_recording(recording.to_owned()); + } + } + + obj + } + + pub fn connect_created(&self, f: F) -> glib::SignalHandlerId { + self.connect_local("created", true, move |values| { + let obj = values[0].get::().unwrap(); + let album = values[1].get::().unwrap(); + f(&obj, album); + None + }) + } + + #[template_callback] + fn select_recording(&self) { + self.imp().recordings_popover.get().unwrap().popup(); + } + + fn add_recording(&self, recording: Recording) { + let row = adw::ActionRow::builder() + .title(recording.work.to_string()) + .subtitle(recording.performers_string()) + .build(); + + let remove_button = gtk::Button::builder() + .icon_name("user-trash-symbolic") + .valign(gtk::Align::Center) + .css_classes(["flat"]) + .build(); + + remove_button.connect_clicked(clone!( + #[weak(rename_to = this)] + self, + #[weak] + row, + #[strong] + recording, + move |_| { + this.imp().recordings_list.remove(&row); + this.imp() + .recordings + .borrow_mut() + .retain(|r| *r != recording); + } + )); + + row.add_suffix(&remove_button); + + self.imp() + .recordings_list + .insert(&row, self.imp().recordings.borrow().len() as i32); + + self.imp().recordings.borrow_mut().push(recording); + } + + #[template_callback] + fn save(&self) { + let library = self.imp().library.get().unwrap(); + + let name = self.imp().name_editor.translation(); + let recordings = self.imp().recordings.borrow().clone(); + + if let Some(album_id) = self.imp().album_id.get() { + library.update_album(album_id, name, recordings).unwrap(); + } else { + let album = library.create_album(name, recordings).unwrap(); + self.emit_by_name::<()>("created", &[&album]); + } + + self.imp().navigation.get().unwrap().pop(); + } +} diff --git a/src/editor/mod.rs b/src/editor/mod.rs index 2533e05..258ecb8 100644 --- a/src/editor/mod.rs +++ b/src/editor/mod.rs @@ -1,4 +1,5 @@ pub mod activatable_row; +pub mod album_editor; pub mod ensemble_editor; pub mod ensemble_selector_popover; pub mod instrument_editor; diff --git a/src/library.rs b/src/library.rs index 68628ec..db310a1 100644 --- a/src/library.rs +++ b/src/library.rs @@ -131,10 +131,13 @@ impl MusicusLibrary { .map(|w| Work::from_table(w, connection)) .collect::>>()?; - let albums: Vec = albums::table + let albums = albums::table .filter(albums::name.like(&search)) .limit(9) - .load(connection)?; + .load::(connection)? + .into_iter() + .map(|a| Album::from_table(a, connection)) + .collect::>>()?; LibraryResults { composers, @@ -259,7 +262,10 @@ impl MusicusLibrary { ) .select(albums::all_columns) .distinct() - .load(connection)?; + .load::(connection)? + .into_iter() + .map(|a| Album::from_table(a, connection)) + .collect::>>()?; LibraryResults { composers, @@ -320,7 +326,10 @@ impl MusicusLibrary { ) .select(albums::all_columns) .distinct() - .load(connection)?; + .load::(connection)? + .into_iter() + .map(|a| Album::from_table(a, connection)) + .collect::>>()?; LibraryResults { composers, @@ -691,7 +700,11 @@ impl MusicusLibrary { let mut binding = self.imp().connection.borrow_mut(); let connection = &mut *binding.as_mut().unwrap(); - let albums = albums::table.load::(connection)?; + let albums = albums::table + .load::(connection)? + .into_iter() + .map(|a| Album::from_table(a, connection)) + .collect::>>()?; Ok(albums) } @@ -1234,8 +1247,92 @@ impl MusicusLibrary { Ok(()) } + pub fn create_album( + &self, + name: TranslatedString, + recordings: Vec, + ) -> Result { + let mut binding = self.imp().connection.borrow_mut(); + let connection = &mut *binding.as_mut().unwrap(); + + let album_id = db::generate_id(); + let now = Local::now().naive_local(); + + let album_data = tables::Album { + album_id: album_id.clone(), + name, + created_at: now, + edited_at: now, + last_used_at: now, + last_played_at: None, + }; + + diesel::insert_into(albums::table) + .values(&album_data) + .execute(connection)?; + + for (index, recording) in recordings.into_iter().enumerate() { + let album_recording_data = tables::AlbumRecording { + album_id: album_id.clone(), + recording_id: recording.recording_id, + sequence_number: index as i32, + }; + + diesel::insert_into(album_recordings::table) + .values(&album_recording_data) + .execute(connection)?; + } + + let album = Album::from_table(album_data, connection)?; + + self.changed(); + + Ok(album) + } + + pub fn update_album( + &self, + album_id: &str, + name: TranslatedString, + recordings: Vec, + ) -> Result<()> { + let mut binding = self.imp().connection.borrow_mut(); + let connection = &mut *binding.as_mut().unwrap(); + + let now = Local::now().naive_local(); + + diesel::update(albums::table) + .filter(albums::album_id.eq(album_id)) + .set(( + albums::name.eq(name), + albums::edited_at.eq(now), + albums::last_used_at.eq(now), + )) + .execute(connection)?; + + diesel::delete(album_recordings::table) + .filter(album_recordings::album_id.eq(album_id)) + .execute(connection)?; + + for (index, recording) in recordings.into_iter().enumerate() { + let album_recording_data = tables::AlbumRecording { + album_id: album_id.to_owned(), + recording_id: recording.recording_id, + sequence_number: index as i32, + }; + + diesel::insert_into(album_recordings::table) + .values(&album_recording_data) + .execute(connection)?; + } + + self.changed(); + + Ok(()) + } + /// Import a track into the music library. - // TODO: Support mediums, think about albums. + // TODO: Support mediums. pub fn import_track( &self, path: impl AsRef, diff --git a/src/library_manager/albums_page.rs b/src/library_manager/albums_page.rs new file mode 100644 index 0000000..7fe74e8 --- /dev/null +++ b/src/library_manager/albums_page.rs @@ -0,0 +1,145 @@ +use crate::{db::models::Album, editor::album_editor::AlbumEditor, library::MusicusLibrary}; + +use adw::{prelude::*, subclass::prelude::*}; +use gettextrs::gettext; +use gtk::glib::{self, clone}; + +use std::cell::{OnceCell, RefCell}; + +mod imp { + use super::*; + + #[derive(Debug, Default, gtk::CompositeTemplate)] + #[template(file = "data/ui/library_manager_albums_page.blp")] + pub struct AlbumsPage { + pub navigation: OnceCell, + pub library: OnceCell, + pub albums: RefCell>, + pub albums_filtered: RefCell>, + + #[template_child] + pub search_entry: TemplateChild, + #[template_child] + pub list: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for AlbumsPage { + const NAME: &'static str = "MusicusLibraryManagerAlbumsPage"; + type Type = super::AlbumsPage; + type ParentType = adw::NavigationPage; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + klass.bind_template_instance_callbacks(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for AlbumsPage {} + impl WidgetImpl for AlbumsPage {} + + impl NavigationPageImpl for AlbumsPage { + fn showing(&self) { + self.parent_showing(); + self.obj().update(); + } + } +} + +glib::wrapper! { + pub struct AlbumsPage(ObjectSubclass) + @extends gtk::Widget, adw::NavigationPage; +} + +#[gtk::template_callbacks] +impl AlbumsPage { + pub fn new(navigation: &adw::NavigationView, library: &MusicusLibrary) -> Self { + let obj: Self = glib::Object::new(); + let imp = obj.imp(); + + imp.navigation.set(navigation.to_owned()).unwrap(); + imp.library.set(library.to_owned()).unwrap(); + + obj + } + + fn update(&self) { + let albums = self.imp().library.get().unwrap().all_albums().unwrap(); + self.imp().albums.replace(albums); + self.search_changed(); + } + + #[template_callback] + fn search_changed(&self) { + let albums_filtered = self + .imp() + .albums + .borrow() + .iter() + .filter(|a| { + a.name + .get() + .contains(&self.imp().search_entry.text().to_string()) + }) + .cloned() + .collect::>(); + + self.imp().list.remove_all(); + + for album in albums_filtered { + let row = adw::ActionRow::builder() + .title(album.name.get()) + .activatable(true) + .build(); + + row.connect_activated(clone!( + #[weak(rename_to = obj)] + self, + #[strong] + album, + move |_| { + obj.imp().navigation.get().unwrap().push(&AlbumEditor::new( + &obj.imp().navigation.get().unwrap(), + &obj.imp().library.get().unwrap(), + Some(&album), + )); + } + )); + + let delete_button = gtk::Button::builder() + .icon_name("user-trash-symbolic") + .tooltip_text(gettext("Delete album")) + .valign(gtk::Align::Center) + .css_classes(["flat"]) + .build(); + + // TODO: + // delete_button.connect_clicked(clone!( + // #[weak(rename_to = obj)] + // self, + // #[strong] + // album, + // move |_| { + // obj.imp().library.delete_album(&album.album_id).unwrap(); + // } + // )); + + row.add_suffix(&delete_button); + + self.imp().list.append(&row); + } + } + + #[template_callback] + fn create(&self) { + self.imp().navigation.get().unwrap().push(&AlbumEditor::new( + &self.imp().navigation.get().unwrap(), + &self.imp().library.get().unwrap(), + None, + )); + } +} diff --git a/src/library_manager.rs b/src/library_manager/mod.rs similarity index 64% rename from src/library_manager.rs rename to src/library_manager/mod.rs index bcfc9cc..6107c49 100644 --- a/src/library_manager.rs +++ b/src/library_manager/mod.rs @@ -1,3 +1,5 @@ +pub mod albums_page; + use crate::{ db::{ models::{Album, Ensemble, Instrument, Person, Recording, Role, Track, Work}, @@ -8,6 +10,7 @@ use crate::{ }; use adw::{prelude::*, subclass::prelude::*}; +use albums_page::AlbumsPage; use gettextrs::gettext; use gtk::glib; @@ -103,7 +106,7 @@ impl LibraryManager { } #[template_callback] - async fn open_library(&self, _: &adw::ActionRow) { + async fn open_library(&self) { let dialog = gtk::FileDialog::builder() .title(gettext("Select music library folder")) .modal(true) @@ -127,37 +130,41 @@ impl LibraryManager { } #[template_callback] - fn import_archive(&self, _: &adw::ButtonRow) {} + fn import_archive(&self) {} #[template_callback] - fn export_archive(&self, _: &adw::ButtonRow) {} + fn export_archive(&self) {} #[template_callback] - fn show_persons(&self, _: &adw::ActionRow) {} + fn show_persons(&self) {} #[template_callback] - fn show_roles(&self, _: &adw::ActionRow) {} + fn show_roles(&self) {} #[template_callback] - fn show_instruments(&self, _: &adw::ActionRow) {} + fn show_instruments(&self) {} #[template_callback] - fn show_works(&self, _: &adw::ActionRow) {} + fn show_works(&self) {} #[template_callback] - fn show_ensembles(&self, _: &adw::ActionRow) {} + fn show_ensembles(&self) {} #[template_callback] - fn show_recordings(&self, _: &adw::ActionRow) {} + fn show_recordings(&self) {} #[template_callback] - fn show_tracks(&self, _: &adw::ActionRow) {} + fn show_tracks(&self) {} #[template_callback] - fn show_mediums(&self, _: &adw::ActionRow) {} + fn show_mediums(&self) {} #[template_callback] - fn show_albums(&self, _: &adw::ActionRow) {} + fn show_albums(&self) { + let navigation = self.imp().navigation.get().unwrap(); + let library = self.imp().library.get().unwrap(); + navigation.push(&AlbumsPage::new(navigation, library)); + } // TODO: Make this async. fn update(&self) { @@ -217,101 +224,4 @@ impl LibraryManager { .set_label(&albums.len().to_string()); self.imp().albums.replace(albums); } - - // #[template_callback] - // fn add_person(&self) { - // self.imp() - // .navigation - // .get() - // .unwrap() - // .push(&MusicusPersonEditor::new( - // &self.imp().navigation.get().unwrap(), - // &self.imp().library.get().unwrap(), - // None, - // )); - // } - - // #[template_callback] - // fn add_role(&self) { - // self.imp() - // .navigation - // .get() - // .unwrap() - // .push(&MusicusRoleEditor::new( - // &self.imp().navigation.get().unwrap(), - // &self.imp().library.get().unwrap(), - // None, - // )); - // } - - // #[template_callback] - // fn add_instrument(&self) { - // self.imp() - // .navigation - // .get() - // .unwrap() - // .push(&MusicusInstrumentEditor::new( - // &self.imp().navigation.get().unwrap(), - // &self.imp().library.get().unwrap(), - // None, - // )); - // } - - // #[template_callback] - // fn add_work(&self) { - // self.imp() - // .navigation - // .get() - // .unwrap() - // .push(&MusicusWorkEditor::new( - // &self.imp().navigation.get().unwrap(), - // &self.imp().library.get().unwrap(), - // None, - // )); - // } - - // #[template_callback] - // fn add_ensemble(&self) { - // self.imp() - // .navigation - // .get() - // .unwrap() - // .push(&MusicusEnsembleEditor::new( - // &self.imp().navigation.get().unwrap(), - // &self.imp().library.get().unwrap(), - // None, - // )); - // } - - // #[template_callback] - // fn add_recording(&self) { - // self.imp() - // .navigation - // .get() - // .unwrap() - // .push(&MusicusRecordingEditor::new( - // &self.imp().navigation.get().unwrap(), - // &self.imp().library.get().unwrap(), - // None, - // )); - // } - - // #[template_callback] - // fn add_medium(&self) { - // todo!("Medium import"); - // } - - // #[template_callback] - // fn add_album(&self) { - // todo!("Album editor"); - // // self.imp() - // // .navigation - // // .get() - // // .unwrap() - // // .push(&MusicusAlbumEditor::new( - // // &self.imp().navigation.get().unwrap(), - // // &self.imp().library.get().unwrap(), - // // None, - // // )); - // } }