From 143876c4de4cbfcb1761b58630d90502462f5730 Mon Sep 17 00:00:00 2001 From: Elias Projahn Date: Sun, 9 Feb 2025 10:00:46 +0100 Subject: [PATCH] Add tracks editor UI --- Cargo.lock | 7 + Cargo.toml | 1 + data/ui/home_page.blp | 5 + data/ui/recording_selector_popover.blp | 147 ++++++++ data/ui/tracks_editor.blp | 98 ++++++ data/ui/tracks_editor_track_row.blp | 25 ++ src/editor/mod.rs | 3 + src/editor/recording_selector_popover.rs | 424 +++++++++++++++++++++++ src/editor/tracks_editor.rs | 240 +++++++++++++ src/editor/tracks_editor_track_row.rs | 149 ++++++++ src/library.rs | 44 ++- src/window.rs | 42 ++- 12 files changed, 1159 insertions(+), 26 deletions(-) create mode 100644 data/ui/recording_selector_popover.blp create mode 100644 data/ui/tracks_editor.blp create mode 100644 data/ui/tracks_editor_track_row.blp create mode 100644 src/editor/recording_selector_popover.rs create mode 100644 src/editor/tracks_editor.rs create mode 100644 src/editor/tracks_editor_track_row.rs diff --git a/Cargo.lock b/Cargo.lock index 6b03e63..8ab11f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -552,6 +552,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "formatx" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa6f3b9014e23925937fbf4d05f27a6f4efe42545f98690b94f193bdb3f1959e" + [[package]] name = "fragile" version = "2.0.0" @@ -1292,6 +1298,7 @@ dependencies = [ "chrono", "diesel", "diesel_migrations", + "formatx", "fragile", "gettext-rs", "glib", diff --git a/Cargo.toml b/Cargo.toml index f9af588..99a32d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ anyhow = "1" chrono = "0.4" diesel = { version = "2.2", features = ["chrono", "sqlite"] } diesel_migrations = "2.2" +formatx = "0.2" fragile = "2" gettext-rs = { version = "0.7", features = ["gettext-system"] } gstreamer-play = "0.23" diff --git a/data/ui/home_page.blp b/data/ui/home_page.blp index 17dd4e1..a9a3529 100644 --- a/data/ui/home_page.blp +++ b/data/ui/home_page.blp @@ -258,6 +258,11 @@ template $MusicusHomePage: Adw.NavigationPage { } menu primary_menu { + item { + label: _("_Import music"); + action: "win.import"; + } + item { label: _("_Library manager"); action: "win.library"; diff --git a/data/ui/recording_selector_popover.blp b/data/ui/recording_selector_popover.blp new file mode 100644 index 0000000..5f4581f --- /dev/null +++ b/data/ui/recording_selector_popover.blp @@ -0,0 +1,147 @@ +using Gtk 4.0; +using Adw 1; + +template $MusicusRecordingSelectorPopover: Gtk.Popover { + styles [ + "selector" + ] + + Gtk.Stack stack { + transition-type: slide_left_right; + + Adw.ToolbarView composer_view { + [top] + Gtk.SearchEntry composer_search_entry { + placeholder-text: _("Search composers…"); + margin-start: 8; + margin-end: 8; + margin-top: 8; + margin-bottom: 6; + search-changed => $composer_search_changed() swapped; + activate => $composer_activate() swapped; + stop-search => $stop_search() swapped; + } + + Gtk.ScrolledWindow composer_scrolled_window { + height-request: 200; + + Gtk.ListBox composer_list { + styles [ + "selector-list" + ] + + selection-mode: none; + activate-on-single-click: true; + } + } + } + + Adw.ToolbarView work_view { + [top] + Gtk.Box { + margin-start: 8; + margin-end: 8; + margin-top: 8; + margin-bottom: 6; + orientation: vertical; + + Gtk.CenterBox { + [start] + Gtk.Button { + styles [ + "flat" + ] + + icon-name: "go-previous-symbolic"; + clicked => $back_to_composer() swapped; + } + + [center] + Gtk.Label composer_label { + styles [ + "heading" + ] + + ellipsize: end; + margin-start: 6; + } + } + + Gtk.SearchEntry work_search_entry { + placeholder-text: _("Search works…"); + margin-top: 6; + search-changed => $work_search_changed() swapped; + activate => $work_activate() swapped; + stop-search => $stop_search() swapped; + } + } + + Gtk.ScrolledWindow work_scrolled_window { + height-request: 200; + + Gtk.ListBox work_list { + styles [ + "selector-list" + ] + + selection-mode: none; + activate-on-single-click: true; + } + } + } + + Adw.ToolbarView recording_view { + [top] + Gtk.Box { + margin-start: 8; + margin-end: 8; + margin-top: 8; + margin-bottom: 6; + orientation: vertical; + + Gtk.CenterBox { + [start] + Gtk.Button { + styles [ + "flat" + ] + + icon-name: "go-previous-symbolic"; + clicked => $back_to_work() swapped; + } + + [center] + Gtk.Label work_label { + styles [ + "heading" + ] + + ellipsize: end; + margin-start: 6; + } + } + + Gtk.SearchEntry recording_search_entry { + placeholder-text: _("Search recordings…"); + margin-top: 6; + search-changed => $recording_search_changed() swapped; + activate => $recording_activate() swapped; + stop-search => $stop_search() swapped; + } + } + + Gtk.ScrolledWindow recording_scrolled_window { + height-request: 200; + + Gtk.ListBox recording_list { + styles [ + "selector-list" + ] + + selection-mode: none; + activate-on-single-click: true; + } + } + } + } +} diff --git a/data/ui/tracks_editor.blp b/data/ui/tracks_editor.blp new file mode 100644 index 0000000..b65a0bb --- /dev/null +++ b/data/ui/tracks_editor.blp @@ -0,0 +1,98 @@ +using Gtk 4.0; +using Adw 1; + +template $MusicusTracksEditor: Adw.NavigationPage { + title: _("Tracks"); + + Adw.ToolbarView { + [top] + Adw.HeaderBar {} + + Gtk.ScrolledWindow { + Adw.Clamp { + Gtk.Box { + orientation: vertical; + margin-bottom: 24; + margin-start: 12; + margin-end: 12; + + Gtk.Label { + label: _("Recording"); + xalign: 0; + margin-top: 24; + + styles [ + "heading" + ] + } + + Gtk.ListBox { + selection-mode: none; + margin-top: 12; + + styles [ + "boxed-list" + ] + + Adw.ActionRow recording_row { + title: _("Select recording"); + activatable: true; + activated => $select_recording() swapped; + + [prefix] + Gtk.Box select_recording_box { + Gtk.Image { + icon-name: "document-edit-symbolic"; + } + } + } + } + + Gtk.Label { + label: _("Tracks"); + xalign: 0; + margin-top: 24; + + styles [ + "heading" + ] + } + + Gtk.ListBox track_list { + selection-mode: none; + margin-top: 12; + + styles [ + "boxed-list" + ] + + Adw.ActionRow { + title: _("Add files"); + activatable: true; + activated => $add_files() swapped; + + [prefix] + Gtk.Image { + icon-name: "list-add-symbolic"; + } + } + } + + Gtk.ListBox { + selection-mode: none; + margin-top: 24; + + styles [ + "boxed-list" + ] + + Adw.ButtonRow save_row { + title: _("Import tracks"); + activated => $save() swapped; + } + } + } + } + } + } +} diff --git a/data/ui/tracks_editor_track_row.blp b/data/ui/tracks_editor_track_row.blp new file mode 100644 index 0000000..676458a --- /dev/null +++ b/data/ui/tracks_editor_track_row.blp @@ -0,0 +1,25 @@ +using Gtk 4.0; +using Adw 1; + +template $MusicusTracksEditorTrackRow: Adw.ActionRow { + title: _("Select parts"); + activatable: true; + activated => $select_parts() swapped; + + [prefix] + Gtk.Box select_parts_box { + Gtk.Image { + icon-name: "document-edit-symbolic"; + } + } + + Gtk.Button { + icon-name: "user-trash-symbolic"; + valign: center; + clicked => $remove() swapped; + + styles [ + "flat" + ] + } +} diff --git a/src/editor/mod.rs b/src/editor/mod.rs index 11deb59..563abf1 100644 --- a/src/editor/mod.rs +++ b/src/editor/mod.rs @@ -9,8 +9,11 @@ pub mod person_selector_popover; pub mod recording_editor; pub mod recording_editor_ensemble_row; pub mod recording_editor_performer_row; +pub mod recording_selector_popover; pub mod role_editor; pub mod role_selector_popover; +pub mod tracks_editor; +pub mod tracks_editor_track_row; pub mod translation_editor; pub mod translation_entry; pub mod work_editor; diff --git a/src/editor/recording_selector_popover.rs b/src/editor/recording_selector_popover.rs new file mode 100644 index 0000000..edb0e62 --- /dev/null +++ b/src/editor/recording_selector_popover.rs @@ -0,0 +1,424 @@ +use crate::{ + db::models::{Person, Recording, Work}, + library::MusicusLibrary, +}; + +use gettextrs::gettext; +use gtk::{ + glib::{self, subclass::Signal, Properties}, + pango, + prelude::*, + subclass::prelude::*, +}; +use once_cell::sync::Lazy; + +use std::cell::{OnceCell, RefCell}; + +use super::activatable_row::MusicusActivatableRow; + +mod imp { + use super::*; + + #[derive(Debug, Default, gtk::CompositeTemplate, Properties)] + #[properties(wrapper_type = super::RecordingSelectorPopover)] + #[template(file = "data/ui/recording_selector_popover.blp")] + pub struct RecordingSelectorPopover { + #[property(get, construct_only)] + pub library: OnceCell, + + pub composers: RefCell>, + pub works: RefCell>, + pub recordings: RefCell>, + + pub composer: RefCell>, + pub work: RefCell>, + + #[template_child] + pub stack: TemplateChild, + #[template_child] + pub composer_view: TemplateChild, + #[template_child] + pub composer_search_entry: TemplateChild, + #[template_child] + pub composer_scrolled_window: TemplateChild, + #[template_child] + pub composer_list: TemplateChild, + #[template_child] + pub work_view: TemplateChild, + #[template_child] + pub composer_label: TemplateChild, + #[template_child] + pub work_search_entry: TemplateChild, + #[template_child] + pub work_scrolled_window: TemplateChild, + #[template_child] + pub work_list: TemplateChild, + #[template_child] + pub recording_view: TemplateChild, + #[template_child] + pub work_label: TemplateChild, + #[template_child] + pub recording_search_entry: TemplateChild, + #[template_child] + pub recording_scrolled_window: TemplateChild, + #[template_child] + pub recording_list: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for RecordingSelectorPopover { + const NAME: &'static str = "MusicusRecordingSelectorPopover"; + type Type = super::RecordingSelectorPopover; + type ParentType = gtk::Popover; + + 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(); + } + } + + #[glib::derived_properties] + impl ObjectImpl for RecordingSelectorPopover { + fn constructed(&self) { + self.parent_constructed(); + + self.obj() + .connect_visible_notify(|obj: &super::RecordingSelectorPopover| { + if obj.is_visible() { + obj.imp().stack.set_visible_child(&*obj.imp().composer_view); + obj.imp().composer_search_entry.set_text(""); + obj.imp().composer_search_entry.grab_focus(); + obj.imp() + .composer_scrolled_window + .vadjustment() + .set_value(0.0); + } + }); + + self.obj().search_composers(""); + } + + fn signals() -> &'static [Signal] { + static SIGNALS: Lazy> = Lazy::new(|| { + vec![ + Signal::builder("selected") + .param_types([Recording::static_type()]) + .build(), + Signal::builder("create").build(), + ] + }); + + SIGNALS.as_ref() + } + } + + impl WidgetImpl for RecordingSelectorPopover { + // TODO: Fix focus. + fn focus(&self, direction_type: gtk::DirectionType) -> bool { + if direction_type == gtk::DirectionType::Down { + if self.stack.visible_child() == Some(self.composer_list.get().upcast()) { + self.composer_list.child_focus(direction_type) + } else if self.stack.visible_child() == Some(self.work_list.get().upcast()) { + self.work_list.child_focus(direction_type) + } else { + self.recording_list.child_focus(direction_type) + } + } else { + self.parent_focus(direction_type) + } + } + } + + impl PopoverImpl for RecordingSelectorPopover {} +} + +glib::wrapper! { + pub struct RecordingSelectorPopover(ObjectSubclass) + @extends gtk::Widget, gtk::Popover; +} + +#[gtk::template_callbacks] +impl RecordingSelectorPopover { + pub fn new(library: &MusicusLibrary) -> Self { + glib::Object::builder().property("library", library).build() + } + + pub fn connect_selected( + &self, + f: F, + ) -> glib::SignalHandlerId { + self.connect_local("selected", true, move |values| { + let obj = values[0].get::().unwrap(); + let recording = values[1].get::().unwrap(); + f(&obj, recording); + None + }) + } + + pub fn connect_create(&self, f: F) -> glib::SignalHandlerId { + self.connect_local("create", true, move |values| { + let obj = values[0].get::().unwrap(); + f(&obj); + None + }) + } + + #[template_callback] + fn composer_search_changed(&self, entry: >k::SearchEntry) { + self.search_composers(&entry.text()); + } + + #[template_callback] + fn composer_activate(&self, _: >k::SearchEntry) { + if let Some(composer) = self.imp().composers.borrow().first() { + self.select_composer(composer.to_owned()); + } else { + self.create(); + } + } + + #[template_callback] + fn back_to_composer(&self, _: >k::Button) { + self.imp() + .stack + .set_visible_child(&*self.imp().composer_view); + self.imp().composer_search_entry.grab_focus(); + } + + #[template_callback] + fn work_search_changed(&self, entry: >k::SearchEntry) { + self.search_works(&entry.text()); + } + + #[template_callback] + fn work_activate(&self, _: >k::SearchEntry) { + if let Some(work) = self.imp().works.borrow().first() { + self.select_work(work.to_owned()); + } else { + self.create(); + } + } + + #[template_callback] + fn back_to_work(&self, _: >k::Button) { + self.imp().stack.set_visible_child(&*self.imp().work_view); + self.imp().work_search_entry.grab_focus(); + } + + #[template_callback] + fn recording_search_changed(&self, entry: >k::SearchEntry) { + self.search_recordings(&entry.text()); + } + + #[template_callback] + fn recording_activate(&self, _: >k::SearchEntry) { + if let Some(recording) = self.imp().recordings.borrow().first() { + self.select(recording.to_owned()); + } else { + self.create(); + } + } + + #[template_callback] + fn stop_search(&self, _: >k::SearchEntry) { + self.popdown(); + } + + fn search_composers(&self, search: &str) { + let imp = self.imp(); + + let persons = imp.library.get().unwrap().search_persons(search).unwrap(); + + imp.composer_list.remove_all(); + + for person in &persons { + let row = MusicusActivatableRow::new( + >k::Label::builder() + .label(person.to_string()) + .halign(gtk::Align::Start) + .ellipsize(pango::EllipsizeMode::Middle) + .build(), + ); + + row.set_tooltip_text(Some(&person.to_string())); + + let person = person.clone(); + let obj = self.clone(); + row.connect_activated(move |_: &MusicusActivatableRow| { + obj.select_composer(person.clone()); + }); + + imp.composer_list.append(&row); + } + + let create_box = gtk::Box::builder().spacing(12).build(); + create_box.append(>k::Image::builder().icon_name("list-add-symbolic").build()); + create_box.append( + >k::Label::builder() + .label(gettext("Create new recording")) + .halign(gtk::Align::Start) + .build(), + ); + + let create_row = MusicusActivatableRow::new(&create_box); + let obj = self.clone(); + create_row.connect_activated(move |_: &MusicusActivatableRow| { + obj.create(); + }); + + imp.composer_list.append(&create_row); + + imp.composers.replace(persons); + } + + fn search_works(&self, search: &str) { + let imp = self.imp(); + + let works = imp + .library + .get() + .unwrap() + .search_works(imp.composer.borrow().as_ref().unwrap(), search) + .unwrap(); + + imp.work_list.remove_all(); + + for work in &works { + let row = MusicusActivatableRow::new( + >k::Label::builder() + .label(work.name.get()) + .halign(gtk::Align::Start) + .ellipsize(pango::EllipsizeMode::Middle) + .build(), + ); + + row.set_tooltip_text(Some(&work.name.get())); + + let work = work.clone(); + let obj = self.clone(); + row.connect_activated(move |_: &MusicusActivatableRow| { + obj.select_work(work.clone()); + }); + + imp.work_list.append(&row); + } + + let create_box = gtk::Box::builder().spacing(12).build(); + create_box.append(>k::Image::builder().icon_name("list-add-symbolic").build()); + create_box.append( + >k::Label::builder() + .label(gettext("Create new recording")) + .halign(gtk::Align::Start) + .build(), + ); + + let create_row = MusicusActivatableRow::new(&create_box); + let obj = self.clone(); + create_row.connect_activated(move |_: &MusicusActivatableRow| { + obj.create(); + }); + + imp.work_list.append(&create_row); + + imp.works.replace(works); + } + + fn search_recordings(&self, search: &str) { + let imp = self.imp(); + + let recordings = imp + .library + .get() + .unwrap() + .search_recordings(imp.work.borrow().as_ref().unwrap(), search) + .unwrap(); + + imp.recording_list.remove_all(); + + for recording in &recordings { + let mut label = recording.performers_string(); + + if let Some(year) = recording.year { + label.push_str(&format!(" ({year})")); + } + + let row = MusicusActivatableRow::new( + >k::Label::builder() + .label(&label) + .halign(gtk::Align::Start) + .ellipsize(pango::EllipsizeMode::Middle) + .build(), + ); + + row.set_tooltip_text(Some(&label)); + + let recording = recording.clone(); + let obj = self.clone(); + row.connect_activated(move |_: &MusicusActivatableRow| { + obj.select(recording.clone()); + }); + + imp.recording_list.append(&row); + } + + let create_box = gtk::Box::builder().spacing(12).build(); + create_box.append(>k::Image::builder().icon_name("list-add-symbolic").build()); + create_box.append( + >k::Label::builder() + .label(gettext("Create new recording")) + .halign(gtk::Align::Start) + .build(), + ); + + let create_row = MusicusActivatableRow::new(&create_box); + let obj = self.clone(); + create_row.connect_activated(move |_: &MusicusActivatableRow| { + obj.create(); + }); + + imp.recording_list.append(&create_row); + + imp.recordings.replace(recordings); + } + + fn select_composer(&self, person: Person) { + self.imp().composer_label.set_text(person.name.get()); + self.imp().work_search_entry.set_text(""); + self.imp().work_search_entry.grab_focus(); + self.imp().work_scrolled_window.vadjustment().set_value(0.0); + self.imp().stack.set_visible_child(&*self.imp().work_view); + + self.imp().composer.replace(Some(person.clone())); + self.search_works(""); + } + + fn select_work(&self, work: Work) { + self.imp().work_label.set_text(work.name.get()); + self.imp().recording_search_entry.set_text(""); + self.imp().recording_search_entry.grab_focus(); + self.imp() + .recording_scrolled_window + .vadjustment() + .set_value(0.0); + self.imp() + .stack + .set_visible_child(&*self.imp().recording_view); + + self.imp().work.replace(Some(work.clone())); + self.search_recordings(""); + } + + fn select(&self, recording: Recording) { + self.emit_by_name::<()>("selected", &[&recording]); + self.popdown(); + } + + fn create(&self) { + self.emit_by_name::<()>("create", &[]); + self.popdown(); + } +} diff --git a/src/editor/tracks_editor.rs b/src/editor/tracks_editor.rs new file mode 100644 index 0000000..4d2869b --- /dev/null +++ b/src/editor/tracks_editor.rs @@ -0,0 +1,240 @@ +use super::tracks_editor_track_row::{PathType, TracksEditorTrackData}; +use crate::{ + db::models::Recording, + editor::{ + recording_editor::MusicusRecordingEditor, + recording_selector_popover::RecordingSelectorPopover, + tracks_editor_track_row::TracksEditorTrackRow, + }, + library::MusicusLibrary, +}; + +use adw::{prelude::*, subclass::prelude::*}; +use gettextrs::gettext; +use gtk::{ + gio, + glib::{self, clone, subclass::Signal, Properties}, +}; +use once_cell::sync::Lazy; + +use std::{ + cell::{OnceCell, RefCell}, + path::PathBuf, +}; + +mod imp { + use super::*; + + #[derive(Debug, Default, gtk::CompositeTemplate, Properties)] + #[properties(wrapper_type = super::TracksEditor)] + #[template(file = "data/ui/tracks_editor.blp")] + pub struct TracksEditor { + #[property(get, construct_only)] + pub navigation: OnceCell, + #[property(get, construct_only)] + pub library: OnceCell, + + pub recording: RefCell>, + pub recordings_popover: OnceCell, + pub track_rows: RefCell>, + + #[template_child] + pub recording_row: TemplateChild, + #[template_child] + pub select_recording_box: TemplateChild, + #[template_child] + pub track_list: TemplateChild, + #[template_child] + pub save_row: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for TracksEditor { + const NAME: &'static str = "MusicusTracksEditor"; + type Type = super::TracksEditor; + 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(); + } + } + + #[glib::derived_properties] + impl ObjectImpl for TracksEditor { + fn signals() -> &'static [Signal] { + static SIGNALS: Lazy> = Lazy::new(|| { + vec![Signal::builder("created") + .param_types([Recording::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.set_recording(recording); + }); + + let obj = self.obj().clone(); + recordings_popover.connect_create(move |_| { + let editor = MusicusRecordingEditor::new( + obj.imp().navigation.get().unwrap(), + &obj.library(), + None, + ); + + editor.connect_created(clone!( + #[weak] + obj, + move |_, recording| { + obj.set_recording(recording); + } + )); + + obj.imp().navigation.get().unwrap().push(&editor); + }); + + self.select_recording_box.append(&recordings_popover); + self.recordings_popover.set(recordings_popover).unwrap(); + } + } + + impl WidgetImpl for TracksEditor {} + impl NavigationPageImpl for TracksEditor {} +} + +glib::wrapper! { + pub struct TracksEditor(ObjectSubclass) + @extends gtk::Widget, adw::NavigationPage; +} + +#[gtk::template_callbacks] +impl TracksEditor { + pub fn new( + navigation: &adw::NavigationView, + library: &MusicusLibrary, + recording: Option, + ) -> Self { + let obj: Self = glib::Object::builder() + .property("navigation", navigation) + .property("library", library) + .build(); + + if let Some(recording) = recording { + obj.imp().save_row.set_title(&gettext("Save changes")); + obj.set_recording(recording); + } + + obj + } + + #[template_callback] + fn select_recording(&self, _: &adw::ActionRow) { + self.imp().recordings_popover.get().unwrap().popup(); + } + + #[template_callback] + async fn add_files(&self, _: &adw::ActionRow) { + let dialog = gtk::FileDialog::builder() + .title(gettext("Select audio files")) + .modal(true) + .build(); + + let root = self.root(); + let window = root + .as_ref() + .and_then(|r| r.downcast_ref::()) + .unwrap(); + + let obj = self.clone(); + match dialog.open_multiple_future(Some(window)).await { + Err(err) => { + if !err.matches(gtk::DialogError::Dismissed) { + log::error!("File selection failed: {err}"); + } + } + Ok(files) => { + for file in &files { + obj.add_file( + file.unwrap() + .downcast::() + .unwrap() + .path() + .unwrap(), + ); + } + } + } + } + + fn set_recording(&self, recording: Recording) { + self.imp().recording_row.set_title(&format!( + "{}: {}", + recording.work.composers_string(), + recording.work.name.get(), + )); + + self.imp() + .recording_row + .set_subtitle(&recording.performers_string()); + + for track in self + .library() + .tracks_for_recording(&recording.recording_id) + .unwrap() + { + self.add_track_row(TracksEditorTrackData { + track_id: Some(track.track_id), + path: PathType::Library(track.path), + works: track.works, + }); + } + + self.imp().recording.replace(Some(recording)); + } + + fn add_file(&self, path: PathBuf) { + self.add_track_row(TracksEditorTrackData { + track_id: None, + path: PathType::System(path), + works: Vec::new(), + }); + } + + fn add_track_row(&self, track_data: TracksEditorTrackData) { + let track_row = TracksEditorTrackRow::new(&self.navigation(), &self.library(), track_data); + + track_row.connect_remove(clone!( + #[weak(rename_to = this)] + self, + move |row| { + this.imp().track_list.remove(row); + this.imp().track_rows.borrow_mut().retain(|p| p != row); + } + )); + + self.imp() + .track_list + .insert(&track_row, self.imp().track_rows.borrow().len() as i32); + + self.imp().track_rows.borrow_mut().push(track_row); + } + + #[template_callback] + fn save(&self) { + // TODO + + self.navigation().pop(); + } +} diff --git a/src/editor/tracks_editor_track_row.rs b/src/editor/tracks_editor_track_row.rs new file mode 100644 index 0000000..d78d1f9 --- /dev/null +++ b/src/editor/tracks_editor_track_row.rs @@ -0,0 +1,149 @@ +use crate::{db::models::Work, library::MusicusLibrary}; + +use adw::{prelude::*, subclass::prelude::*}; +use formatx::formatx; +use gettextrs::gettext; +use gtk::glib::{self, clone, subclass::Signal, Properties}; +use once_cell::sync::Lazy; + +use std::{ + cell::{OnceCell, RefCell}, + path::PathBuf, +}; + +mod imp { + use super::*; + + #[derive(Properties, Debug, Default, gtk::CompositeTemplate)] + #[properties(wrapper_type = super::TracksEditorTrackRow)] + #[template(file = "data/ui/tracks_editor_track_row.blp")] + pub struct TracksEditorTrackRow { + #[property(get, construct_only)] + pub navigation: OnceCell, + #[property(get, construct_only)] + pub library: OnceCell, + + pub track_data: RefCell, + + #[template_child] + pub select_parts_box: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for TracksEditorTrackRow { + const NAME: &'static str = "MusicusTracksEditorTrackRow"; + type Type = super::TracksEditorTrackRow; + type ParentType = adw::ActionRow; + + 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(); + } + } + + #[glib::derived_properties] + impl ObjectImpl for TracksEditorTrackRow { + fn signals() -> &'static [Signal] { + static SIGNALS: Lazy> = + Lazy::new(|| vec![Signal::builder("remove").build()]); + + SIGNALS.as_ref() + } + } + + impl WidgetImpl for TracksEditorTrackRow {} + impl ListBoxRowImpl for TracksEditorTrackRow {} + impl PreferencesRowImpl for TracksEditorTrackRow {} + impl ActionRowImpl for TracksEditorTrackRow {} +} + +glib::wrapper! { + pub struct TracksEditorTrackRow(ObjectSubclass) + @extends gtk::Widget, gtk::ListBoxRow, adw::PreferencesRow, adw::ActionRow; +} + +#[gtk::template_callbacks] +impl TracksEditorTrackRow { + pub fn new( + navigation: &adw::NavigationView, + library: &MusicusLibrary, + track_data: TracksEditorTrackData, + ) -> Self { + let obj: Self = glib::Object::builder() + .property("navigation", navigation) + .property("library", library) + .build(); + + obj.set_subtitle(&match &track_data.path { + PathType::None => String::new(), + PathType::Library(path) => path.to_owned(), + PathType::System(path) => { + let format_string = gettext("Import from {}"); + let file_name = path.file_name().unwrap().to_str().unwrap(); + match formatx!(&format_string, file_name) { + Ok(title) => title, + Err(_) => { + log::error!("Error in translated format string: {format_string}"); + file_name.to_owned() + } + } + } + }); + + obj.set_works(&track_data.works); + obj.imp().track_data.replace(track_data); + + obj + } + + pub fn connect_remove(&self, f: F) -> glib::SignalHandlerId { + self.connect_local("remove", true, move |values| { + let obj = values[0].get::().unwrap(); + f(&obj); + None + }) + } + + pub fn track_data(&self) -> TracksEditorTrackData { + self.imp().track_data.borrow().to_owned() + } + + #[template_callback] + fn select_parts(&self) { + // self.imp().parts_popover.get().unwrap().popup(); + } + + #[template_callback] + fn remove(&self) { + self.emit_by_name::<()>("remove", &[]); + } + + fn set_works(&self, works: &[Work]) { + self.set_title( + &works + .iter() + .map(|w| w.name.get()) + .collect::>() + .join(", "), + ); + } +} + +#[derive(Clone, Default, Debug)] +pub struct TracksEditorTrackData { + pub track_id: Option, + pub path: PathType, + pub works: Vec, +} + +#[derive(Clone, Default, Debug)] +pub enum PathType { + #[default] + None, + Library(String), + System(PathBuf), +} diff --git a/src/library.rs b/src/library.rs index d4e1d8d..a74be8c 100644 --- a/src/library.rs +++ b/src/library.rs @@ -115,7 +115,7 @@ impl MusicusLibrary { .collect::>>()?; let works: Vec = works::table - .inner_join(work_persons::table.inner_join(persons::table)) + .left_join(work_persons::table.inner_join(persons::table)) .filter(works::name.like(&search).or(persons::name.like(&search))) .limit(9) .select(works::all_columns) @@ -225,7 +225,7 @@ impl MusicusLibrary { let recordings = recordings::table .inner_join( - works::table.inner_join(work_persons::table.inner_join(persons::table)), + works::table.left_join(work_persons::table.inner_join(persons::table)), ) // .inner_join(recording_persons::table.inner_join(persons::table)) .inner_join(recording_ensembles::table) @@ -287,7 +287,7 @@ impl MusicusLibrary { let recordings = recordings::table .inner_join( - works::table.inner_join(work_persons::table.inner_join(persons::table)), + works::table.left_join(work_persons::table.inner_join(persons::table)), ) .inner_join(recording_persons::table) .filter( @@ -400,10 +400,10 @@ impl MusicusLibrary { let connection = &mut *binding.as_mut().unwrap(); let mut query = recordings::table - .inner_join(works::table.inner_join(work_persons::table)) - .inner_join(recording_persons::table) - .inner_join(recording_ensembles::table) - .inner_join(album_recordings::table) + .inner_join(works::table.left_join(work_persons::table)) + .left_join(recording_persons::table) + .left_join(recording_ensembles::table) + .left_join(album_recordings::table) .into_boxed(); if let Some(composer_id) = program.composer_id() { @@ -556,7 +556,7 @@ impl MusicusLibrary { let connection = &mut *binding.as_mut().unwrap(); let works: Vec = works::table - .inner_join(work_persons::table) + .left_join(work_persons::table) .filter( works::name .like(&search) @@ -573,6 +573,32 @@ impl MusicusLibrary { Ok(works) } + pub fn search_recordings(&self, work: &Work, search: &str) -> Result> { + let search = format!("%{}%", search); + let mut binding = self.imp().connection.borrow_mut(); + let connection = &mut *binding.as_mut().unwrap(); + + let recordings = recordings::table + .left_join(recording_persons::table.inner_join(persons::table)) + .left_join(recording_ensembles::table.inner_join(ensembles::table)) + .filter( + recordings::work_id.eq(&work.work_id).and( + persons::name + .like(&search) + .or(ensembles::name.like(&search)), + ), + ) + .limit(9) + .select(recordings::all_columns) + .distinct() + .load::(connection)? + .into_iter() + .map(|r| Recording::from_table(r, connection)) + .collect::>>()?; + + Ok(recordings) + } + pub fn all_works(&self) -> Result> { let mut binding = self.imp().connection.borrow_mut(); let connection = &mut *binding.as_mut().unwrap(); @@ -1259,4 +1285,4 @@ impl LibraryResults { && self.recordings.is_empty() && self.albums.is_empty() } -} \ No newline at end of file +} diff --git a/src/window.rs b/src/window.rs index 4aad684..0ae3a8a 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1,7 +1,7 @@ use crate::{ - config, home_page::MusicusHomePage, library::MusicusLibrary, library_manager::LibraryManager, - player::MusicusPlayer, player_bar::PlayerBar, playlist_page::MusicusPlaylistPage, - welcome_page::MusicusWelcomePage, + config, editor::tracks_editor::TracksEditor, home_page::MusicusHomePage, + library::MusicusLibrary, library_manager::LibraryManager, player::MusicusPlayer, + player_bar::PlayerBar, playlist_page::MusicusPlaylistPage, welcome_page::MusicusWelcomePage, }; use adw::subclass::prelude::*; @@ -15,8 +15,8 @@ mod imp { #[derive(Debug, Default, gtk::CompositeTemplate)] #[template(file = "data/ui/window.blp")] pub struct MusicusWindow { + pub library: RefCell>, pub player: MusicusPlayer, - pub library_manager: RefCell>, #[template_child] pub stack: TemplateChild, @@ -52,14 +52,29 @@ mod imp { self.obj().add_css_class("devel"); } - let navigation_view = self.navigation_view.get().to_owned(); - let library_action = gio::ActionEntry::builder("library") - .activate(move |_: &super::MusicusWindow, _, _| { - navigation_view.push_by_tag("library") + let obj = self.obj().to_owned(); + let import_action = gio::ActionEntry::builder("import") + .activate(move |_, _, _| { + if let Some(library) = &*obj.imp().library.borrow() { + let editor = TracksEditor::new(&obj.imp().navigation_view, library, None); + obj.imp().navigation_view.push(&editor); + } }) .build(); - self.obj().add_action_entries([library_action]); + let obj = self.obj().to_owned(); + let library_action = gio::ActionEntry::builder("library") + .activate(move |_, _, _| { + if let Some(library) = &*obj.imp().library.borrow() { + let library_manager = + LibraryManager::new(&obj.imp().navigation_view, library); + obj.imp().navigation_view.push(&library_manager); + } + }) + .build(); + + self.obj() + .add_action_entries([import_action, library_action]); let player_bar = PlayerBar::new(&self.player); self.player_bar_revealer.set_child(Some(&player_bar)); @@ -174,16 +189,9 @@ impl MusicusWindow { self.imp().player.set_library(&library); let navigation = self.imp().navigation_view.get(); - if let Some(library_manager) = self.imp().library_manager.take() { - navigation.remove(&library_manager); - } - - let library_manager = LibraryManager::new(&navigation, &library); - navigation .replace(&[MusicusHomePage::new(&navigation, &library, &self.imp().player).into()]); - navigation.add(&library_manager); - self.imp().library_manager.replace(Some(library_manager)); + self.imp().library.replace(Some(library)); } }