New search page

This commit is contained in:
Elias Projahn 2025-03-02 08:03:31 +01:00
parent a6e0935df8
commit cff489f43e
12 changed files with 830 additions and 793 deletions

90
data/ui/album_page.blp Normal file
View file

@ -0,0 +1,90 @@
using Gtk 4.0;
using Adw 1;
template $MusicusAlbumPage: Adw.NavigationPage {
title: _("Album");
Adw.ToolbarView {
[top]
Adw.HeaderBar {
show-title: false;
}
Gtk.ScrolledWindow {
Adw.Clamp {
Gtk.Box {
orientation: vertical;
margin-bottom: 24;
margin-start: 12;
margin-end: 12;
Gtk.Box {
spacing: 12;
margin-top: 24;
Gtk.Box {
orientation: vertical;
hexpand: true;
Gtk.Label title_label {
wrap: true;
xalign: 0.0;
styles [
"title-1",
]
}
Gtk.Label subtitle_label {
wrap: true;
xalign: 0.0;
}
}
Gtk.Button {
icon-name: "document-edit-symbolic";
valign: center;
clicked => $edit_button_clicked() swapped;
styles [
"flat",
]
}
Gtk.Button {
icon-name: "media-playback-start-symbolic";
label: _("_Play album");
use-underline: true;
valign: center;
clicked => $play_button_clicked() swapped;
styles [
"pill",
"suggested-action",
]
}
}
Gtk.Label {
label: _("Recordings");
xalign: 0;
margin-top: 24;
styles [
"heading",
]
}
Gtk.FlowBox recordings_flow_box {
margin-top: 12;
column-spacing: 12;
row-spacing: 12;
homogeneous: true;
selection-mode: none;
child-activated => $recording_selected() swapped;
}
}
}
}
}
}

View file

@ -1,32 +0,0 @@
using Gtk 4.0;
template $MusicusSearchEntry : Gtk.Box {
styles ["searchbar"]
margin-start: 12;
margin-end: 12;
margin-top: 6;
margin-bottom: 6;
Gtk.Image {
icon-name: "system-search-symbolic";
}
Gtk.Box tags_box {
valign: center;
}
Gtk.Text text {
placeholder-text: _("Enter composers, performers, works…");
hexpand: true;
activate => $activate() swapped;
backspace => $backspace() swapped;
changed => $text_changed() swapped;
}
Gtk.Image clear_icon {
visible: false;
icon-name: "edit-clear-symbolic";
tooltip-text: _("Clear entry");
}
}

View file

