Add playlist view

This commit is contained in:
Elias Projahn 2023-10-25 17:45:32 +02:00
parent 16d1408194
commit 7d21617e9a
14 changed files with 430 additions and 57 deletions

View file

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

View file

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

45
data/ui/playlist_tile.blp Normal file
View file

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

View file

@ -16,13 +16,6 @@ template $MusicusWindow : Adw.ApplicationWindow {
}
};
}
Gtk.StackPage {
name: "playlist";
child: $MusicusPlaylistPage {
close => $hide_playlist() swapped;
};
}
}
[bottom]

View file

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

View file

@ -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::<Vec<String>>()
.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) {

View file

@ -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<Performance> {
pub fn work_parts(&self, work: &Work) -> Vec<String> {
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::<rusqlite::Result<Vec<String>>>()
.unwrap()
}
pub fn tracks(&self, recording: &Recording) -> Vec<Track> {
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::<usize>(s))
.collect::<Result<Vec<usize>, ParseIntError>>()
.expect("work part IDs should be valid integers"),
path: PathBuf::from(self.folder()).join(row.get::<_, String>(6)?),
})
})
.unwrap()
.collect::<rusqlite::Result<Vec<Track>>>()
.unwrap()
}
pub fn random_recording(&self, query: &LibraryQuery) -> Option<Recording> {
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<String> {
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::<Vec<String>>()
}
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<usize>,
pub path: PathBuf,
}

View file

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

View file

@ -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<bool>,
#[property(get, set)]
pub playing: Cell<bool>,
#[property(get, construct_only)]
pub playlist: OnceCell<gio::ListStore>,
#[property(get, set)]
pub current_index: Cell<u32>,
}
#[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::<PlaylistItem>())
.property("current-index", 0u32)
.build()
}
pub fn append(&self, tracks: Vec<PlaylistItem>) {
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)
}
}

66
src/playlist_item.rs Normal file
View file

@ -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<bool>,
#[property(get, construct_only, nullable)]
pub title: OnceCell<Option<String>>,
#[property(get, construct_only, nullable)]
pub performers: OnceCell<Option<String>>,
#[property(get, construct_only, nullable)]
pub part_title: OnceCell<Option<String>>,
#[property(get, construct_only)]
pub path: OnceCell<PathBuf>,
}
#[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<imp::PlaylistItem>);
}
impl PlaylistItem {
pub fn new(
title: &str,
performers: Option<&str>,
part_title: Option<&str>,
path: impl AsRef<Path>,
) -> 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<Path>) -> Self {
glib::Object::builder()
.property("is-title", false)
.property("part-title", part_title)
.property("path", path.as_ref())
.build()
}
}

View file

@ -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<MusicusPlayer>,
#[template_child]
pub playlist: TemplateChild<gtk::ListView>,
}
#[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<Vec<Signal>> =
@ -32,6 +44,30 @@ mod imp {
SIGNALS.as_ref()
}
fn constructed(&self) {
self.parent_constructed();
self.playlist.set_model(Some(&gtk::NoSelection::new(Some(
self.player.get().unwrap().playlist(),
))));
let factory = gtk::SignalListItemFactory::new();
factory.connect_setup(|_, item| {
let item = item.downcast_ref::<gtk::ListItem>().unwrap();
item.set_child(Some(&PlaylistTile::new()));
});
factory.connect_bind(|_, item| {
let item = item.downcast_ref::<gtk::ListItem>().unwrap();
let tile = item.child().and_downcast::<PlaylistTile>().unwrap();
let playlist_item = item.item().and_downcast::<PlaylistItem>().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<F: Fn(&Self) + 'static>(&self, f: F) -> glib::SignalHandlerId {
self.connect_local("close", true, move |values| {
let obj = values[0].get::<Self>().unwrap();
f(&obj);
None
})
}
#[template_callback]

74
src/playlist_tile.rs Normal file
View file

@ -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<gtk::Image>,
#[template_child]
pub title_label: TemplateChild<gtk::Label>,
#[template_child]
pub performances_label: TemplateChild<gtk::Label>,
#[template_child]
pub part_title_label: TemplateChild<gtk::Label>,
}
#[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<Self>) {
obj.init_template();
}
}
impl ObjectImpl for PlaylistTile {}
impl WidgetImpl for PlaylistTile {}
impl BoxImpl for PlaylistTile {}
}
glib::wrapper! {
pub struct PlaylistTile(ObjectSubclass<imp::PlaylistTile>)
@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);
}
}

View file

@ -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<Performance>) -> Self {
pub fn new(recording: &Recording, performances: Vec<String>) -> 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::<Vec<String>>()
.join(", "),
);
imp.performances_label.set_label(&performances.join(", "));
imp.recording.set(recording.clone()).unwrap();
obj

View file

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