diff --git a/data/res/style.css b/data/res/style.css index 8f363d2..5190551 100644 --- a/data/res/style.css +++ b/data/res/style.css @@ -34,4 +34,24 @@ margin-top: 3px; margin-bottom: 3px; font-size: smaller; +} + +.playlist { + background-color: rgba(0, 0, 0, 0); +} + +.playlist > row { + border-radius: 12px; +} + +.playlisttile .title { + font-weight: bold; +} + +.playlisttile .subtitle { + font-size: smaller; +} + +.playlisttile .parttitle { + font-size: smaller; } \ No newline at end of file diff --git a/data/ui/playlist_page.blp b/data/ui/playlist_page.blp index 60a0033..636ccdd 100644 --- a/data/ui/playlist_page.blp +++ b/data/ui/playlist_page.blp @@ -22,6 +22,15 @@ template $MusicusPlaylistPage : Adw.Bin { Adw.Clamp { maximum-size: 1000; tightening-threshold: 600; + + Gtk.ListView playlist { + styles ["playlist", "background"] + margin-top: 12; + margin-bottom: 12; + margin-start: 12; + margin-end: 12; + single-click-activate: true; + } } } } diff --git a/data/ui/playlist_tile.blp b/data/ui/playlist_tile.blp new file mode 100644 index 0000000..559b2f4 --- /dev/null +++ b/data/ui/playlist_tile.blp @@ -0,0 +1,45 @@ +using Gtk 4.0; +using Adw 1; + +template $MusicusPlaylistTile : Gtk.Box { + styles ["playlisttile"] + + Adw.Bin { + width-request: 48; + + Gtk.Image playing_icon { + visible: false; + icon-name: "media-playback-start-symbolic"; + } + } + + Gtk.Box { + margin-end: 12; + orientation: vertical; + + Gtk.Label title_label { + styles ["title"] + visible: false; + margin-top: 24; + halign: start; + wrap: true; + } + + Gtk.Label performances_label { + styles ["subtitle", "dim-label"] + visible: false; + halign: start; + wrap: true; + } + + Gtk.Label part_title_label { + styles ["parttitle"] + margin-top: 12; + margin-bottom: 12; + visible: false; + margin-start: 24; + halign: start; + wrap: true; + } + } +} \ No newline at end of file diff --git a/data/ui/window.blp b/data/ui/window.blp index d429fa4..00aa05d 100644 --- a/data/ui/window.blp +++ b/data/ui/window.blp @@ -16,13 +16,6 @@ template $MusicusWindow : Adw.ApplicationWindow { } }; } - - Gtk.StackPage { - name: "playlist"; - child: $MusicusPlaylistPage { - close => $hide_playlist() swapped; - }; - } } [bottom] diff --git a/po/POTFILES b/po/POTFILES index de11f14..ef5adb1 100644 --- a/po/POTFILES +++ b/po/POTFILES @@ -1,5 +1,6 @@ data/ui/home_page.blp data/ui/playlist_page.blp +data/ui/playlist_tile.blp data/ui/recording_tile.blp data/ui/search_entry.blp data/ui/search_tag.blp diff --git a/src/home_page.rs b/src/home_page.rs index ddea96b..5245800 100644 --- a/src/home_page.rs +++ b/src/home_page.rs @@ -1,6 +1,7 @@ use crate::{ - library::{Ensemble, LibraryQuery, MusicusLibrary, Person, Recording, Work}, + library::{Ensemble, LibraryQuery, MusicusLibrary, Person, Recording, Track, Work}, player::MusicusPlayer, + playlist_item::PlaylistItem, recording_tile::MusicusRecordingTile, search_entry::MusicusSearchEntry, search_tag::Tag, @@ -158,7 +159,62 @@ impl MusicusHomePage { } fn play_recording(&self, recording: &Recording) { - log::info!("Play recording: {:?}", recording) + let tracks = self.library().tracks(recording); + + if tracks.is_empty() { + log::warn!("Ignoring recording without tracks being added to the playlist."); + return; + } + + let title = format!( + "{}: {}", + recording.work.composer.name_fl(), + recording.work.title + ); + + let performances = self.library().performances(recording); + let performances = if performances.is_empty() { + None + } else { + Some(performances.join(", ")) + }; + + let mut items = Vec::new(); + + if tracks.len() == 1 { + items.push(PlaylistItem::new( + &title, + performances.as_ref().map(|x| x.as_str()), + None, + &tracks[0].path, + )) + } else { + let work_parts = self.library().work_parts(&recording.work); + let mut tracks = tracks.into_iter(); + let first_track = tracks.next().unwrap(); + + let track_title = |track: &Track| -> String { + track + .work_parts + .iter() + .map(|w| work_parts[*w].clone()) + .collect::>() + .join(", ") + }; + + items.push(PlaylistItem::new( + &title, + performances.as_ref().map(|x| x.as_str()), + Some(&track_title(&first_track)), + &first_track.path, + )); + + while let Some(track) = tracks.next() { + items.push(PlaylistItem::new_part(&track_title(&track), &track.path)); + } + } + + self.player().append(items); } fn query(&self, query: &LibraryQuery) { diff --git a/src/library.rs b/src/library.rs index 35c04b3..02cd17f 100644 --- a/src/library.rs +++ b/src/library.rs @@ -2,6 +2,7 @@ use gtk::{glib, glib::Properties, prelude::*, subclass::prelude::*}; use rusqlite::{Connection, Row}; use std::{ cell::OnceCell, + num::ParseIntError, path::{Path, PathBuf}, }; @@ -257,7 +258,51 @@ impl MusicusLibrary { } } - pub fn performances(&self, recording: &Recording) -> Vec { + pub fn work_parts(&self, work: &Work) -> Vec { + self.con() + .prepare("SELECT * FROM work_parts WHERE work IS ?1 ORDER BY part_index") + .unwrap() + .query_map([&work.id], |row| row.get::<_, String>(3)) + .unwrap() + .collect::>>() + .unwrap() + } + + pub fn tracks(&self, recording: &Recording) -> Vec { + self.con() + .prepare("SELECT * FROM tracks WHERE recording IS ?1 ORDER BY \"index\"") + .unwrap() + .query_map([&recording.id], |row| { + Ok(Track { + work_parts: row + .get::<_, String>(4)? + .split(',') + .filter(|s| !s.is_empty()) + .map(|s| str::parse::(s)) + .collect::, ParseIntError>>() + .expect("work part IDs should be valid integers"), + path: PathBuf::from(self.folder()).join(row.get::<_, String>(6)?), + }) + }) + .unwrap() + .collect::>>() + .unwrap() + } + + pub fn random_recording(&self, query: &LibraryQuery) -> Option { + match query { + LibraryQuery { .. } => self + .con() + .prepare("SELECT * FROM recordings ORDER BY RANDOM() LIMIT 1") + .unwrap() + .query_map([], Recording::from_row) + .unwrap() + .next() + .map(|r| r.unwrap()), + } + } + + pub fn performances(&self, recording: &Recording) -> Vec { let mut performances = self .con() .prepare("SELECT persons.id, persons.first_name, persons.last_name, instruments.id, instruments.name FROM performances INNER JOIN persons ON persons.id = performances.person LEFT JOIN instruments ON instruments.id = performances.role INNER JOIN recordings ON performances.recording = recordings.id WHERE recordings.id IS ?1") @@ -277,6 +322,24 @@ impl MusicusLibrary { .unwrap()); performances + .into_iter() + .map(|performance| match performance { + Performance::Person(person, role) => { + let mut result = person.name_fl(); + if let Some(role) = role { + result.push_str(&format!(" ({})", role.name)); + } + result + } + Performance::Ensemble(ensemble, role) => { + let mut result = ensemble.name; + if let Some(role) = role { + result.push_str(&format!(" ({})", role.name)); + } + result + } + }) + .collect::>() } fn con(&self) -> &Connection { @@ -472,3 +535,9 @@ impl PartialEq for Role { self.id == other.id } } + +#[derive(Debug, Clone)] +pub struct Track { + pub work_parts: Vec, + pub path: PathBuf, +} diff --git a/src/main.rs b/src/main.rs index e224381..a63cbf7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,7 +3,9 @@ mod config; mod home_page; mod library; mod player; +mod playlist_item; mod playlist_page; +mod playlist_tile; mod recording_tile; mod search_entry; mod search_tag; diff --git a/src/player.rs b/src/player.rs index 1c5e763..00a5ae0 100644 --- a/src/player.rs +++ b/src/player.rs @@ -1,5 +1,6 @@ -use gtk::{glib, glib::Properties, prelude::*, subclass::prelude::*}; -use std::cell::Cell; +use crate::playlist_item::PlaylistItem; +use gtk::{gio, glib, glib::Properties, prelude::*, subclass::prelude::*}; +use std::cell::{Cell, OnceCell}; mod imp { use super::*; @@ -11,6 +12,10 @@ mod imp { pub active: Cell, #[property(get, set)] pub playing: Cell, + #[property(get, construct_only)] + pub playlist: OnceCell, + #[property(get, set)] + pub current_index: Cell, } #[glib::object_subclass] @@ -29,19 +34,30 @@ glib::wrapper! { impl MusicusPlayer { pub fn new() -> Self { - glib::Object::new() + glib::Object::builder() + .property("active", false) + .property("playing", false) + .property("playlist", gio::ListStore::new::()) + .property("current-index", 0u32) + .build() + } + + pub fn append(&self, tracks: Vec) { + let playlist = self.playlist(); + + for track in tracks { + playlist.append(&track); + } + + self.set_active(true); } pub fn play(&self) { - if !self.imp().active.get() { - self.set_property("active", true); - } - - self.set_property("playing", true); + self.set_playing(true) } pub fn pause(&self) { - self.set_property("playing", false); + self.set_playing(false) } } diff --git a/src/playlist_item.rs b/src/playlist_item.rs new file mode 100644 index 0000000..762f8fb --- /dev/null +++ b/src/playlist_item.rs @@ -0,0 +1,66 @@ +use gtk::{glib, glib::Properties, prelude::*, subclass::prelude::*}; +use std::{ + cell::OnceCell, + path::{Path, PathBuf}, +}; + +mod imp { + use super::*; + + #[derive(Properties, Default)] + #[properties(wrapper_type = super::PlaylistItem)] + pub struct PlaylistItem { + #[property(get, construct_only)] + pub is_title: OnceCell, + + #[property(get, construct_only, nullable)] + pub title: OnceCell>, + + #[property(get, construct_only, nullable)] + pub performers: OnceCell>, + + #[property(get, construct_only, nullable)] + pub part_title: OnceCell>, + + #[property(get, construct_only)] + pub path: OnceCell, + } + + #[glib::object_subclass] + impl ObjectSubclass for PlaylistItem { + const NAME: &'static str = "MusicusPlaylistItem"; + type Type = super::PlaylistItem; + } + + #[glib::derived_properties] + impl ObjectImpl for PlaylistItem {} +} + +glib::wrapper! { + pub struct PlaylistItem(ObjectSubclass); +} + +impl PlaylistItem { + pub fn new( + title: &str, + performers: Option<&str>, + part_title: Option<&str>, + path: impl AsRef, + ) -> Self { + glib::Object::builder() + .property("is-title", true) + .property("title", title) + .property("performers", performers) + .property("part-title", part_title) + .property("path", path.as_ref()) + .build() + } + + pub fn new_part(part_title: &str, path: impl AsRef) -> Self { + glib::Object::builder() + .property("is-title", false) + .property("part-title", part_title) + .property("path", path.as_ref()) + .build() + } +} diff --git a/src/playlist_page.rs b/src/playlist_page.rs index 6a9ac66..fe34f1b 100644 --- a/src/playlist_page.rs +++ b/src/playlist_page.rs @@ -1,13 +1,24 @@ +use crate::{player::MusicusPlayer, playlist_tile::PlaylistTile}; use adw::subclass::prelude::*; -use gtk::{glib, glib::subclass::Signal, prelude::*}; +use gtk::{glib, glib::subclass::Signal, glib::Properties, prelude::*}; use once_cell::sync::Lazy; +use std::cell::OnceCell; mod imp { + use crate::playlist_item::PlaylistItem; + use super::*; - #[derive(Debug, Default, gtk::CompositeTemplate)] + #[derive(Properties, Debug, Default, gtk::CompositeTemplate)] + #[properties(wrapper_type = super::MusicusPlayer)] #[template(file = "data/ui/playlist_page.blp")] - pub struct MusicusPlaylistPage {} + pub struct MusicusPlaylistPage { + #[property(get, construct_only)] + pub player: OnceCell, + + #[template_child] + pub playlist: TemplateChild, + } #[glib::object_subclass] impl ObjectSubclass for MusicusPlaylistPage { @@ -25,6 +36,7 @@ mod imp { } } + #[glib::derived_properties] impl ObjectImpl for MusicusPlaylistPage { fn signals() -> &'static [Signal] { static SIGNALS: Lazy> = @@ -32,6 +44,30 @@ mod imp { SIGNALS.as_ref() } + + fn constructed(&self) { + self.parent_constructed(); + + self.playlist.set_model(Some(>k::NoSelection::new(Some( + self.player.get().unwrap().playlist(), + )))); + + let factory = gtk::SignalListItemFactory::new(); + + factory.connect_setup(|_, item| { + let item = item.downcast_ref::().unwrap(); + item.set_child(Some(&PlaylistTile::new())); + }); + + factory.connect_bind(|_, item| { + let item = item.downcast_ref::().unwrap(); + let tile = item.child().and_downcast::().unwrap(); + let playlist_item = item.item().and_downcast::().unwrap(); + tile.set_item(&playlist_item); + }); + + self.playlist.set_factory(Some(&factory)); + } } impl WidgetImpl for MusicusPlaylistPage {} @@ -45,8 +81,16 @@ glib::wrapper! { #[gtk::template_callbacks] impl MusicusPlaylistPage { - pub fn new() -> Self { - glib::Object::new() + pub fn new(player: &MusicusPlayer) -> Self { + glib::Object::builder().property("player", player).build() + } + + pub fn connect_close(&self, f: F) -> glib::SignalHandlerId { + self.connect_local("close", true, move |values| { + let obj = values[0].get::().unwrap(); + f(&obj); + None + }) } #[template_callback] diff --git a/src/playlist_tile.rs b/src/playlist_tile.rs new file mode 100644 index 0000000..411a643 --- /dev/null +++ b/src/playlist_tile.rs @@ -0,0 +1,74 @@ +use crate::playlist_item::PlaylistItem; +use gtk::{glib, prelude::*, subclass::prelude::*}; + +mod imp { + use super::*; + + #[derive(Debug, Default, gtk::CompositeTemplate)] + #[template(file = "data/ui/playlist_tile.blp")] + pub struct PlaylistTile { + #[template_child] + pub playing_icon: TemplateChild, + #[template_child] + pub title_label: TemplateChild, + #[template_child] + pub performances_label: TemplateChild, + #[template_child] + pub part_title_label: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for PlaylistTile { + const NAME: &'static str = "MusicusPlaylistTile"; + type Type = super::PlaylistTile; + type ParentType = gtk::Box; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for PlaylistTile {} + impl WidgetImpl for PlaylistTile {} + impl BoxImpl for PlaylistTile {} +} + +glib::wrapper! { + pub struct PlaylistTile(ObjectSubclass) + @extends gtk::Widget, gtk::FlowBoxChild; +} + +impl PlaylistTile { + pub fn new() -> Self { + glib::Object::new() + } + + pub fn set_item(&self, item: &PlaylistItem) { + let imp = self.imp(); + + if let Some(title) = item.title() { + imp.title_label.set_label(&title); + imp.title_label.set_visible(true); + } + + if let Some(performances) = item.performers() { + imp.performances_label.set_label(&performances); + imp.performances_label.set_visible(true); + } + + if let Some(part_title) = item.part_title() { + imp.part_title_label.set_label(&part_title); + imp.part_title_label.set_visible(true); + } else { + imp.obj().set_margin_bottom(24); + } + } + + pub fn set_playing(&self, playing: bool) { + self.imp().playing_icon.set_visible(playing); + } +} diff --git a/src/recording_tile.rs b/src/recording_tile.rs index 6c05f09..fff874d 100644 --- a/src/recording_tile.rs +++ b/src/recording_tile.rs @@ -1,4 +1,4 @@ -use crate::library::{Performance, Recording}; +use crate::library::Recording; use gtk::{glib, subclass::prelude::*}; use std::cell::OnceCell; @@ -44,37 +44,14 @@ glib::wrapper! { } impl MusicusRecordingTile { - pub fn new(recording: &Recording, performances: Vec) -> Self { + pub fn new(recording: &Recording, performances: Vec) -> Self { let obj: Self = glib::Object::new(); let imp = obj.imp(); imp.work_label.set_label(&recording.work.title); imp.composer_label .set_label(&recording.work.composer.name_fl()); - - imp.performances_label.set_label( - &performances - .into_iter() - .map(|performance| match performance { - Performance::Person(person, role) => { - let mut result = person.name_fl(); - if let Some(role) = role { - result.push_str(&format!(" ({})", role.name)); - } - result - } - Performance::Ensemble(ensemble, role) => { - let mut result = ensemble.name; - if let Some(role) = role { - result.push_str(&format!(" ({})", role.name)); - } - result - } - }) - .collect::>() - .join(", "), - ); - + imp.performances_label.set_label(&performances.join(", ")); imp.recording.set(recording.clone()).unwrap(); obj diff --git a/src/window.rs b/src/window.rs index 848c355..164c454 100644 --- a/src/window.rs +++ b/src/window.rs @@ -33,8 +33,6 @@ mod imp { type ParentType = adw::ApplicationWindow; fn class_init(klass: &mut Self::Class) { - MusicusHomePage::static_type(); - MusicusPlaylistPage::static_type(); MusicusWelcomePage::static_type(); klass.bind_template(); klass.bind_template_instance_callbacks(); @@ -73,6 +71,14 @@ mod imp { player.play(); } })); + + let playlist_page = MusicusPlaylistPage::new(&self.player); + let playlist_button = self.playlist_button.get(); + playlist_page.connect_close(move |_| { + playlist_button.set_active(false); + }); + + self.stack.add_named(&playlist_page, Some("playlist")); } } @@ -142,9 +148,4 @@ impl MusicusWindow { "navigation" }); } - - #[template_callback] - fn hide_playlist(&self, _: &MusicusPlaylistPage) { - self.imp().playlist_button.set_active(false); - } }