editor: Add album editor

This commit is contained in:
Elias Projahn 2025-02-22 16:07:30 +01:00
parent c4162fd98a
commit a16dc446d6
8 changed files with 638 additions and 116 deletions

78
data/ui/album_editor.blp Normal file
View file

@ -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;
}
}
}
}
}
}

View file

@ -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"
]
}
}
}
}
}

View file

@ -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<Work>,
}
#[derive(Boxed, Clone, Debug)]
#[boxed_type(name = "MusicusAlbum")]
pub struct Album {
pub album_id: String,
pub name: TranslatedString,
pub recordings: Vec<Recording>,
}
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<Self> {
let recordings: Vec<Recording> = 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::<Result<Vec<Recording>>>()?;
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)
}
}

208
src/editor/album_editor.rs Normal file
View file

@ -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<adw::NavigationView>,
#[property(get, construct_only)]
pub library: OnceCell<MusicusLibrary>,
pub album_id: OnceCell<String>,
pub recordings: RefCell<Vec<Recording>>,
pub recordings_popover: OnceCell<RecordingSelectorPopover>,
#[template_child]
pub name_editor: TemplateChild<MusicusTranslationEditor>,
#[template_child]
pub recordings_list: TemplateChild<gtk::ListBox>,
#[template_child]
pub select_recording_box: TemplateChild<gtk::Box>,
#[template_child]
pub save_row: TemplateChild<adw::ButtonRow>,
}
#[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<Self>) {
obj.init_template();
}
}
#[glib::derived_properties]
impl ObjectImpl for AlbumEditor {
fn signals() -> &'static [Signal] {
static SIGNALS: Lazy<Vec<Signal>> = 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<imp::AlbumEditor>)
@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<F: Fn(&Self, Album) + 'static>(&self, f: F) -> glib::SignalHandlerId {
self.connect_local("created", true, move |values| {
let obj = values[0].get::<Self>().unwrap();
let album = values[1].get::<Album>().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();
}
}

View file

@ -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;

View file

@ -131,10 +131,13 @@ impl MusicusLibrary {
.map(|w| Work::from_table(w, connection))
.collect::<Result<Vec<Work>>>()?;
let albums: Vec<Album> = albums::table
let albums = albums::table
.filter(albums::name.like(&search))
.limit(9)
.load(connection)?;
.load::<tables::Album>(connection)?
.into_iter()
.map(|a| Album::from_table(a, connection))
.collect::<Result<Vec<Album>>>()?;
LibraryResults {
composers,
@ -259,7 +262,10 @@ impl MusicusLibrary {
)
.select(albums::all_columns)
.distinct()
.load(connection)?;
.load::<tables::Album>(connection)?
.into_iter()
.map(|a| Album::from_table(a, connection))
.collect::<Result<Vec<Album>>>()?;
LibraryResults {
composers,
@ -320,7 +326,10 @@ impl MusicusLibrary {
)
.select(albums::all_columns)
.distinct()
.load(connection)?;
.load::<tables::Album>(connection)?
.into_iter()
.map(|a| Album::from_table(a, connection))
.collect::<Result<Vec<Album>>>()?;
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::<Album>(connection)?;
let albums = albums::table
.load::<tables::Album>(connection)?
.into_iter()
.map(|a| Album::from_table(a, connection))
.collect::<Result<Vec<Album>>>()?;
Ok(albums)
}
@ -1234,8 +1247,92 @@ impl MusicusLibrary {
Ok(())
}
pub fn create_album(
&self,
name: TranslatedString,
recordings: Vec<Recording>,
) -> Result<Album> {
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<Recording>,
) -> 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<Path>,

View file

@ -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<adw::NavigationView>,
pub library: OnceCell<MusicusLibrary>,
pub albums: RefCell<Vec<Album>>,
pub albums_filtered: RefCell<Vec<Album>>,
#[template_child]
pub search_entry: TemplateChild<gtk::SearchEntry>,
#[template_child]
pub list: TemplateChild<gtk::ListBox>,
}
#[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<Self>) {
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<imp::AlbumsPage>)
@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::<Vec<Album>>();
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,
));
}
}

View file

@ -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,
// // ));
// }
}