@ -1,98 +1,91 @@
using Gtk 4.0; using Gtk 4.0;
using Adw 1; using Adw 1;
template $MusicusHomePage: Adw.NavigationPage { template $MusicusSearchPage: Adw.NavigationPage {
title: _("Musicus"); title: _("Musicus");
tag: "home";
Gtk.Overlay { Adw.ToolbarView {
Adw.ToolbarView { [top]
[top] Adw.HeaderBar header_bar {
Adw.HeaderBar header_bar { [end]
[end] MenuButton {
MenuButton { icon-name: "open-menu-symbolic";
icon-name: "open-menu-symbolic"; menu-model: primary_menu;
menu-model: primary_menu;
}
} }
}
[top] Gtk.ScrolledWindow scrolled_window {
Adw.Clamp { Adw.Clamp {
maximum-size: 1000; maximum-size: 1000;
tightening-threshold: 600; tightening-threshold: 600;
Gtk.Box { Gtk.Box {
orientation: vertical; orientation: vertical;
margin-bottom: 24;
$MusicusSearchEntry search_entry { margin-start: 12;
activate => $select() swapped; margin-end: 12;
}
Gtk.Box header_box { Gtk.Box header_box {
visible: false; visible: false;
spacing: 12; spacing: 12;
margin-start: 12;
margin-end: 12;
margin-top: 24; margin-top: 24;
margin-bottom: 12;
Gtk.Button {
styles [
"flat"
]
valign: center;
icon-name: "go-previous-symbolic";
clicked => $back_button_clicked() swapped;
}
Gtk.Box { Gtk.Box {
orientation: vertical; orientation: vertical;
hexpand: true; hexpand: true;
Gtk.Label title_label { Gtk.Label title_label {
styles [ wrap: true;
"title-1"
]
xalign: 0.0; xalign: 0.0;
styles [
"title-1",
]
} }
Gtk.Label subtitle_label { Gtk.Label subtitle_label {
wrap: true;
xalign: 0.0; xalign: 0.0;
} }
} }
Gtk.Button { Gtk.Button {
styles [
"flat"
]
valign: center;
icon-name: "document-edit-symbolic"; icon-name: "document-edit-symbolic";
valign: center;
clicked => $edit_button_clicked() swapped; clicked => $edit_button_clicked() swapped;
styles [
"flat",
]
}
Gtk.Button {
icon-name: "media-playback-start-symbolic";
label: _("_Play");
use-underline: true;
valign: center;
clicked => $play_button_clicked() swapped;
styles [
"pill",
"suggested-action",
]
} }
} }
}
}
Gtk.Stack stack { Gtk.SearchEntry search_entry {
Gtk.StackPage { placeholder-text: _("Enter composers, performers, works…");
name: "results"; margin-top: 24;
activate => $select() swapped;
}
child: Gtk.ScrolledWindow { Gtk.Stack stack {
hscrollbar-policy: never; Gtk.StackPage {
name: "results";
Adw.Clamp { child: Gtk.Box {
maximum-size: 1000;
tightening-threshold: 600;
Gtk.Box {
orientation: vertical; orientation: vertical;
margin-start: 12;
margin-end: 12;
margin-top: 24; margin-top: 24;
margin-bottom: 68;
Gtk.FlowBox programs_flow_box { Gtk.FlowBox programs_flow_box {
margin-top: 12; margin-top: 12;
@ -106,7 +99,7 @@ template $MusicusHomePage: Adw.NavigationPage {
Gtk.Label { Gtk.Label {
styles [ styles [
"heading" "heading",
] ]
visible: bind composers_flow_box.visible; visible: bind composers_flow_box.visible;
@ -126,7 +119,7 @@ template $MusicusHomePage: Adw.NavigationPage {
Gtk.Label { Gtk.Label {
styles [ styles [
"heading" "heading",
] ]
visible: bind performers_flow_box.visible; visible: bind performers_flow_box.visible;
@ -146,7 +139,7 @@ template $MusicusHomePage: Adw.NavigationPage {
Gtk.Label { Gtk.Label {
styles [ styles [
"heading" "heading",
] ]
visible: bind ensembles_flow_box.visible; visible: bind ensembles_flow_box.visible;
@ -166,7 +159,7 @@ template $MusicusHomePage: Adw.NavigationPage {
Gtk.Label { Gtk.Label {
styles [ styles [
"heading" "heading",
] ]
visible: bind instruments_flow_box.visible; visible: bind instruments_flow_box.visible;
@ -186,7 +179,7 @@ template $MusicusHomePage: Adw.NavigationPage {
Gtk.Label { Gtk.Label {
styles [ styles [
"heading" "heading",
] ]
visible: bind works_flow_box.visible; visible: bind works_flow_box.visible;
@ -206,7 +199,7 @@ template $MusicusHomePage: Adw.NavigationPage {
Gtk.Label { Gtk.Label {
styles [ styles [
"heading" "heading",
] ]
visible: bind recordings_flow_box.visible; visible: bind recordings_flow_box.visible;
@ -226,7 +219,7 @@ template $MusicusHomePage: Adw.NavigationPage {
Gtk.Label { Gtk.Label {
styles [ styles [
"heading" "heading",
] ]
visible: bind albums_flow_box.visible; visible: bind albums_flow_box.visible;
@ -243,37 +236,22 @@ template $MusicusHomePage: Adw.NavigationPage {
selection-mode: none; selection-mode: none;
child-activated => $album_selected() swapped; child-activated => $album_selected() swapped;
} }
} };
} }
};
}
Gtk.StackPage { Gtk.StackPage {
name: "empty"; name: "empty";
child: Adw.StatusPage { child: Adw.StatusPage {
icon-name: "system-search-symbolic"; icon-name: "system-search-symbolic";
title: _("Nothing Found"); title: _("Nothing Found");
description: _("Try a different search."); description: _("Try a different search.");
}; };
}
}
} }
} }
} }
[overlay]
Gtk.Button play_button {
styles [
"pill",
"suggested-action"
]
halign: end;
valign: end;
margin-end: 24;
margin-bottom: 24;
label: _("Play music");
clicked => $play() swapped;
}
} }
} }

132
src/album_page.rs Normal file
View file

@ -0,0 +1,132 @@
use std::cell::OnceCell;
use adw::subclass::prelude::*;
use gtk::{
glib::{self, Properties},
prelude::*,
};
use crate::{
db::models::*, editor::album::AlbumEditor, library::Library, player::Player,
playlist_item::PlaylistItem, recording_tile::RecordingTile,
};
mod imp {
use super::*;
#[derive(Properties, Debug, Default, gtk::CompositeTemplate)]
#[properties(wrapper_type = super::AlbumPage)]
#[template(file = "data/ui/album_page.blp")]
pub struct AlbumPage {
#[property(get, construct_only)]
pub navigation: OnceCell<adw::NavigationView>,
#[property(get, construct_only)]
pub library: OnceCell<Library>,
#[property(get, construct_only)]
pub player: OnceCell<Player>,
pub album: OnceCell<Album>,
#[template_child]
pub title_label: TemplateChild<gtk::Label>,
#[template_child]
pub subtitle_label: TemplateChild<gtk::Label>,
#[template_child]
pub recordings_flow_box: TemplateChild<gtk::FlowBox>,
}
#[glib::object_subclass]
impl ObjectSubclass for AlbumPage {
const NAME: &'static str = "MusicusAlbumPage";
type Type = super::AlbumPage;
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();
}
}
#[glib::derived_properties]
impl ObjectImpl for AlbumPage {
fn constructed(&self) {
self.parent_constructed();
}
}
impl WidgetImpl for AlbumPage {}
impl NavigationPageImpl for AlbumPage {}
}
glib::wrapper! {
pub struct AlbumPage(ObjectSubclass<imp::AlbumPage>)
@extends gtk::Widget, adw::NavigationPage;
}
#[gtk::template_callbacks]
impl AlbumPage {
pub fn new(
navigation: &adw::NavigationView,
library: &Library,
player: &Player,
album: Album,
) -> Self {
let obj: Self = glib::Object::builder()
.property("navigation", navigation)
.property("library", library)
.property("player", player)
.build();
obj.imp().title_label.set_label(&album.to_string());
obj.imp().subtitle_label.set_label(&album.performers_string());
for recording in &album.recordings {
obj.imp()
.recordings_flow_box
.append(&RecordingTile::new(navigation, library, recording));
}
obj.imp().album.set(album).unwrap();
obj
}
#[template_callback]
fn edit_button_clicked(&self) {
self.navigation().push(&AlbumEditor::new(
&self.navigation(),
&self.library(),
Some(&self.imp().album.get().unwrap().clone()),
));
}
#[template_callback]
fn play_button_clicked(&self) {
let playlist = self
.imp()
.album
.get()
.unwrap()
.recordings
.iter()
.map(|r| self.player().recording_to_playlist(r))
.flatten()
.collect::<Vec<PlaylistItem>>();
self.player().append_and_play(playlist);
}
#[template_callback]
fn recording_selected(&self, tile: &gtk::FlowBoxChild) {
let playlist = self
.player()
.recording_to_playlist(tile.downcast_ref::<RecordingTile>().unwrap().recording());
self.player().append_and_play(playlist);
}
}

View file

@ -1,7 +1,7 @@
//! This module contains higher-level models combining information from //! This module contains higher-level models combining information from
//! multiple database tables. //! multiple database tables.
use std::fmt::Display; use std::{collections::HashSet, fmt::Display};
use anyhow::Result; use anyhow::Result;
use diesel::prelude::*; use diesel::prelude::*;
@ -392,6 +392,27 @@ impl Album {
recordings, recordings,
}) })
} }
pub fn performers_string(&self) -> String {
let mut performers = HashSet::new();
let mut ensembles = HashSet::new();
for recording in &self.recordings {
for performer in &recording.persons {
performers.insert(performer.to_string());
}
for ensemble in &recording.ensembles {
ensembles.insert(ensemble.to_string());
}
}
performers
.into_iter()
.chain(ensembles)
.collect::<Vec<String>>()
.join(", ")
}
} }
impl Eq for Album {} impl Eq for Album {}

View file

@ -1,389 +0,0 @@
use std::cell::{OnceCell, RefCell};
use adw::subclass::{navigation_page::NavigationPageImpl, prelude::*};
use gtk::{
gio,
glib::{self, Properties},
prelude::*,
};
use crate::{
album_tile::AlbumTile,
config,
db::models::*,
editor::{
ensemble::EnsembleEditor, instrument::InstrumentEditor, person::PersonEditor,
work::WorkEditor,
},
library::{Library, LibraryQuery},
player::Player,
program::Program,
program_tile::ProgramTile,
recording_tile::RecordingTile,
search_entry::SearchEntry,
search_tag::Tag,
tag_tile::TagTile,
};
mod imp {
use super::*;
#[derive(Properties, Debug, Default, gtk::CompositeTemplate)]
#[properties(wrapper_type = super::HomePage)]
#[template(file = "data/ui/home_page.blp")]
pub struct HomePage {
#[property(get, construct_only)]
pub navigation: OnceCell<adw::NavigationView>,
#[property(get, construct_only)]
pub library: OnceCell<Library>,
#[property(get, construct_only)]
pub player: OnceCell<Player>,
pub programs: RefCell<Vec<Program>>,
pub composers: RefCell<Vec<Person>>,
pub performers: RefCell<Vec<Person>>,
pub ensembles: RefCell<Vec<Ensemble>>,
pub instruments: RefCell<Vec<Instrument>>,
pub works: RefCell<Vec<Work>>,
pub recordings: RefCell<Vec<Recording>>,
pub albums: RefCell<Vec<Album>>,
#[template_child]
pub search_entry: TemplateChild<SearchEntry>,
#[template_child]
pub stack: TemplateChild<gtk::Stack>,
#[template_child]
pub header_box: TemplateChild<gtk::Box>,
#[template_child]
pub title_label: TemplateChild<gtk::Label>,
#[template_child]
pub subtitle_label: TemplateChild<gtk::Label>,
#[template_child]
pub programs_flow_box: TemplateChild<gtk::FlowBox>,
#[template_child]
pub composers_flow_box: TemplateChild<gtk::FlowBox>,
#[template_child]
pub performers_flow_box: TemplateChild<gtk::FlowBox>,
#[template_child]
pub ensembles_flow_box: TemplateChild<gtk::FlowBox>,
#[template_child]
pub instruments_flow_box: TemplateChild<gtk::FlowBox>,
#[template_child]
pub works_flow_box: TemplateChild<gtk::FlowBox>,
#[template_child]
pub recordings_flow_box: TemplateChild<gtk::FlowBox>,
#[template_child]
pub albums_flow_box: TemplateChild<gtk::FlowBox>,
#[template_child]
pub play_button: TemplateChild<gtk::Button>,
}
#[glib::object_subclass]
impl ObjectSubclass for HomePage {
const NAME: &'static str = "MusicusHomePage";
type Type = super::HomePage;
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();
}
}
#[glib::derived_properties]
impl ObjectImpl for HomePage {
fn constructed(&self) {
self.parent_constructed();
self.search_entry.set_key_capture_widget(&*self.obj());
let obj = self.obj().to_owned();
self.search_entry.connect_query_changed(move |entry| {
obj.query(&entry.query());
});
let obj = self.obj().to_owned();
self.library.get().unwrap().connect_changed(move |_| {
obj.imp().search_entry.reset();
});
self.player
.get()
.unwrap()
.bind_property("active", &self.play_button.get(), "visible")
.invert_boolean()
.sync_create()
.build();
let settings = gio::Settings::new(&config::APP_ID);
let programs = vec![
Program::deserialize(&settings.string("program1")).unwrap(),
Program::deserialize(&settings.string("program2")).unwrap(),
Program::deserialize(&settings.string("program3")).unwrap(),
];
for program in &programs {
self.programs_flow_box
.append(&ProgramTile::new(program.to_owned()));
}
self.programs.replace(programs);
self.obj().query(&LibraryQuery::default());
}
}
impl WidgetImpl for HomePage {}
impl NavigationPageImpl for HomePage {}
}
glib::wrapper! {
pub struct HomePage(ObjectSubclass<imp::HomePage>)
@extends gtk::Widget, adw::NavigationPage;
}
#[gtk::template_callbacks]
impl HomePage {
pub fn new(navigation: &adw::NavigationView, library: &Library, player: &Player) -> Self {
glib::Object::builder()
.property("navigation", navigation)
.property("library", library)
.property("player", player)
.build()
}
#[template_callback]
fn back_button_clicked(&self) {
self.imp().search_entry.reset();
}
#[template_callback]
fn edit_button_clicked(&self) {
if let Some(tag) = self.imp().search_entry.tags().first() {
match tag {
Tag::Composer(person) | Tag::Performer(person) => {
self.navigation().push(&PersonEditor::new(
&self.navigation(),
&self.library(),
Some(person),
));
}
Tag::Ensemble(ensemble) => {
self.navigation().push(&EnsembleEditor::new(
&self.navigation(),
&self.library(),
Some(ensemble),
));
}
Tag::Instrument(instrument) => self.navigation().push(&InstrumentEditor::new(
&self.navigation(),
&self.library(),
Some(instrument),
)),
Tag::Work(work) => self.navigation().push(&WorkEditor::new(
&self.navigation(),
&self.library(),
Some(work),
false,
)),
}
}
}
#[template_callback]
fn play(&self) {
let program = Program::from_query(self.imp().search_entry.query());
self.player().set_program(program);
self.player().play();
}
#[template_callback]
fn select(&self, search_entry: &SearchEntry) {
let imp = self.imp();
if imp.programs_flow_box.is_visible() {
if let Some(program) = imp.programs.borrow().first().cloned() {
self.player().set_program(program);
}
} else {
let (composer, performer, ensemble, instrument, work, recording, album) = {
(
imp.composers.borrow().first().cloned(),
imp.performers.borrow().first().cloned(),
imp.ensembles.borrow().first().cloned(),
imp.instruments.borrow().first().cloned(),
imp.works.borrow().first().cloned(),
imp.recordings.borrow().first().cloned(),
imp.albums.borrow().first().cloned(),
)
};
if let Some(person) = composer {
search_entry.add_tag(Tag::Composer(person));
} else if let Some(person) = performer {
search_entry.add_tag(Tag::Performer(person));
} else if let Some(ensemble) = ensemble {
search_entry.add_tag(Tag::Ensemble(ensemble));
} else if let Some(instrument) = instrument {
search_entry.add_tag(Tag::Instrument(instrument));
} else if let Some(work) = work {
search_entry.add_tag(Tag::Work(work));
} else if let Some(recording) = recording {
self.player().play_recording(&recording);
} else if let Some(album) = album {
self.show_album(&album);
}
}
}
#[template_callback]
fn program_selected(&self, tile: &gtk::FlowBoxChild, _: &gtk::FlowBox) {
self.player()
.set_program(tile.downcast_ref::<ProgramTile>().unwrap().program());
}
#[template_callback]
fn tile_selected(&self, tile: &gtk::FlowBoxChild, _: &gtk::FlowBox) {
self.imp()
.search_entry
.add_tag(tile.downcast_ref::<TagTile>().unwrap().tag().clone())
}
#[template_callback]
fn recording_selected(&self, tile: &gtk::FlowBoxChild, _: &gtk::FlowBox) {
self.player()
.play_recording(tile.downcast_ref::<RecordingTile>().unwrap().recording());
}
#[template_callback]
fn album_selected(&self, tile: &gtk::FlowBoxChild, _: &gtk::FlowBox) {
self.show_album(tile.downcast_ref::<AlbumTile>().unwrap().album());
}
fn show_album(&self, _album: &Album) {
todo!("Show album");
}
fn query(&self, query: &LibraryQuery) {
let imp = self.imp();
let results = self.library().query(query).unwrap();
for flowbox in [
&imp.composers_flow_box,
&imp.performers_flow_box,
&imp.ensembles_flow_box,
&imp.instruments_flow_box,
&imp.works_flow_box,
&imp.recordings_flow_box,
&imp.albums_flow_box,
] {
while let Some(widget) = flowbox.first_child() {
flowbox.remove(&widget);
}
}
imp.programs_flow_box.set_visible(query.is_empty());
if let Some(tag) = imp.search_entry.tags().first() {
match tag {
Tag::Composer(person) | Tag::Performer(person) => {
imp.title_label.set_text(&person.name.get());
imp.subtitle_label.set_visible(false);
}
Tag::Ensemble(ensemble) => {
imp.title_label.set_text(&ensemble.name.get());
imp.subtitle_label.set_visible(false);
}
Tag::Instrument(instrument) => {
imp.title_label.set_text(&instrument.name.get());
imp.subtitle_label.set_visible(false);
}
Tag::Work(work) => {
imp.title_label.set_text(&work.name.get());
if let Some(composers) = work.composers_string() {
imp.subtitle_label.set_text(&composers);
imp.subtitle_label.set_visible(true);
} else {
imp.subtitle_label.set_visible(false);
}
}
}
imp.header_box.set_visible(true);
} else {
imp.header_box.set_visible(false);
}
if results.is_empty() {
imp.stack.set_visible_child_name("empty");
} else {
imp.stack.set_visible_child_name("results");
imp.composers_flow_box
.set_visible(!results.composers.is_empty());
imp.performers_flow_box
.set_visible(!results.performers.is_empty());
imp.ensembles_flow_box
.set_visible(!results.ensembles.is_empty());
imp.instruments_flow_box
.set_visible(!results.instruments.is_empty());
imp.works_flow_box.set_visible(!results.works.is_empty());
imp.recordings_flow_box
.set_visible(!results.recordings.is_empty());
imp.albums_flow_box.set_visible(!results.albums.is_empty());
for composer in &results.composers {
imp.composers_flow_box
.append(&TagTile::new(Tag::Composer(composer.clone())));
}
for performer in &results.performers {
imp.performers_flow_box
.append(&TagTile::new(Tag::Performer(performer.clone())));
}
for ensemble in &results.ensembles {
imp.ensembles_flow_box
.append(&TagTile::new(Tag::Ensemble(ensemble.clone())));
}
for instrument in &results.instruments {
imp.instruments_flow_box
.append(&TagTile::new(Tag::Instrument(instrument.clone())));
}
for work in &results.works {
imp.works_flow_box
.append(&TagTile::new(Tag::Work(work.clone())));
}
for recording in &results.recordings {
imp.recordings_flow_box.append(&RecordingTile::new(
&self.navigation(),
&self.library(),
recording,
));
}
for album in &results.albums {
imp.albums_flow_box.append(&AlbumTile::new(album));
}
imp.composers.replace(results.composers);
imp.performers.replace(results.performers);
imp.ensembles.replace(results.ensembles);
imp.instruments.replace(results.instruments);
imp.works.replace(results.works);
imp.recordings.replace(results.recordings);
imp.albums.replace(results.albums);
}
}
}

View file

@ -72,8 +72,8 @@ impl Library {
.build() .build()
} }
pub fn query(&self, query: &LibraryQuery) -> Result<LibraryResults> { pub fn search(&self, query: &LibraryQuery, search: &str) -> Result<LibraryResults> {
let search = format!("%{}%", query.search); let search = format!("%{}%", search);
let mut binding = self.imp().connection.borrow_mut(); let mut binding = self.imp().connection.borrow_mut();
let connection = &mut *binding.as_mut().unwrap(); let connection = &mut *binding.as_mut().unwrap();
@ -1541,14 +1541,13 @@ impl Library {
} }
} }
#[derive(Default, Debug)] #[derive(Clone, Default, Debug)]
pub struct LibraryQuery { pub struct LibraryQuery {
pub composer: Option<Person>, pub composer: Option<Person>,
pub performer: Option<Person>, pub performer: Option<Person>,
pub ensemble: Option<Ensemble>, pub ensemble: Option<Ensemble>,
pub instrument: Option<Instrument>, pub instrument: Option<Instrument>,
pub work: Option<Work>, pub work: Option<Work>,
pub search: String,
} }
impl LibraryQuery { impl LibraryQuery {
@ -1558,7 +1557,6 @@ impl LibraryQuery {
&& self.ensemble.is_none() && self.ensemble.is_none()
&& self.instrument.is_none() && self.instrument.is_none()
&& self.work.is_none() && self.work.is_none()
&& self.search.is_empty()
} }
} }

View file

@ -1,9 +1,10 @@
mod album_page;
mod album_tile; mod album_tile;
mod application; mod application;
mod config; mod config;
mod db; mod db;
mod editor; mod editor;
mod home_page; mod search_page;
mod library; mod library;
mod library_manager; mod library_manager;
mod player; mod player;
@ -14,7 +15,6 @@ mod playlist_tile;
mod program; mod program;
mod program_tile; mod program_tile;
mod recording_tile; mod recording_tile;
mod search_entry;
mod search_tag; mod search_tag;
mod selector; mod selector;
mod tag_tile; mod tag_tile;

View file

@ -203,7 +203,7 @@ impl Player {
}) })
} }
pub fn play_recording(&self, recording: &Recording) { pub fn recording_to_playlist(&self, recording: &Recording) -> Vec<PlaylistItem> {
let tracks = &self let tracks = &self
.library() .library()
.unwrap() .unwrap()
@ -211,8 +211,8 @@ impl Player {
.unwrap(); .unwrap();
if tracks.is_empty() { if tracks.is_empty() {
log::warn!("Ignoring recording without tracks being added to the playlist."); log::warn!("Recording without tracks: {}.", &recording.recording_id);
return; return Vec::new();
} }
let performances = recording.performers_string(); let performances = recording.performers_string();
@ -272,14 +272,14 @@ impl Player {
} }
} }
self.append(items); items
} }
pub fn append(&self, tracks: Vec<PlaylistItem>) { pub fn append(&self, items: Vec<PlaylistItem>) {
let playlist = self.playlist(); let playlist = self.playlist();
for track in tracks { for item in items {
playlist.append(&track); playlist.append(&item);
} }
if !self.active() && playlist.n_items() > 0 { if !self.active() && playlist.n_items() > 0 {
@ -289,6 +289,21 @@ impl Player {
} }
} }
pub fn append_and_play(&self, items: Vec<PlaylistItem>) {
let playlist = self.playlist();
let first_index = playlist.n_items();
for item in items {
playlist.append(&item);
}
if playlist.n_items() > first_index {
self.set_active(true);
self.set_current_index(first_index);
self.play();
}
}
pub fn play_pause(&self) { pub fn play_pause(&self) {
if self.playing() { if self.playing() {
self.pause(); self.pause();
@ -423,7 +438,8 @@ impl Player {
if let Some(library) = self.library() { if let Some(library) = self.library() {
// TODO: if program.play_full_recordings() { // TODO: if program.play_full_recordings() {
let recording = library.generate_recording(program).unwrap(); let recording = library.generate_recording(program).unwrap();
self.play_recording(&recording); let playlist = self.recording_to_playlist(&recording);
self.append(playlist);
} }
} }

View file

@ -1,267 +0,0 @@
use std::{cell::RefCell, time::Duration};
use adw::{prelude::*, subclass::prelude::*};
use gtk::{
gdk, gio,
glib::{self, clone, subclass::Signal, Propagation},
};
use once_cell::sync::Lazy;
use crate::{
library::LibraryQuery,
search_tag::{SearchTag, Tag},
};
mod imp {
use super::*;
#[derive(Debug, Default, gtk::CompositeTemplate)]
#[template(file = "data/ui/search_entry.blp")]
pub struct SearchEntry {
#[template_child]
pub tags_box: TemplateChild<gtk::Box>,
#[template_child]
pub text: TemplateChild<gtk::Text>,
#[template_child]
pub clear_icon: TemplateChild<gtk::Image>,
pub tags: RefCell<Vec<SearchTag>>,
pub query_changed: RefCell<Option<gio::Cancellable>>,
}
#[glib::object_subclass]
impl ObjectSubclass for SearchEntry {
const NAME: &'static str = "MusicusSearchEntry";
type Type = super::SearchEntry;
type ParentType = gtk::Box;
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
klass.bind_template_instance_callbacks();
klass.set_css_name("entry");
klass.add_shortcut(
&gtk::Shortcut::builder()
.trigger(&gtk::KeyvalTrigger::new(
gdk::Key::Escape,
gdk::ModifierType::empty(),
))
.action(&gtk::CallbackAction::new(|widget, _| match widget
.downcast_ref::<super::SearchEntry>()
{
Some(obj) => {
obj.reset();
Propagation::Stop
}
None => Propagation::Proceed,
}))
.build(),
);
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
obj.init_template();
}
}
impl ObjectImpl for SearchEntry {
fn constructed(&self) {
let controller = gtk::GestureClick::new();
controller.connect_pressed(|gesture, _, _, _| {
gesture.set_state(gtk::EventSequenceState::Claimed);
});
let obj = self.obj().to_owned();
controller.connect_released(move |_, _, _, _| {
obj.reset();
});
self.clear_icon.add_controller(controller);
}
fn signals() -> &'static [Signal] {
static SIGNALS: Lazy<Vec<Signal>> = Lazy::new(|| {
vec![
Signal::builder("activate").build(),
Signal::builder("query-changed").build(),
]
});
SIGNALS.as_ref()
}
}
impl WidgetImpl for SearchEntry {
fn grab_focus(&self) -> bool {
self.text.grab_focus_without_selecting()
}
}
impl BoxImpl for SearchEntry {}
}
glib::wrapper! {
pub struct SearchEntry(ObjectSubclass<imp::SearchEntry>)
@extends gtk::Widget;
}
#[gtk::template_callbacks]
impl SearchEntry {
pub fn new() -> Self {
glib::Object::new()
}
pub fn connect_query_changed<F: Fn(&Self) + 'static>(&self, f: F) -> glib::SignalHandlerId {
self.connect_local("query-changed", true, move |values| {
let obj = values[0].get::<Self>().unwrap();
f(&obj);
None
})
}
pub fn set_key_capture_widget(&self, widget: &impl IsA<gtk::Widget>) {
let controller = gtk::EventControllerKey::new();
controller.connect_key_pressed(clone!(
#[weak(rename_to = this)]
self,
#[upgrade_or]
glib::Propagation::Proceed,
move |controller, _, _, _| {
match controller.forward(&this.imp().text.get()) {
true => {
this.grab_focus();
glib::Propagation::Stop
}
false => glib::Propagation::Proceed,
}
}
));
controller.connect_key_released(clone!(
#[weak(rename_to = this)]
self,
move |controller, _, _, _| {
controller.forward(&this.imp().text.get());
}
));
widget.add_controller(controller);
}
pub fn reset(&self) {
{
let mut tags = self.imp().tags.borrow_mut();
while let Some(tag) = tags.pop() {
self.imp().tags_box.remove(&tag);
}
}
self.imp().text.set_text("");
self.emit_by_name::<()>("query-changed", &[]);
}
pub fn add_tag(&self, tag: Tag) {
let imp = self.imp();
imp.clear_icon.set_visible(true);
imp.text.set_text("");
let tag = SearchTag::new(tag);
tag.connect_remove(clone!(
#[weak(rename_to = this)]
self,
move |tag| {
let imp = this.imp();
imp.tags_box.remove(tag);
{
imp.tags.borrow_mut().retain(|t| t.tag() != tag.tag());
}
this.emit_by_name::<()>("query-changed", &[]);
}
));
imp.tags_box.append(&tag);
imp.tags.borrow_mut().push(tag);
self.emit_by_name::<()>("query-changed", &[]);
}
pub fn tags(&self) -> Vec<Tag> {
self.imp()
.tags
.borrow()
.iter()
.map(|t| t.tag().to_owned())
.collect()
}
pub fn query(&self) -> LibraryQuery {
let mut query = LibraryQuery {
search: self.imp().text.text().to_string(),
..Default::default()
};
for tag in &*self.imp().tags.borrow() {
match tag.tag().clone() {
Tag::Composer(person) => query.composer = Some(person),
Tag::Performer(person) => query.performer = Some(person),
Tag::Ensemble(ensemble) => query.ensemble = Some(ensemble),
Tag::Instrument(instrument) => query.instrument = Some(instrument),
Tag::Work(work) => query.work = Some(work),
}
}
query
}
#[template_callback]
fn activate(&self, _: &gtk::Text) {
self.emit_by_name::<()>("activate", &[]);
}
#[template_callback]
fn backspace(&self, text: &gtk::Text) {
if text.position() == 0 {
let changed = if let Some(tag) = self.imp().tags.borrow_mut().pop() {
self.imp().tags_box.remove(&tag);
true
} else {
false
};
if changed {
self.emit_by_name::<()>("query-changed", &[]);
}
}
}
#[template_callback]
async fn text_changed(&self, text: &gtk::Text) {
let imp = self.imp();
if imp.tags.borrow().is_empty() {
imp.clear_icon.set_visible(!text.text().is_empty());
}
if let Some(cancellable) = imp.query_changed.borrow_mut().take() {
cancellable.cancel();
}
let cancellable = gio::Cancellable::new();
imp.query_changed.replace(Some(cancellable.clone()));
let _ = gio::CancellableFuture::new(
async {
glib::timeout_future(Duration::from_millis(150)).await;
self.emit_by_name::<()>("query-changed", &[]);
},
cancellable,
)
.await;
}
}

478
src/search_page.rs Normal file
View file

@ -0,0 +1,478 @@
use std::cell::{OnceCell, RefCell};
use adw::subclass::{navigation_page::NavigationPageImpl, prelude::*};
use formatx::formatx;
use gettextrs::gettext;
use gtk::{
gio,
glib::{self, Properties},
prelude::*,
};
use crate::{
album_page::AlbumPage,
album_tile::AlbumTile,
config,
db::models::*,
editor::{
ensemble::EnsembleEditor, instrument::InstrumentEditor, person::PersonEditor,
work::WorkEditor,
},
library::{Library, LibraryQuery},
player::Player,
program::Program,
program_tile::ProgramTile,
recording_tile::RecordingTile,
search_tag::Tag,
tag_tile::TagTile,
};
mod imp {
use super::*;
#[derive(Properties, Debug, Default, gtk::CompositeTemplate)]
#[properties(wrapper_type = super::SearchPage)]
#[template(file = "data/ui/search_page.blp")]
pub struct SearchPage {
#[property(get, construct_only)]
pub navigation: OnceCell<adw::NavigationView>,
#[property(get, construct_only)]
pub library: OnceCell<Library>,
#[property(get, construct_only)]
pub player: OnceCell<Player>,
pub query: OnceCell<LibraryQuery>,
pub highlight: RefCell<Option<Tag>>,
pub programs: RefCell<Vec<Program>>,
pub composers: RefCell<Vec<Person>>,
pub performers: RefCell<Vec<Person>>,
pub ensembles: RefCell<Vec<Ensemble>>,
pub instruments: RefCell<Vec<Instrument>>,
pub works: RefCell<Vec<Work>>,
pub recordings: RefCell<Vec<Recording>>,
pub albums: RefCell<Vec<Album>>,
#[template_child]
pub scrolled_window: TemplateChild<gtk::ScrolledWindow>,
#[template_child]
pub header_bar: TemplateChild<adw::HeaderBar>,
#[template_child]
pub search_entry: TemplateChild<gtk::SearchEntry>,
#[template_child]
pub stack: TemplateChild<gtk::Stack>,
#[template_child]
pub header_box: TemplateChild<gtk::Box>,
#[template_child]
pub title_label: TemplateChild<gtk::Label>,
#[template_child]
pub subtitle_label: TemplateChild<gtk::Label>,
#[template_child]
pub programs_flow_box: TemplateChild<gtk::FlowBox>,
#[template_child]
pub composers_flow_box: TemplateChild<gtk::FlowBox>,
#[template_child]
pub performers_flow_box: TemplateChild<gtk::FlowBox>,
#[template_child]
pub ensembles_flow_box: TemplateChild<gtk::FlowBox>,
#[template_child]
pub instruments_flow_box: TemplateChild<gtk::FlowBox>,
#[template_child]
pub works_flow_box: TemplateChild<gtk::FlowBox>,
#[template_child]
pub recordings_flow_box: TemplateChild<gtk::FlowBox>,
#[template_child]
pub albums_flow_box: TemplateChild<gtk::FlowBox>,
}
#[glib::object_subclass]
impl ObjectSubclass for SearchPage {
const NAME: &'static str = "MusicusSearchPage";
type Type = super::SearchPage;
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();
}
}
#[glib::derived_properties]
impl ObjectImpl for SearchPage {
fn constructed(&self) {
self.parent_constructed();
self.search_entry.set_key_capture_widget(Some(&*self.obj()));
let obj = self.obj().to_owned();
self.search_entry.connect_search_changed(move |entry| {
obj.imp().scrolled_window.vadjustment().set_value(0.0);
obj.search(&entry.text());
});
}
}
impl WidgetImpl for SearchPage {
fn map(&self) {
self.parent_map();
self.search_entry.grab_focus();
}
}
impl NavigationPageImpl for SearchPage {}
}
glib::wrapper! {
pub struct SearchPage(ObjectSubclass<imp::SearchPage>)
@extends gtk::Widget, adw::NavigationPage;
}
#[gtk::template_callbacks]
impl SearchPage {
pub fn new(
navigation: &adw::NavigationView,
library: &Library,
player: &Player,
query: LibraryQuery,
) -> Self {
let obj: Self = glib::Object::builder()
.property("navigation", navigation)
.property("library", library)
.property("player", player)
.build();
if query.is_empty() {
let settings = gio::Settings::new(&config::APP_ID);
let programs = vec![
Program::deserialize(&settings.string("program1")).unwrap(),
Program::deserialize(&settings.string("program2")).unwrap(),
Program::deserialize(&settings.string("program3")).unwrap(),
];
for program in &programs {
obj.imp()
.programs_flow_box
.append(&ProgramTile::new(program.to_owned()));
}
obj.imp().programs.replace(programs);
}
obj.imp().query.set(query).unwrap();
obj.search("");
obj
}
#[template_callback]
fn edit_button_clicked(&self) {
if let Some(highlight) = &*self.imp().highlight.borrow() {
match highlight {
Tag::Composer(person) | Tag::Performer(person) => {
self.navigation().push(&PersonEditor::new(
&self.navigation(),
&self.library(),
Some(person),
));
}
Tag::Ensemble(ensemble) => {
self.navigation().push(&EnsembleEditor::new(
&self.navigation(),
&self.library(),
Some(ensemble),
));
}
Tag::Instrument(instrument) => self.navigation().push(&InstrumentEditor::new(
&self.navigation(),
&self.library(),
Some(instrument),
)),
Tag::Work(work) => self.navigation().push(&WorkEditor::new(
&self.navigation(),
&self.library(),
Some(work),
false,
)),
}
}
}
#[template_callback]
fn play_button_clicked(&self) {
let program = Program::from_query(self.imp().query.get().unwrap().clone());
self.player().set_program(program);
self.player().play();
}
#[template_callback]
fn select(&self) {
let imp = self.imp();
if imp.programs_flow_box.is_visible() {
if let Some(program) = imp.programs.borrow().first().cloned() {
self.player().set_program(program);
}
} else {
let mut new_query = self.imp().query.get().unwrap().clone();
let query_changed = if let Some(person) = imp.composers.borrow().first().cloned() {
new_query.composer = Some(person);
true
} else if let Some(person) = imp.performers.borrow().first().cloned() {
new_query.performer = Some(person);
true
} else if let Some(ensemble) = imp.ensembles.borrow().first().cloned() {
new_query.ensemble = Some(ensemble);
true
} else if let Some(instrument) = imp.instruments.borrow().first().cloned() {
new_query.instrument = Some(instrument);
true
} else if let Some(work) = imp.works.borrow().first().cloned() {
new_query.work = Some(work);
true
} else if let Some(recording) = imp.recordings.borrow().first().cloned() {
let playlist = self.player().recording_to_playlist(&recording);
self.player().append_and_play(playlist);
false
} else if let Some(album) = imp.albums.borrow().first().cloned() {
self.show_album(&album);
false
} else {
false
};
if query_changed {
self.navigation().push(&SearchPage::new(
&self.navigation(),
&self.library(),
&self.player(),
new_query,
));
}
}
}
#[template_callback]
fn program_selected(&self, tile: &gtk::FlowBoxChild) {
self.player()
.set_program(tile.downcast_ref::<ProgramTile>().unwrap().program());
}
#[template_callback]
fn tile_selected(&self, tile: &gtk::FlowBoxChild) {
let mut new_query = self.imp().query.get().unwrap().clone();
match tile.downcast_ref::<TagTile>().unwrap().tag().clone() {
Tag::Composer(person) => new_query.composer = Some(person),
Tag::Performer(person) => new_query.performer = Some(person),
Tag::Ensemble(ensemble) => new_query.ensemble = Some(ensemble),
Tag::Instrument(instrument) => new_query.instrument = Some(instrument),
Tag::Work(work) => new_query.work = Some(work),
}
self.navigation().push(&SearchPage::new(
&self.navigation(),
&self.library(),
&self.player(),
new_query,
));
}
#[template_callback]
fn recording_selected(&self, tile: &gtk::FlowBoxChild) {
let playlist = self
.player()
.recording_to_playlist(tile.downcast_ref::<RecordingTile>().unwrap().recording());
self.player().append_and_play(playlist);
}
#[template_callback]
fn album_selected(&self, tile: &gtk::FlowBoxChild) {
self.show_album(tile.downcast_ref::<AlbumTile>().unwrap().album());
}
fn show_album(&self, album: &Album) {
self.navigation().push(&AlbumPage::new(
&self.navigation(),
&self.library(),
&self.player(),
album.to_owned(),
));
}
fn search(&self, search: &str) {
let query = self.imp().query.get().unwrap();
let imp = self.imp();
let results = self.library().search(query, search).unwrap();
for flowbox in [
&imp.composers_flow_box,
&imp.performers_flow_box,
&imp.ensembles_flow_box,
&imp.instruments_flow_box,
&imp.works_flow_box,
&imp.recordings_flow_box,
&imp.albums_flow_box,
] {
while let Some(widget) = flowbox.first_child() {
flowbox.remove(&widget);
}
}
// Only show programs initially.
imp.programs_flow_box
.set_visible(query.is_empty() && search.is_empty());
imp.header_bar.set_show_title(query.is_empty());
imp.header_box.set_visible(!query.is_empty());
let highlight = if let Some(work) = &query.work {
imp.title_label.set_text(&work.name.get());
if let Some(composers) = work.composers_string() {
imp.subtitle_label.set_text(&composers);
imp.subtitle_label.set_visible(true);
} else {
imp.subtitle_label.set_visible(false);
}
Some(Tag::Work(work.to_owned()))
} else if let Some(person) = &query.composer {
imp.title_label.set_text(&person.name.get());
imp.subtitle_label.set_visible(false);
Some(Tag::Composer(person.to_owned()))
} else if let Some(person) = &query.performer {
imp.title_label.set_text(&person.name.get());
imp.subtitle_label.set_visible(false);
Some(Tag::Performer(person.to_owned()))
} else if let Some(ensemble) = &query.ensemble {
imp.title_label.set_text(&ensemble.name.get());
imp.subtitle_label.set_visible(false);
Some(Tag::Ensemble(ensemble.to_owned()))
} else if let Some(instrument) = &query.instrument {
imp.title_label
.set_text(&formatx!(gettext("Music for {}"), &instrument.name.get()).unwrap());
imp.subtitle_label.set_visible(false);
Some(Tag::Instrument(instrument.to_owned()))
} else {
None
};
if let Some(highlight) = &highlight {
if !matches!(highlight, Tag::Work(_)) {
let mut details = Vec::new();
match highlight {
Tag::Composer(_) => {
if let Some(instrument) = &query.instrument {
details.push(formatx!(gettext("Works with {}"), instrument).unwrap());
}
if let (Some(person), Some(ensemble)) = (&query.performer, &query.ensemble)
{
details.push(
formatx!(gettext("Performed by {} and {}"), person, ensemble)
.unwrap(),
);
} else if let Some(person) = &query.performer {
details.push(formatx!(gettext("Performed by {}"), person).unwrap());
} else if let Some(ensemble) = &query.ensemble {
details.push(formatx!(gettext("Performed by {}"), ensemble).unwrap());
}
}
Tag::Performer(_) => {
if let Some(instrument) = &query.instrument {
details.push(formatx!(gettext("Works with {}"), instrument).unwrap());
}
if let Some(ensemble) = &query.ensemble {
details.push(formatx!(gettext("Performed with {}"), ensemble).unwrap());
}
}
Tag::Ensemble(_) => {
if let Some(instrument) = &query.instrument {
details.push(formatx!(gettext("Works with {}"), instrument).unwrap());
}
}
Tag::Instrument(_) => (),
// Already covered.
Tag::Work(_) => unreachable!(),
}
imp.subtitle_label.set_visible(!details.is_empty());
imp.subtitle_label.set_text(&details.join(", "));
}
}
imp.highlight.replace(highlight);
if results.is_empty() {
imp.stack.set_visible_child_name("empty");
} else {
imp.stack.set_visible_child_name("results");
imp.composers_flow_box
.set_visible(!results.composers.is_empty());
imp.performers_flow_box
.set_visible(!results.performers.is_empty());
imp.ensembles_flow_box
.set_visible(!results.ensembles.is_empty());
imp.instruments_flow_box
.set_visible(!results.instruments.is_empty());
imp.works_flow_box.set_visible(!results.works.is_empty());
imp.recordings_flow_box
.set_visible(!results.recordings.is_empty());
imp.albums_flow_box.set_visible(!results.albums.is_empty());
for composer in &results.composers {
imp.composers_flow_box
.append(&TagTile::new(Tag::Composer(composer.clone())));
}
for performer in &results.performers {
imp.performers_flow_box
.append(&TagTile::new(Tag::Performer(performer.clone())));
}
for ensemble in &results.ensembles {
imp.ensembles_flow_box
.append(&TagTile::new(Tag::Ensemble(ensemble.clone())));
}
for instrument in &results.instruments {
imp.instruments_flow_box
.append(&TagTile::new(Tag::Instrument(instrument.clone())));
}
for work in &results.works {
imp.works_flow_box
.append(&TagTile::new(Tag::Work(work.clone())));
}
for recording in &results.recordings {
imp.recordings_flow_box.append(&RecordingTile::new(
&self.navigation(),
&self.library(),
recording,
));
}
for album in &results.albums {
imp.albums_flow_box.append(&AlbumTile::new(album));
}
imp.composers.replace(results.composers);
imp.performers.replace(results.performers);
imp.ensembles.replace(results.ensembles);
imp.instruments.replace(results.instruments);
imp.works.replace(results.works);
imp.recordings.replace(results.recordings);
imp.albums.replace(results.albums);
}
}
}

View file

@ -4,9 +4,15 @@ use adw::subclass::prelude::*;
use gtk::{gio, glib, glib::clone, prelude::*}; use gtk::{gio, glib, glib::clone, prelude::*};
use crate::{ use crate::{
config, editor::tracks::TracksEditor, home_page::HomePage, library::Library, config,
library_manager::LibraryManager, player::Player, player_bar::PlayerBar, editor::tracks::TracksEditor,
playlist_page::PlaylistPage, welcome_page::WelcomePage, library::{Library, LibraryQuery},
library_manager::LibraryManager,
player::Player,
player_bar::PlayerBar,
playlist_page::PlaylistPage,
search_page::SearchPage,
welcome_page::WelcomePage,
}; };
mod imp { mod imp {
@ -189,7 +195,13 @@ impl Window {
self.imp().player.set_library(&library); self.imp().player.set_library(&library);
let navigation = self.imp().navigation_view.get(); let navigation = self.imp().navigation_view.get();
navigation.replace(&[HomePage::new(&navigation, &library, &self.imp().player).into()]); navigation.replace(&[SearchPage::new(
&navigation,
&library,
&self.imp().player,
LibraryQuery::default(),
)
.into()]);
self.imp().library.replace(Some(library)); self.imp().library.replace(Some(library));
} }