Move crates to subdirectory

This commit is contained in:
Elias Projahn 2022-08-26 14:38:27 +02:00
parent 1db96062fb
commit ac4b29e86d
115 changed files with 10 additions and 5 deletions

19
crates/backend/Cargo.toml Normal file
View file

@ -0,0 +1,19 @@
[package]
name = "musicus_backend"
version = "0.1.0"
edition = "2021"
[dependencies]
fragile = "1.2.0"
gio = "0.15.11"
glib = "0.15.11"
gstreamer = "0.18.8"
gstreamer-player = "0.18.0"
log = { version = "0.4.16", features = ["std"] }
musicus_database = { version = "0.1.0", path = "../database" }
musicus_import = { version = "0.1.0", path = "../import" }
thiserror = "1.0.31"
tokio = { version = "1.18.0", features = ["sync"] }
[target.'cfg(target_os = "linux")'.dependencies]
mpris-player = "0.6.1"

View file

@ -0,0 +1,17 @@
/// An error that happened within the backend.
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error(transparent)]
DatabaseError(#[from] musicus_database::Error),
#[error("An error happened while decoding to UTF-8.")]
Utf8Error(#[from] std::str::Utf8Error),
#[error("Failed to receive an event.")]
RecvError(#[from] tokio::sync::broadcast::error::RecvError),
#[error("An error happened: {0}")]
Other(String),
}
pub type Result<T> = std::result::Result<T, Error>;

181
crates/backend/src/lib.rs Normal file
View file

@ -0,0 +1,181 @@
use gio::traits::SettingsExt;
use log::warn;
use musicus_database::Database;
use std::{
cell::{Cell, RefCell},
path::PathBuf,
rc::Rc,
};
use tokio::sync::{broadcast, broadcast::Sender};
pub use musicus_database as db;
pub use musicus_import as import;
pub mod error;
pub use error::*;
pub mod library;
pub use library::*;
mod logger;
pub mod player;
pub use player::*;
/// General states the application can be in.
#[derive(Debug, Copy, Clone)]
pub enum BackendState {
/// The backend is not set up yet. This means that no backend methods except for setting the
/// music library path should be called. The user interface should adapt and only present this
/// option.
NoMusicLibrary,
/// The backend is loading the music library. No methods should be called. The user interface
/// should represent that state by prohibiting all interaction.
Loading,
/// The backend is ready and all methods may be called.
Ready,
}
/// A collection of all backend state and functionality.
pub struct Backend {
/// A closure that will be called whenever the backend state changes.
state_cb: RefCell<Option<Box<dyn Fn(BackendState)>>>,
/// Access to GSettings.
settings: gio::Settings,
/// The current path to the music library, which is used by the player and the database. This
/// is guaranteed to be Some, when the state is set to BackendState::Ready.
music_library_path: RefCell<Option<PathBuf>>,
/// The sender for sending library update notifications.
library_updated_sender: Sender<()>,
/// The database. This can be assumed to exist, when the state is set to BackendState::Ready.
database: RefCell<Option<Rc<Database>>>,
/// The player handling playlist and playback. This can be assumed to exist, when the state is
/// set to BackendState::Ready.
player: RefCell<Option<Rc<Player>>>,
/// Whether to keep playing random tracks after the playlist ends.
keep_playing: Cell<bool>,
/// Whether to choose full recordings for random playback.
play_full_recordings: Cell<bool>,
}
impl Backend {
/// Create a new backend initerface. The user interface should subscribe to the state stream
/// and call init() afterwards. There may be only one backend for a process and this method
/// may only be called exactly once. Otherwise it will panic.
pub fn new() -> Self {
logger::register();
let (library_updated_sender, _) = broadcast::channel(1024);
Backend {
state_cb: RefCell::new(None),
settings: gio::Settings::new("de.johrpan.musicus"),
music_library_path: RefCell::new(None),
library_updated_sender,
database: RefCell::new(None),
player: RefCell::new(None),
keep_playing: Cell::new(false),
play_full_recordings: Cell::new(true),
}
}
/// Set the closure to be called whenever the backend state changes.
pub fn set_state_cb<F: Fn(BackendState) + 'static>(&self, cb: F) {
self.state_cb.replace(Some(Box::new(cb)));
}
/// Initialize the backend. A state callback should already have been registered using
/// [`set_state_cb()`] to react to the result.
pub fn init(self: Rc<Self>) -> Result<()> {
self.keep_playing.set(self.settings.boolean("keep-playing"));
self.play_full_recordings
.set(self.settings.boolean("play-full-recordings"));
Rc::clone(&self).init_library()?;
match self.get_music_library_path() {
None => self.set_state(BackendState::NoMusicLibrary),
Some(_) => self.set_state(BackendState::Ready),
};
Ok(())
}
/// Whether to keep playing random tracks after the playlist ends.
pub fn keep_playing(&self) -> bool {
self.keep_playing.get()
}
/// Set whether to keep playing random tracks after the playlist ends.
pub fn set_keep_playing(self: Rc<Self>, keep_playing: bool) {
if let Err(err) = self.settings.set_boolean("keep-playing", keep_playing) {
warn!(
"The preference \"keep-playing\" could not be saved using GSettings. It will most \
likely not be available at the next startup. Error message: {}",
err
);
}
self.keep_playing.set(keep_playing);
self.update_track_generator();
}
/// Whether to choose full recordings for random playback.
pub fn play_full_recordings(&self) -> bool {
self.play_full_recordings.get()
}
/// Set whether to choose full recordings for random playback.
pub fn set_play_full_recordings(self: Rc<Self>, play_full_recordings: bool) {
if let Err(err) = self
.settings
.set_boolean("play-full-recordings", play_full_recordings)
{
warn!(
"The preference \"play-full-recordings\" could not be saved using GSettings. It \
will most likely not be available at the next startup. Error message: {}",
err
);
}
self.play_full_recordings.set(play_full_recordings);
self.update_track_generator();
}
/// Set the current state and notify the user interface.
fn set_state(&self, state: BackendState) {
if let Some(cb) = &*self.state_cb.borrow() {
cb(state);
}
}
/// Apply the current track generation settings.
fn update_track_generator(self: Rc<Self>) {
if let Some(player) = self.get_player() {
if self.keep_playing() {
if self.play_full_recordings() {
player.set_track_generator(Some(RandomRecordingGenerator::new(self)));
} else {
player.set_track_generator(Some(RandomTrackGenerator::new(self)));
}
} else {
player.set_track_generator(None::<RandomRecordingGenerator>);
}
}
}
}
impl Default for Backend {
fn default() -> Self {
Self::new()
}
}

View file

@ -0,0 +1,86 @@
use crate::{Backend, BackendState, Player, Result};
use gio::prelude::*;
use log::warn;
use musicus_database::Database;
use std::path::PathBuf;
use std::rc::Rc;
impl Backend {
/// Initialize the music library if it is set in the settings.
pub(super) fn init_library(self: Rc<Self>) -> Result<()> {
let path = self.settings.string("music-library-path");
if !path.is_empty() {
self.set_music_library_path_priv(PathBuf::from(path.to_string()))?;
}
Ok(())
}
/// Set the path to the music library folder and connect to the database.
pub fn set_music_library_path(self: Rc<Self>, path: PathBuf) -> Result<()> {
if let Err(err) = self
.settings
.set_string("music-library-path", path.to_str().unwrap())
{
warn!(
"The music library path could not be saved using GSettings. It will most likely \
not be available at the next startup. Error message: {}",
err
);
}
self.set_music_library_path_priv(path)
}
/// Set the path to the music library folder and and connect to the database.
pub fn set_music_library_path_priv(self: Rc<Self>, path: PathBuf) -> Result<()> {
self.set_state(BackendState::Loading);
self.music_library_path.replace(Some(path.clone()));
let mut db_path = path.clone();
db_path.push("musicus.db");
let database = Rc::new(Database::new(db_path.to_str().unwrap())?);
self.database.replace(Some(Rc::clone(&database)));
let player = Player::new(path);
self.player.replace(Some(player));
Rc::clone(&self).update_track_generator();
self.set_state(BackendState::Ready);
Ok(())
}
/// Get the currently set music library path.
pub fn get_music_library_path(&self) -> Option<PathBuf> {
self.music_library_path.borrow().clone()
}
/// Get an interface to the database and panic if there is none.
pub fn db(&self) -> Rc<Database> {
self.database.borrow().clone().unwrap()
}
/// Get an interface to the playback service.
pub fn get_player(&self) -> Option<Rc<Player>> {
self.player.borrow().clone()
}
/// Wait for the next library update.
pub async fn library_update(&self) -> Result<()> {
Ok(self.library_updated_sender.subscribe().recv().await?)
}
/// Notify the frontend that the library was changed.
pub fn library_changed(&self) {
self.library_updated_sender.send(()).unwrap();
}
/// Get an interface to the player and panic if there is none.
pub fn pl(&self) -> Rc<Player> {
self.get_player().unwrap()
}
}

View file

@ -0,0 +1,63 @@
use log::{Level, LevelFilter, Log, Metadata, Record};
use std::{fmt::Display, sync::Mutex};
/// Register the custom logger. This will panic if called more than once.
pub fn register() {
log::set_boxed_logger(Box::new(Logger::default()))
.map(|()| log::set_max_level(LevelFilter::Info))
.unwrap();
}
/// A simple logging handler that prints out all messages and caches them for
/// later access by the user interface.
struct Logger {
/// All messages since the start of the program.
messages: Mutex<Vec<LogMessage>>,
}
impl Default for Logger {
fn default() -> Self {
Self {
messages: Mutex::new(Vec::new()),
}
}
}
impl Log for Logger {
fn enabled(&self, metadata: &Metadata) -> bool {
metadata.level() <= Level::Info
}
fn log(&self, record: &Record) {
if record.level() <= Level::Info {
let message = record.into();
println!("{}", message);
self.messages.lock().unwrap().push(message);
}
}
fn flush(&self) {}
}
/// A simplified representation of a [`Record`].
struct LogMessage {
pub level: String,
pub module: String,
pub message: String,
}
impl<'a> From<&Record<'a>> for LogMessage {
fn from(record: &Record<'a>) -> Self {
Self {
level: record.level().to_string(),
module: String::from(record.module_path().unwrap_or_else(|| record.target())),
message: format!("{}", record.args()),
}
}
}
impl Display for LogMessage {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} ({}): {}", self.module, self.level, self.message)
}
}

View file

@ -0,0 +1,483 @@
use crate::{Backend, Error, Result};
use glib::clone;
use gstreamer_player::prelude::*;
use musicus_database::Track;
use std::cell::{Cell, RefCell};
use std::path::PathBuf;
use std::rc::Rc;
use std::sync::Arc;
#[cfg(target_os = "linux")]
use mpris_player::{Metadata, MprisPlayer, PlaybackStatus};
pub struct Player {
music_library_path: PathBuf,
player: gstreamer_player::Player,
playlist: RefCell<Vec<Track>>,
current_track: Cell<Option<usize>>,
playing: Cell<bool>,
duration: Cell<u64>,
track_generator: RefCell<Option<Box<dyn TrackGenerator>>>,
playlist_cbs: RefCell<Vec<Box<dyn Fn(Vec<Track>)>>>,
track_cbs: RefCell<Vec<Box<dyn Fn(usize)>>>,
duration_cbs: RefCell<Vec<Box<dyn Fn(u64)>>>,
playing_cbs: RefCell<Vec<Box<dyn Fn(bool)>>>,
position_cbs: RefCell<Vec<Box<dyn Fn(u64)>>>,
raise_cb: RefCell<Option<Box<dyn Fn()>>>,
#[cfg(target_os = "linux")]
mpris: Arc<MprisPlayer>,
}
impl Player {
pub fn new(music_library_path: PathBuf) -> Rc<Self> {
let dispatcher = gstreamer_player::PlayerGMainContextSignalDispatcher::new(None);
let player = gstreamer_player::Player::new(None, Some(&dispatcher.upcast()));
let mut config = player.config();
config.set_position_update_interval(250);
player.set_config(config).unwrap();
player.set_video_track_enabled(false);
let result = Rc::new(Self {
music_library_path,
player: player.clone(),
playlist: RefCell::new(Vec::new()),
current_track: Cell::new(None),
playing: Cell::new(false),
duration: Cell::new(0),
track_generator: RefCell::new(None),
playlist_cbs: RefCell::new(Vec::new()),
track_cbs: RefCell::new(Vec::new()),
duration_cbs: RefCell::new(Vec::new()),
playing_cbs: RefCell::new(Vec::new()),
position_cbs: RefCell::new(Vec::new()),
raise_cb: RefCell::new(None),
#[cfg(target_os = "linux")]
mpris: {
let mpris = MprisPlayer::new(
"de.johrpan.musicus".to_string(),
"Musicus".to_string(),
"de.johrpan.musicus.desktop".to_string(),
);
mpris.set_can_raise(true);
mpris.set_can_play(false);
mpris.set_can_go_previous(false);
mpris.set_can_go_next(false);
mpris.set_can_seek(false);
mpris.set_can_set_fullscreen(false);
mpris
},
});
let clone = fragile::Fragile::new(result.clone());
player.connect_end_of_stream(move |_| {
let clone = clone.get();
if clone.has_next() {
clone.next().unwrap();
} else {
clone.player.stop();
clone.playing.replace(false);
for cb in &*clone.playing_cbs.borrow() {
cb(false);
}
#[cfg(target_os = "linux")]
clone.mpris.set_playback_status(PlaybackStatus::Paused);
}
});
let clone = fragile::Fragile::new(result.clone());
player.connect_position_updated(move |_, position| {
for cb in &*clone.get().position_cbs.borrow() {
cb(position.unwrap().mseconds());
}
});
let clone = fragile::Fragile::new(result.clone());
player.connect_duration_changed(move |_, duration| {
for cb in &*clone.get().duration_cbs.borrow() {
let duration = duration.unwrap().mseconds();
clone.get().duration.set(duration);
cb(duration);
}
});
#[cfg(target_os = "linux")]
{
result
.mpris
.connect_play_pause(clone!(@weak result => move || {
result.play_pause().unwrap();
}));
result.mpris.connect_play(clone!(@weak result => move || {
if !result.is_playing() {
result.play_pause().unwrap();
}
}));
result.mpris.connect_pause(clone!(@weak result => move || {
if result.is_playing() {
result.play_pause().unwrap();
}
}));
result
.mpris
.connect_previous(clone!(@weak result => move || {
let _ = result.previous();
}));
result.mpris.connect_next(clone!(@weak result => move || {
let _ = result.next();
}));
result.mpris.connect_raise(clone!(@weak result => move || {
let cb = result.raise_cb.borrow();
if let Some(cb) = &*cb {
cb()
}
}));
}
result
}
pub fn set_track_generator<G: TrackGenerator + 'static>(&self, generator: Option<G>) {
self.track_generator.replace(match generator {
Some(generator) => Some(Box::new(generator)),
None => None,
});
}
pub fn add_playlist_cb<F: Fn(Vec<Track>) + 'static>(&self, cb: F) {
self.playlist_cbs.borrow_mut().push(Box::new(cb));
}
pub fn add_track_cb<F: Fn(usize) + 'static>(&self, cb: F) {
self.track_cbs.borrow_mut().push(Box::new(cb));
}
pub fn add_duration_cb<F: Fn(u64) + 'static>(&self, cb: F) {
self.duration_cbs.borrow_mut().push(Box::new(cb));
}
pub fn add_playing_cb<F: Fn(bool) + 'static>(&self, cb: F) {
self.playing_cbs.borrow_mut().push(Box::new(cb));
}
pub fn add_position_cb<F: Fn(u64) + 'static>(&self, cb: F) {
self.position_cbs.borrow_mut().push(Box::new(cb));
}
pub fn set_raise_cb<F: Fn() + 'static>(&self, cb: F) {
self.raise_cb.replace(Some(Box::new(cb)));
}
pub fn get_playlist(&self) -> Vec<Track> {
self.playlist.borrow().clone()
}
pub fn get_current_track(&self) -> Option<usize> {
self.current_track.get()
}
pub fn get_duration(&self) -> Option<gstreamer::ClockTime> {
self.player.duration()
}
pub fn is_playing(&self) -> bool {
self.playing.get()
}
/// Add some items to the playlist.
pub fn add_items(&self, mut items: Vec<Track>) -> Result<()> {
if items.is_empty() {
return Ok(())
}
let was_empty = {
let mut playlist = self.playlist.borrow_mut();
let was_empty = playlist.is_empty();
playlist.append(&mut items);
was_empty
};
for cb in &*self.playlist_cbs.borrow() {
cb(self.playlist.borrow().clone());
}
if was_empty {
self.set_track(0)?;
self.player.play();
self.playing.set(true);
for cb in &*self.playing_cbs.borrow() {
cb(true);
}
#[cfg(target_os = "linux")]
{
self.mpris.set_can_play(true);
self.mpris.set_playback_status(PlaybackStatus::Playing);
}
}
Ok(())
}
pub fn play_pause(&self) -> Result<()> {
if self.is_playing() {
self.player.pause();
self.playing.set(false);
for cb in &*self.playing_cbs.borrow() {
cb(false);
}
#[cfg(target_os = "linux")]
self.mpris.set_playback_status(PlaybackStatus::Paused);
} else {
if self.current_track.get().is_none() {
self.next()?;
}
self.player.play();
self.playing.set(true);
for cb in &*self.playing_cbs.borrow() {
cb(true);
}
#[cfg(target_os = "linux")]
self.mpris.set_playback_status(PlaybackStatus::Playing);
}
Ok(())
}
pub fn seek(&self, ms: u64) {
self.player.seek(gstreamer::ClockTime::from_mseconds(ms));
}
pub fn has_previous(&self) -> bool {
if let Some(current_track) = self.current_track.get() {
current_track > 0
} else {
false
}
}
pub fn previous(&self) -> Result<()> {
let mut current_track = self.current_track.get().ok_or_else(|| {
Error::Other(String::from(
"Player tried to access non existant current track.",
))
})?;
if current_track > 0 {
current_track -= 1;
} else {
return Err(Error::Other(String::from("No existing previous track.")));
}
self.set_track(current_track)
}
pub fn has_next(&self) -> bool {
if let Some(generator) = &*self.track_generator.borrow() {
generator.has_next()
} else if let Some(current_track) = self.current_track.get() {
let playlist = self.playlist.borrow();
current_track + 1 < playlist.len()
} else {
false
}
}
pub fn next(&self) -> Result<()> {
let current_track = self.current_track.get();
let generator = self.track_generator.borrow();
if let Some(current_track) = current_track {
if current_track + 1 >= self.playlist.borrow().len() {
if let Some(generator) = &*generator {
let items = generator.next();
if !items.is_empty() {
self.add_items(items)?;
} else {
return Err(Error::Other(String::from(
"Track generator failed to generate next track.",
)));
}
} else {
return Err(Error::Other(String::from("No existing next track.")));
}
}
self.set_track(current_track + 1)?;
Ok(())
} else if let Some(generator) = &*generator {
let items = generator.next();
if !items.is_empty() {
self.add_items(items)?;
} else {
return Err(Error::Other(String::from(
"Track generator failed to generate next track.",
)));
}
Ok(())
} else {
Err(Error::Other(String::from("No existing next track.")))
}
}
pub fn set_track(&self, current_track: usize) -> Result<()> {
let track = &self.playlist.borrow()[current_track];
let path = self
.music_library_path
.join(track.path.clone())
.into_os_string()
.into_string()
.unwrap();
let uri = glib::filename_to_uri(&path, None)
.map_err(|_| Error::Other(format!("Failed to create URI from path: {}", path)))?;
self.player.set_uri(Some(&uri));
if self.is_playing() {
self.player.play();
}
self.current_track.set(Some(current_track));
for cb in &*self.track_cbs.borrow() {
cb(current_track);
}
#[cfg(target_os = "linux")]
{
let mut parts = Vec::<String>::new();
for part in &track.work_parts {
parts.push(track.recording.work.parts[*part].title.clone());
}
let mut title = track.recording.work.get_title();
if !parts.is_empty() {
title = format!("{}: {}", title, parts.join(", "));
}
let subtitle = track.recording.get_performers();
let mut metadata = Metadata::new();
metadata.artist = Some(vec![title]);
metadata.title = Some(subtitle);
self.mpris.set_metadata(metadata);
self.mpris.set_can_go_previous(self.has_previous());
self.mpris.set_can_go_next(self.has_next());
}
Ok(())
}
pub fn send_data(&self) {
for cb in &*self.playlist_cbs.borrow() {
cb(self.playlist.borrow().clone());
}
for cb in &*self.track_cbs.borrow() {
cb(self.current_track.get().unwrap());
}
for cb in &*self.duration_cbs.borrow() {
cb(self.duration.get());
}
for cb in &*self.playing_cbs.borrow() {
cb(self.is_playing());
}
}
pub fn clear(&self) {
self.player.stop();
self.playing.set(false);
self.current_track.set(None);
self.playlist.replace(Vec::new());
for cb in &*self.playing_cbs.borrow() {
cb(false);
}
for cb in &*self.playlist_cbs.borrow() {
cb(Vec::new());
}
#[cfg(target_os = "linux")]
self.mpris.set_can_play(false);
}
}
/// Generator for new tracks to be appended to the playlist.
pub trait TrackGenerator {
/// Whether the generator will provide a next track if asked.
fn has_next(&self) -> bool;
/// Provide the next track.
///
/// This function should always return at least one track in a state where
/// `has_next()` returns `true`.
fn next(&self) -> Vec<Track>;
}
/// A track generator that generates one random track per call.
pub struct RandomTrackGenerator {
backend: Rc<Backend>,
}
impl RandomTrackGenerator {
pub fn new(backend: Rc<Backend>) -> Self {
Self { backend }
}
}
impl TrackGenerator for RandomTrackGenerator {
fn has_next(&self) -> bool {
true
}
fn next(&self) -> Vec<Track> {
vec![self.backend.db().random_track().unwrap()]
}
}
/// A track generator that returns the tracks of one random recording per call.
pub struct RandomRecordingGenerator {
backend: Rc<Backend>,
}
impl RandomRecordingGenerator {
pub fn new(backend: Rc<Backend>) -> Self {
Self { backend }
}
}
impl TrackGenerator for RandomRecordingGenerator {
fn has_next(&self) -> bool {
true
}
fn next(&self) -> Vec<Track> {
let recording = self.backend.db().random_recording().unwrap();
self.backend.db().get_tracks(&recording.id).unwrap()
}
}

1
crates/database/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
test.sqlite

View file

@ -0,0 +1,13 @@
[package]
name = "musicus_database"
version = "0.1.0"
edition = "2021"
[dependencies]
diesel = { version = "1.4.8", features = ["sqlite"] }
diesel_migrations = "1.4.0"
chrono = "0.4.19"
log = "0.4.16"
rand = "0.8.5"
thiserror = "1.0.31"
uuid = { version = "1.0.0", features = ["v4"] }

View file

@ -0,0 +1,2 @@
[print_schema]
file = "src/schema.rs"

View file

@ -0,0 +1,13 @@
PRAGMA defer_foreign_keys;
DROP TABLE "persons";
DROP TABLE "instruments";
DROP TABLE "works";
DROP TABLE "instrumentations";
DROP TABLE "work_parts";
DROP TABLE "ensembles";
DROP TABLE "recordings";
DROP TABLE "performances";
DROP TABLE "mediums";
DROP TABLE "tracks";

View file

@ -0,0 +1,65 @@
CREATE TABLE "persons" (
"id" TEXT NOT NULL PRIMARY KEY,
"first_name" TEXT NOT NULL,
"last_name" TEXT NOT NULL
);
CREATE TABLE "instruments" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL
);
CREATE TABLE "works" (
"id" TEXT NOT NULL PRIMARY KEY,
"composer" TEXT NOT NULL REFERENCES "persons"("id"),
"title" TEXT NOT NULL
);
CREATE TABLE "instrumentations" (
"id" BIGINT NOT NULL PRIMARY KEY,
"work" TEXT NOT NULL REFERENCES "works"("id") ON DELETE CASCADE,
"instrument" TEXT NOT NULL REFERENCES "instruments"("id") ON DELETE CASCADE
);
CREATE TABLE "work_parts" (
"id" BIGINT NOT NULL PRIMARY KEY,
"work" TEXT NOT NULL REFERENCES "works"("id") ON DELETE CASCADE,
"part_index" BIGINT NOT NULL,
"title" TEXT NOT NULL
);
CREATE TABLE "ensembles" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL
);
CREATE TABLE "recordings" (
"id" TEXT NOT NULL PRIMARY KEY,
"work" TEXT NOT NULL REFERENCES "works"("id"),
"comment" TEXT NOT NULL
);
CREATE TABLE "performances" (
"id" BIGINT NOT NULL PRIMARY KEY,
"recording" TEXT NOT NULL REFERENCES "recordings"("id") ON DELETE CASCADE,
"person" TEXT REFERENCES "persons"("id"),
"ensemble" TEXT REFERENCES "ensembles"("id"),
"role" TEXT REFERENCES "instruments"("id")
);
CREATE TABLE "mediums" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"discid" TEXT
);
CREATE TABLE "tracks" (
"id" TEXT NOT NULL PRIMARY KEY,
"medium" TEXT NOT NULL REFERENCES "mediums"("id") ON DELETE CASCADE,
"index" INTEGER NOT NULL,
"recording" TEXT NOT NULL REFERENCES "recordings"("id"),
"work_parts" TEXT NOT NULL,
"source_index" INTEGER NOT NULL,
"path" TEXT NOT NULL
);

View file

@ -0,0 +1,20 @@
ALTER TABLE "persons" DROP COLUMN "last_used";
ALTER TABLE "persons" DROP COLUMN "last_played";
ALTER TABLE "instruments" DROP COLUMN "last_used";
ALTER TABLE "instruments" DROP COLUMN "last_played";
ALTER TABLE "works" DROP COLUMN "last_used";
ALTER TABLE "works" DROP COLUMN "last_played";
ALTER TABLE "ensembles" DROP COLUMN "last_used";
ALTER TABLE "ensembles" DROP COLUMN "last_played";
ALTER TABLE "recordings" DROP COLUMN "last_used";
ALTER TABLE "recordings" DROP COLUMN "last_played";
ALTER TABLE "mediums" DROP COLUMN "last_used";
ALTER TABLE "mediums" DROP COLUMN "last_played";
ALTER TABLE "tracks" DROP COLUMN "last_used";
ALTER TABLE "tracks" DROP COLUMN "last_played";

View file

@ -0,0 +1,21 @@
ALTER TABLE "persons" ADD COLUMN "last_used" BIGINT;
ALTER TABLE "persons" ADD COLUMN "last_played" BIGINT;
ALTER TABLE "instruments" ADD COLUMN "last_used" BIGINT;
ALTER TABLE "instruments" ADD COLUMN "last_played" BIGINT;
ALTER TABLE "works" ADD COLUMN "last_used" BIGINT;
ALTER TABLE "works" ADD COLUMN "last_played" BIGINT;
ALTER TABLE "ensembles" ADD COLUMN "last_used" BIGINT;
ALTER TABLE "ensembles" ADD COLUMN "last_played" BIGINT;
ALTER TABLE "recordings" ADD COLUMN "last_used" BIGINT;
ALTER TABLE "recordings" ADD COLUMN "last_played" BIGINT;
ALTER TABLE "mediums" ADD COLUMN "last_used" BIGINT;
ALTER TABLE "mediums" ADD COLUMN "last_played" BIGINT;
ALTER TABLE "tracks" ADD COLUMN "last_used" BIGINT;
ALTER TABLE "tracks" ADD COLUMN "last_played" BIGINT;

View file

@ -0,0 +1,76 @@
use super::schema::ensembles;
use super::{Database, Result};
use chrono::Utc;
use diesel::prelude::*;
use log::info;
/// An ensemble that takes part in recordings.
#[derive(Insertable, Queryable, PartialEq, Eq, Hash, Debug, Clone)]
pub struct Ensemble {
pub id: String,
pub name: String,
pub last_used: Option<i64>,
pub last_played: Option<i64>,
}
impl Ensemble {
pub fn new(id: String, name: String) -> Self {
Self {
id,
name,
last_used: Some(Utc::now().timestamp()),
last_played: None,
}
}
}
impl Database {
/// Update an existing ensemble or insert a new one.
pub fn update_ensemble(&self, mut ensemble: Ensemble) -> Result<()> {
info!("Updating ensemble {:?}", ensemble);
self.defer_foreign_keys()?;
ensemble.last_used = Some(Utc::now().timestamp());
self.connection.transaction(|| {
diesel::replace_into(ensembles::table)
.values(ensemble)
.execute(&self.connection)
})?;
Ok(())
}
/// Get an existing ensemble.
pub fn get_ensemble(&self, id: &str) -> Result<Option<Ensemble>> {
let ensemble = ensembles::table
.filter(ensembles::id.eq(id))
.load::<Ensemble>(&self.connection)?
.into_iter()
.next();
Ok(ensemble)
}
/// Delete an existing ensemble.
pub fn delete_ensemble(&self, id: &str) -> Result<()> {
info!("Deleting ensemble {}", id);
diesel::delete(ensembles::table.filter(ensembles::id.eq(id))).execute(&self.connection)?;
Ok(())
}
/// Get all existing ensembles.
pub fn get_ensembles(&self) -> Result<Vec<Ensemble>> {
let ensembles = ensembles::table.load::<Ensemble>(&self.connection)?;
Ok(ensembles)
}
/// Get recently used ensembles.
pub fn get_recent_ensembles(&self) -> Result<Vec<Ensemble>> {
let ensembles = ensembles::table
.order(ensembles::last_used.desc())
.load::<Ensemble>(&self.connection)?;
Ok(ensembles)
}
}

View file

@ -0,0 +1,24 @@
/// Error that happens within the database module.
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error(transparent)]
ConnectionError(#[from] diesel::result::ConnectionError),
#[error(transparent)]
MigrationsError(#[from] diesel_migrations::RunMigrationsError),
#[error(transparent)]
QueryError(#[from] diesel::result::Error),
#[error("Missing item dependency ({0} {1})")]
MissingItem(&'static str, String),
#[error("Failed to parse {0} from '{1}'")]
ParsingError(&'static str, String),
#[error("{0}")]
Other(&'static str),
}
/// Return type for database methods.
pub type Result<T> = std::result::Result<T, Error>;

View file

@ -0,0 +1,79 @@
use super::schema::instruments;
use super::{Database, Result};
use chrono::Utc;
use diesel::prelude::*;
use log::info;
/// An instrument or any other possible role within a recording.
#[derive(Insertable, Queryable, PartialEq, Eq, Hash, Debug, Clone)]
pub struct Instrument {
pub id: String,
pub name: String,
pub last_used: Option<i64>,
pub last_played: Option<i64>,
}
impl Instrument {
pub fn new(id: String, name: String) -> Self {
Self {
id,
name,
last_used: Some(Utc::now().timestamp()),
last_played: None,
}
}
}
impl Database {
/// Update an existing instrument or insert a new one.
pub fn update_instrument(&self, mut instrument: Instrument) -> Result<()> {
info!("Updating instrument {:?}", instrument);
self.defer_foreign_keys()?;
instrument.last_used = Some(Utc::now().timestamp());
self.connection.transaction(|| {
diesel::replace_into(instruments::table)
.values(instrument)
.execute(&self.connection)
})?;
Ok(())
}
/// Get an existing instrument.
pub fn get_instrument(&self, id: &str) -> Result<Option<Instrument>> {
let instrument = instruments::table
.filter(instruments::id.eq(id))
.load::<Instrument>(&self.connection)?
.into_iter()
.next();
Ok(instrument)
}
/// Delete an existing instrument.
pub fn delete_instrument(&self, id: &str) -> Result<()> {
info!("Deleting instrument {}", id);
diesel::delete(instruments::table.filter(instruments::id.eq(id)))
.execute(&self.connection)?;
Ok(())
}
/// Get all existing instruments.
pub fn get_instruments(&self) -> Result<Vec<Instrument>> {
let instruments = instruments::table.load::<Instrument>(&self.connection)?;
Ok(instruments)
}
/// Get recently used instruments.
pub fn get_recent_instruments(&self) -> Result<Vec<Instrument>> {
let instruments = instruments::table
.order(instruments::last_used.desc())
.load::<Instrument>(&self.connection)?;
Ok(instruments)
}
}

View file

@ -0,0 +1,66 @@
// Required for schema.rs
#[macro_use]
extern crate diesel;
// Required for embed_migrations macro in database.rs
#[macro_use]
extern crate diesel_migrations;
use diesel::prelude::*;
use log::info;
pub mod ensembles;
pub use ensembles::*;
pub mod error;
pub use error::*;
pub mod instruments;
pub use instruments::*;
pub mod medium;
pub use medium::*;
pub mod persons;
pub use persons::*;
pub mod recordings;
pub use recordings::*;
pub mod works;
pub use works::*;
mod schema;
// This makes the SQL migration scripts accessible from the code.
embed_migrations!();
/// Generate a random string suitable as an item ID.
pub fn generate_id() -> String {
uuid::Uuid::new_v4().simple().to_string()
}
/// Interface to a Musicus database.
pub struct Database {
connection: SqliteConnection,
}
impl Database {
/// Create a new database interface and run migrations if necessary.
pub fn new(file_name: &str) -> Result<Database> {
info!("Opening database file '{}'", file_name);
let connection = SqliteConnection::establish(file_name)?;
diesel::sql_query("PRAGMA foreign_keys = ON").execute(&connection)?;
info!("Running migrations if necessary");
embedded_migrations::run(&connection)?;
Ok(Database { connection })
}
/// Defer all foreign keys for the next transaction.
fn defer_foreign_keys(&self) -> Result<()> {
diesel::sql_query("PRAGMA defer_foreign_keys = ON").execute(&self.connection)?;
Ok(())
}
}

View file

@ -0,0 +1,337 @@
use super::generate_id;
use super::schema::{ensembles, mediums, performances, persons, recordings, tracks};
use super::{Database, Error, Recording, Result};
use chrono::{DateTime, TimeZone, Utc};
use diesel::prelude::*;
use log::info;
/// Representation of someting like a physical audio disc or a folder with
/// audio files (i.e. a collection of tracks for one or more recordings).
#[derive(PartialEq, Eq, Hash, Debug, Clone)]
pub struct Medium {
/// An unique ID for the medium.
pub id: String,
/// The human identifier for the medium.
pub name: String,
/// If applicable, the MusicBrainz DiscID.
pub discid: Option<String>,
/// The tracks of the medium.
pub tracks: Vec<Track>,
pub last_used: Option<DateTime<Utc>>,
pub last_played: Option<DateTime<Utc>>,
}
impl Medium {
pub fn new(id: String, name: String, discid: Option<String>, tracks: Vec<Track>) -> Self {
Self {
id,
name,
discid,
tracks,
last_used: Some(Utc::now()),
last_played: None,
}
}
}
/// A track on a medium.
#[derive(PartialEq, Eq, Hash, Debug, Clone)]
pub struct Track {
/// The recording on this track.
pub recording: Recording,
/// The work parts that are played on this track. They are indices to the
/// work parts of the work that is associated with the recording.
pub work_parts: Vec<usize>,
/// The index of the track within its source. This is used to associate
/// the metadata with the audio data from the source when importing.
pub source_index: usize,
/// The path to the audio file containing this track.
pub path: String,
pub last_used: Option<DateTime<Utc>>,
pub last_played: Option<DateTime<Utc>>,
}
impl Track {
pub fn new(recording: Recording, work_parts: Vec<usize>, source_index: usize, path: String) -> Self {
Self {
recording,
work_parts,
source_index,
path,
last_used: Some(Utc::now()),
last_played: None,
}
}
}
/// Table data for a [`Medium`].
#[derive(Insertable, Queryable, Debug, Clone)]
#[table_name = "mediums"]
struct MediumRow {
pub id: String,
pub name: String,
pub discid: Option<String>,
pub last_used: Option<i64>,
pub last_played: Option<i64>,
}
/// Table data for a [`Track`].
#[derive(Insertable, Queryable, QueryableByName, Debug, Clone)]
#[table_name = "tracks"]
struct TrackRow {
pub id: String,
pub medium: String,
pub index: i32,
pub recording: String,
pub work_parts: String,
pub source_index: i32,
pub path: String,
pub last_used: Option<i64>,
pub last_played: Option<i64>,
}
impl Database {
/// Update an existing medium or insert a new one.
pub fn update_medium(&self, medium: Medium) -> Result<()> {
info!("Updating medium {:?}", medium);
self.defer_foreign_keys()?;
self.connection.transaction::<(), Error, _>(|| {
let medium_id = &medium.id;
// This will also delete the tracks.
self.delete_medium(medium_id)?;
// Add the new medium.
let medium_row = MediumRow {
id: medium_id.to_owned(),
name: medium.name.clone(),
discid: medium.discid.clone(),
last_used: Some(Utc::now().timestamp()),
last_played: medium.last_played.map(|t| t.timestamp()),
};
diesel::insert_into(mediums::table)
.values(medium_row)
.execute(&self.connection)?;
for (index, track) in medium.tracks.iter().enumerate() {
// Add associated items from the server, if they don't already exist.
if self.get_recording(&track.recording.id)?.is_none() {
self.update_recording(track.recording.clone())?;
}
// Add the actual track data.
let work_parts = track
.work_parts
.iter()
.map(|part_index| part_index.to_string())
.collect::<Vec<String>>()
.join(",");
let track_row = TrackRow {
id: generate_id(),
medium: medium_id.to_owned(),
index: index as i32,
recording: track.recording.id.clone(),
work_parts,
source_index: track.source_index as i32,
path: track.path.clone(),
last_used: Some(Utc::now().timestamp()),
last_played: track.last_played.map(|t| t.timestamp()),
};
diesel::insert_into(tracks::table)
.values(track_row)
.execute(&self.connection)?;
}
Ok(())
})?;
Ok(())
}
/// Get an existing medium.
pub fn get_medium(&self, id: &str) -> Result<Option<Medium>> {
let row = mediums::table
.filter(mediums::id.eq(id))
.load::<MediumRow>(&self.connection)?
.into_iter()
.next();
let medium = match row {
Some(row) => Some(self.get_medium_data(row)?),
None => None,
};
Ok(medium)
}
/// Get mediums that have a specific source ID.
pub fn get_mediums_by_source_id(&self, source_id: &str) -> Result<Vec<Medium>> {
let mut mediums: Vec<Medium> = Vec::new();
let rows = mediums::table
.filter(mediums::discid.nullable().eq(source_id))
.load::<MediumRow>(&self.connection)?;
for row in rows {
let medium = self.get_medium_data(row)?;
mediums.push(medium);
}
Ok(mediums)
}
/// Get mediums on which this person is performing.
pub fn get_mediums_for_person(&self, person_id: &str) -> Result<Vec<Medium>> {
let mut mediums: Vec<Medium> = Vec::new();
let rows = mediums::table
.inner_join(tracks::table.on(tracks::medium.eq(mediums::id)))
.inner_join(recordings::table.on(recordings::id.eq(tracks::recording)))
.inner_join(performances::table.on(performances::recording.eq(recordings::id)))
.inner_join(persons::table.on(persons::id.nullable().eq(performances::person)))
.filter(persons::id.eq(person_id))
.select(mediums::table::all_columns())
.distinct()
.load::<MediumRow>(&self.connection)?;
for row in rows {
let medium = self.get_medium_data(row)?;
mediums.push(medium);
}
Ok(mediums)
}
/// Get mediums on which this ensemble is performing.
pub fn get_mediums_for_ensemble(&self, ensemble_id: &str) -> Result<Vec<Medium>> {
let mut mediums: Vec<Medium> = Vec::new();
let rows = mediums::table
.inner_join(tracks::table.on(tracks::medium.eq(tracks::id)))
.inner_join(recordings::table.on(recordings::id.eq(tracks::recording)))
.inner_join(performances::table.on(performances::recording.eq(recordings::id)))
.inner_join(ensembles::table.on(ensembles::id.nullable().eq(performances::ensemble)))
.filter(ensembles::id.eq(ensemble_id))
.select(mediums::table::all_columns())
.distinct()
.load::<MediumRow>(&self.connection)?;
for row in rows {
let medium = self.get_medium_data(row)?;
mediums.push(medium);
}
Ok(mediums)
}
/// Delete a medium and all of its tracks. This will fail, if the music
/// library contains audio files referencing any of those tracks.
pub fn delete_medium(&self, id: &str) -> Result<()> {
info!("Deleting medium {}", id);
diesel::delete(mediums::table.filter(mediums::id.eq(id))).execute(&self.connection)?;
Ok(())
}
/// Get all available tracks for a recording.
pub fn get_tracks(&self, recording_id: &str) -> Result<Vec<Track>> {
let mut tracks: Vec<Track> = Vec::new();
let rows = tracks::table
.inner_join(recordings::table.on(recordings::id.eq(tracks::recording)))
.filter(recordings::id.eq(recording_id))
.select(tracks::table::all_columns())
.load::<TrackRow>(&self.connection)?;
for row in rows {
let track = self.get_track_from_row(row)?;
tracks.push(track);
}
Ok(tracks)
}
/// Get a random track from the database.
pub fn random_track(&self) -> Result<Track> {
let row = diesel::sql_query("SELECT * FROM tracks ORDER BY RANDOM() LIMIT 1")
.load::<TrackRow>(&self.connection)?
.into_iter()
.next()
.ok_or(Error::Other("Failed to generate random track"))?;
self.get_track_from_row(row)
}
/// Retrieve all available information on a medium from related tables.
fn get_medium_data(&self, row: MediumRow) -> Result<Medium> {
let track_rows = tracks::table
.filter(tracks::medium.eq(&row.id))
.order_by(tracks::index)
.load::<TrackRow>(&self.connection)?;
let mut tracks = Vec::new();
for track_row in track_rows {
let track = self.get_track_from_row(track_row)?;
tracks.push(track);
}
let medium = Medium {
id: row.id,
name: row.name,
discid: row.discid,
tracks,
last_used: row.last_used.map(|t| Utc.timestamp(t, 0)),
last_played: row.last_played.map(|t| Utc.timestamp(t, 0)),
};
Ok(medium)
}
/// Convert a track row from the database to an actual track.
fn get_track_from_row(&self, row: TrackRow) -> Result<Track> {
let recording_id = row.recording;
let recording = self
.get_recording(&recording_id)?
.ok_or(Error::MissingItem("recording", recording_id))?;
let mut part_indices = Vec::new();
let work_parts = row.work_parts.split(',');
for part_index in work_parts {
if !part_index.is_empty() {
let index = str::parse(part_index)
.map_err(|_| Error::ParsingError("part index", String::from(part_index)))?;
part_indices.push(index);
}
}
let track = Track {
recording,
work_parts: part_indices,
source_index: row.source_index as usize,
path: row.path,
last_used: row.last_used.map(|t| Utc.timestamp(t, 0)),
last_played: row.last_played.map(|t| Utc.timestamp(t, 0)),
};
Ok(track)
}
}

View file

@ -0,0 +1,89 @@
use super::schema::persons;
use super::{Database, Result};
use chrono::Utc;
use diesel::prelude::*;
use log::info;
/// A person that is a composer, an interpret or both.
#[derive(Insertable, Queryable, PartialEq, Eq, Hash, Debug, Clone)]
pub struct Person {
pub id: String,
pub first_name: String,
pub last_name: String,
pub last_used: Option<i64>,
pub last_played: Option<i64>,
}
impl Person {
pub fn new(id: String, first_name: String, last_name: String) -> Self {
Self {
id,
first_name,
last_name,
last_used: Some(Utc::now().timestamp()),
last_played: None,
}
}
/// Get the full name in the form "First Last".
pub fn name_fl(&self) -> String {
format!("{} {}", self.first_name, self.last_name)
}
/// Get the full name in the form "Last, First".
pub fn name_lf(&self) -> String {
format!("{}, {}", self.last_name, self.first_name)
}
}
impl Database {
/// Update an existing person or insert a new one.
pub fn update_person(&self, mut person: Person) -> Result<()> {
info!("Updating person {:?}", person);
self.defer_foreign_keys()?;
person.last_used = Some(Utc::now().timestamp());
self.connection.transaction(|| {
diesel::replace_into(persons::table)
.values(person)
.execute(&self.connection)
})?;
Ok(())
}
/// Get an existing person.
pub fn get_person(&self, id: &str) -> Result<Option<Person>> {
let person = persons::table
.filter(persons::id.eq(id))
.load::<Person>(&self.connection)?
.into_iter()
.next();
Ok(person)
}
/// Delete an existing person.
pub fn delete_person(&self, id: &str) -> Result<()> {
info!("Deleting person {}", id);
diesel::delete(persons::table.filter(persons::id.eq(id))).execute(&self.connection)?;
Ok(())
}
/// Get all existing persons.
pub fn get_persons(&self) -> Result<Vec<Person>> {
let persons = persons::table.load::<Person>(&self.connection)?;
Ok(persons)
}
/// Get recently used persons.
pub fn get_recent_persons(&self) -> Result<Vec<Person>> {
let persons = persons::table
.order(persons::last_used.desc())
.load::<Person>(&self.connection)?;
Ok(persons)
}
}

View file

@ -0,0 +1,344 @@
use super::generate_id;
use super::schema::{ensembles, performances, persons, recordings};
use super::{Database, Ensemble, Error, Instrument, Person, Result, Work};
use chrono::{DateTime, TimeZone, Utc};
use diesel::prelude::*;
use log::info;
/// A specific recording of a work.
#[derive(PartialEq, Eq, Hash, Debug, Clone)]
pub struct Recording {
pub id: String,
pub work: Work,
pub comment: String,
pub performances: Vec<Performance>,
pub last_used: Option<DateTime<Utc>>,
pub last_played: Option<DateTime<Utc>>,
}
impl Recording {
pub fn new(id: String, work: Work, comment: String, performances: Vec<Performance>) -> Self {
Self {
id,
work,
comment,
performances,
last_used: Some(Utc::now()),
last_played: None,
}
}
/// Initialize a new recording with a work.
pub fn from_work(work: Work) -> Self {
Self {
id: generate_id(),
work,
comment: String::new(),
performances: Vec::new(),
last_used: Some(Utc::now()),
last_played: None,
}
}
/// Get a string representation of the performances in this recording.
// TODO: Maybe replace with impl Display?
pub fn get_performers(&self) -> String {
let texts: Vec<String> = self
.performances
.iter()
.map(|performance| performance.get_title())
.collect();
texts.join(", ")
}
}
/// How a person or ensemble was involved in a recording.
#[derive(PartialEq, Eq, Hash, Debug, Clone)]
pub struct Performance {
pub performer: PersonOrEnsemble,
pub role: Option<Instrument>,
}
impl Performance {
/// Get a string representation of the performance.
// TODO: Replace with impl Display.
pub fn get_title(&self) -> String {
let performer_title = self.performer.get_title();
if let Some(role) = &self.role {
format!("{} ({})", performer_title, role.name)
} else {
performer_title
}
}
}
/// Either a person or an ensemble.
#[derive(PartialEq, Eq, Hash, Clone, Debug)]
pub enum PersonOrEnsemble {
Person(Person),
Ensemble(Ensemble),
}
impl PersonOrEnsemble {
/// Get a short textual representation of the item.
pub fn get_title(&self) -> String {
match self {
PersonOrEnsemble::Person(person) => person.name_lf(),
PersonOrEnsemble::Ensemble(ensemble) => ensemble.name.clone(),
}
}
}
/// Database table data for a recording.
#[derive(Insertable, Queryable, QueryableByName, Debug, Clone)]
#[table_name = "recordings"]
struct RecordingRow {
pub id: String,
pub work: String,
pub comment: String,
pub last_used: Option<i64>,
pub last_played: Option<i64>,
}
impl From<Recording> for RecordingRow {
fn from(recording: Recording) -> Self {
RecordingRow {
id: recording.id,
work: recording.work.id,
comment: recording.comment,
last_used: Some(Utc::now().timestamp()),
last_played: recording.last_played.map(|t| t.timestamp()),
}
}
}
/// Database table data for a performance.
#[derive(Insertable, Queryable, Debug, Clone)]
#[table_name = "performances"]
struct PerformanceRow {
pub id: i64,
pub recording: String,
pub person: Option<String>,
pub ensemble: Option<String>,
pub role: Option<String>,
}
impl Database {
/// Update an existing recording or insert a new one.
// TODO: Think about whether to also insert the other items.
pub fn update_recording(&self, recording: Recording) -> Result<()> {
info!("Updating recording {:?}", recording);
self.defer_foreign_keys()?;
self.connection.transaction::<(), Error, _>(|| {
let recording_id = &recording.id;
self.delete_recording(recording_id)?;
// Add associated items from the server, if they don't already exist.
if self.get_work(&recording.work.id)?.is_none() {
self.update_work(recording.work.clone())?;
}
for performance in &recording.performances {
match &performance.performer {
PersonOrEnsemble::Person(person) => {
if self.get_person(&person.id)?.is_none() {
self.update_person(person.clone())?;
}
}
PersonOrEnsemble::Ensemble(ensemble) => {
if self.get_ensemble(&ensemble.id)?.is_none() {
self.update_ensemble(ensemble.clone())?;
}
}
}
if let Some(role) = &performance.role {
if self.get_instrument(&role.id)?.is_none() {
self.update_instrument(role.clone())?;
}
}
}
// Add the actual recording.
let row: RecordingRow = recording.clone().into();
diesel::insert_into(recordings::table)
.values(row)
.execute(&self.connection)?;
for performance in recording.performances {
let (person, ensemble) = match performance.performer {
PersonOrEnsemble::Person(person) => (Some(person.id), None),
PersonOrEnsemble::Ensemble(ensemble) => (None, Some(ensemble.id)),
};
let row = PerformanceRow {
id: rand::random(),
recording: recording_id.to_string(),
person,
ensemble,
role: performance.role.map(|role| role.id),
};
diesel::insert_into(performances::table)
.values(row)
.execute(&self.connection)?;
}
Ok(())
})?;
Ok(())
}
/// Check whether the database contains a recording.
pub fn recording_exists(&self, id: &str) -> Result<bool> {
let exists = recordings::table
.filter(recordings::id.eq(id))
.load::<RecordingRow>(&self.connection)?
.first()
.is_some();
Ok(exists)
}
/// Get an existing recording.
pub fn get_recording(&self, id: &str) -> Result<Option<Recording>> {
let row = recordings::table
.filter(recordings::id.eq(id))
.load::<RecordingRow>(&self.connection)?
.into_iter()
.next();
let recording = match row {
Some(row) => Some(self.get_recording_data(row)?),
None => None,
};
Ok(recording)
}
/// Get a random recording from the database.
pub fn random_recording(&self) -> Result<Recording> {
let row = diesel::sql_query("SELECT * FROM recordings ORDER BY RANDOM() LIMIT 1")
.load::<RecordingRow>(&self.connection)?
.into_iter()
.next()
.ok_or(Error::Other("Failed to find random recording."))?;
self.get_recording_data(row)
}
/// Retrieve all available information on a recording from related tables.
fn get_recording_data(&self, row: RecordingRow) -> Result<Recording> {
let mut performance_descriptions: Vec<Performance> = Vec::new();
let performance_rows = performances::table
.filter(performances::recording.eq(&row.id))
.load::<PerformanceRow>(&self.connection)?;
for row in performance_rows {
performance_descriptions.push(Performance {
performer: if let Some(id) = row.person {
PersonOrEnsemble::Person(
self.get_person(&id)?
.ok_or(Error::MissingItem("person", id))?,
)
} else if let Some(id) = row.ensemble {
PersonOrEnsemble::Ensemble(
self.get_ensemble(&id)?
.ok_or(Error::MissingItem("ensemble", id))?,
)
} else {
return Err(Error::Other("Performance without performer"));
},
role: match row.role {
Some(id) => Some(
self.get_instrument(&id)?
.ok_or(Error::MissingItem("instrument", id))?,
),
None => None,
},
});
}
let work_id = row.work;
let work = self
.get_work(&work_id)?
.ok_or(Error::MissingItem("work", work_id))?;
let recording_description = Recording {
id: row.id,
work,
comment: row.comment,
performances: performance_descriptions,
last_used: row.last_used.map(|t| Utc.timestamp(t, 0)),
last_played: row.last_played.map(|t| Utc.timestamp(t, 0)),
};
Ok(recording_description)
}
/// Get all available information on all recordings where a person is performing.
pub fn get_recordings_for_person(&self, person_id: &str) -> Result<Vec<Recording>> {
let mut recordings: Vec<Recording> = Vec::new();
let rows = recordings::table
.inner_join(performances::table.on(performances::recording.eq(recordings::id)))
.inner_join(persons::table.on(persons::id.nullable().eq(performances::person)))
.filter(persons::id.eq(person_id))
.select(recordings::table::all_columns())
.load::<RecordingRow>(&self.connection)?;
for row in rows {
recordings.push(self.get_recording_data(row)?);
}
Ok(recordings)
}
/// Get all available information on all recordings where an ensemble is performing.
pub fn get_recordings_for_ensemble(&self, ensemble_id: &str) -> Result<Vec<Recording>> {
let mut recordings: Vec<Recording> = Vec::new();
let rows = recordings::table
.inner_join(performances::table.on(performances::recording.eq(recordings::id)))
.inner_join(ensembles::table.on(ensembles::id.nullable().eq(performances::ensemble)))
.filter(ensembles::id.eq(ensemble_id))
.select(recordings::table::all_columns())
.load::<RecordingRow>(&self.connection)?;
for row in rows {
recordings.push(self.get_recording_data(row)?);
}
Ok(recordings)
}
/// Get allavailable information on all recordings of a work.
pub fn get_recordings_for_work(&self, work_id: &str) -> Result<Vec<Recording>> {
let mut recordings: Vec<Recording> = Vec::new();
let rows = recordings::table
.filter(recordings::work.eq(work_id))
.load::<RecordingRow>(&self.connection)?;
for row in rows {
recordings.push(self.get_recording_data(row)?);
}
Ok(recordings)
}
/// Delete an existing recording. This will fail if there are still references to this
/// recording from other tables that are not directly part of the recording data.
pub fn delete_recording(&self, id: &str) -> Result<()> {
info!("Deleting recording {}", id);
diesel::delete(recordings::table.filter(recordings::id.eq(id)))
.execute(&self.connection)?;
Ok(())
}
}

View file

@ -0,0 +1,123 @@
table! {
ensembles (id) {
id -> Text,
name -> Text,
last_used -> Nullable<BigInt>,
last_played -> Nullable<BigInt>,
}
}
table! {
instrumentations (id) {
id -> BigInt,
work -> Text,
instrument -> Text,
}
}
table! {
instruments (id) {
id -> Text,
name -> Text,
last_used -> Nullable<BigInt>,
last_played -> Nullable<BigInt>,
}
}
table! {
mediums (id) {
id -> Text,
name -> Text,
discid -> Nullable<Text>,
last_used -> Nullable<BigInt>,
last_played -> Nullable<BigInt>,
}
}
table! {
performances (id) {
id -> BigInt,
recording -> Text,
person -> Nullable<Text>,
ensemble -> Nullable<Text>,
role -> Nullable<Text>,
}
}
table! {
persons (id) {
id -> Text,
first_name -> Text,
last_name -> Text,
last_used -> Nullable<BigInt>,
last_played -> Nullable<BigInt>,
}
}
table! {
recordings (id) {
id -> Text,
work -> Text,
comment -> Text,
last_used -> Nullable<BigInt>,
last_played -> Nullable<BigInt>,
}
}
table! {
tracks (id) {
id -> Text,
medium -> Text,
index -> Integer,
recording -> Text,
work_parts -> Text,
source_index -> Integer,
path -> Text,
last_used -> Nullable<BigInt>,
last_played -> Nullable<BigInt>,
}
}
table! {
work_parts (id) {
id -> BigInt,
work -> Text,
part_index -> BigInt,
title -> Text,
}
}
table! {
works (id) {
id -> Text,
composer -> Text,
title -> Text,
last_used -> Nullable<BigInt>,
last_played -> Nullable<BigInt>,
}
}
joinable!(instrumentations -> instruments (instrument));
joinable!(instrumentations -> works (work));
joinable!(performances -> ensembles (ensemble));
joinable!(performances -> instruments (role));
joinable!(performances -> persons (person));
joinable!(performances -> recordings (recording));
joinable!(recordings -> works (work));
joinable!(tracks -> mediums (medium));
joinable!(tracks -> recordings (recording));
joinable!(work_parts -> works (work));
joinable!(works -> persons (composer));
allow_tables_to_appear_in_same_query!(
ensembles,
instrumentations,
instruments,
mediums,
performances,
persons,
recordings,
tracks,
work_parts,
works,
);

View file

@ -0,0 +1,249 @@
use super::generate_id;
use super::schema::{instrumentations, work_parts, works};
use super::{Database, Error, Instrument, Person, Result};
use chrono::{DateTime, TimeZone, Utc};
use diesel::prelude::*;
use diesel::{Insertable, Queryable};
use log::info;
/// Table row data for a work.
#[derive(Insertable, Queryable, Debug, Clone)]
#[table_name = "works"]
struct WorkRow {
pub id: String,
pub composer: String,
pub title: String,
pub last_used: Option<i64>,
pub last_played: Option<i64>,
}
impl From<Work> for WorkRow {
fn from(work: Work) -> Self {
WorkRow {
id: work.id,
composer: work.composer.id,
title: work.title,
last_used: Some(Utc::now().timestamp()),
last_played: work.last_played.map(|t| t.timestamp()),
}
}
}
/// Definition that a work uses an instrument.
#[derive(Insertable, Queryable, Debug, Clone)]
#[table_name = "instrumentations"]
struct InstrumentationRow {
pub id: i64,
pub work: String,
pub instrument: String,
}
/// Table row data for a work part.
#[derive(Insertable, Queryable, Debug, Clone)]
#[table_name = "work_parts"]
struct WorkPartRow {
pub id: i64,
pub work: String,
pub part_index: i64,
pub title: String,
}
/// A concrete work part that can be recorded.
#[derive(PartialEq, Eq, Hash, Debug, Clone)]
pub struct WorkPart {
pub title: String,
}
/// A specific work by a composer.
#[derive(PartialEq, Eq, Hash, Debug, Clone)]
pub struct Work {
pub id: String,
pub title: String,
pub composer: Person,
pub instruments: Vec<Instrument>,
pub parts: Vec<WorkPart>,
pub last_used: Option<DateTime<Utc>>,
pub last_played: Option<DateTime<Utc>>,
}
impl Work {
pub fn new(id: String, title: String, composer: Person, instruments: Vec<Instrument>, parts: Vec<WorkPart>) -> Self {
Self {
id,
title,
composer,
instruments,
parts,
last_used: Some(Utc::now()),
last_played: None,
}
}
/// Initialize a new work with a composer.
pub fn from_composer(composer: Person) -> Self {
Self {
id: generate_id(),
title: String::new(),
composer,
instruments: Vec::new(),
parts: Vec::new(),
last_used: Some(Utc::now()),
last_played: None,
}
}
/// Get a string including the composer and title of the work.
// TODO: Replace with impl Display.
pub fn get_title(&self) -> String {
format!("{}: {}", self.composer.name_fl(), self.title)
}
}
impl Database {
/// Update an existing work or insert a new one.
// TODO: Think about also inserting related items.
pub fn update_work(&self, work: Work) -> Result<()> {
info!("Updating work {:?}", work);
self.defer_foreign_keys()?;
self.connection.transaction::<(), Error, _>(|| {
let work_id = &work.id;
self.delete_work(work_id)?;
// Add associated items from the server, if they don't already exist.
if self.get_person(&work.composer.id)?.is_none() {
self.update_person(work.composer.clone())?;
}
for instrument in &work.instruments {
if self.get_instrument(&instrument.id)?.is_none() {
self.update_instrument(instrument.clone())?;
}
}
// Add the actual work.
let row: WorkRow = work.clone().into();
diesel::insert_into(works::table)
.values(row)
.execute(&self.connection)?;
let Work {
instruments, parts, ..
} = work;
for instrument in instruments {
let row = InstrumentationRow {
id: rand::random(),
work: work_id.to_string(),
instrument: instrument.id,
};
diesel::insert_into(instrumentations::table)
.values(row)
.execute(&self.connection)?;
}
for (index, part) in parts.into_iter().enumerate() {
let row = WorkPartRow {
id: rand::random(),
work: work_id.to_string(),
part_index: index as i64,
title: part.title,
};
diesel::insert_into(work_parts::table)
.values(row)
.execute(&self.connection)?;
}
Ok(())
})?;
Ok(())
}
/// Get an existing work.
pub fn get_work(&self, id: &str) -> Result<Option<Work>> {
let row = works::table
.filter(works::id.eq(id))
.load::<WorkRow>(&self.connection)?
.first()
.cloned();
let work = match row {
Some(row) => Some(self.get_work_data(row)?),
None => None,
};
Ok(work)
}
/// Retrieve all available information on a work from related tables.
fn get_work_data(&self, row: WorkRow) -> Result<Work> {
let mut instruments: Vec<Instrument> = Vec::new();
let instrumentations = instrumentations::table
.filter(instrumentations::work.eq(&row.id))
.load::<InstrumentationRow>(&self.connection)?;
for instrumentation in instrumentations {
let id = instrumentation.instrument;
instruments.push(
self.get_instrument(&id)?
.ok_or(Error::MissingItem("instrument", id))?,
);
}
let mut parts: Vec<WorkPart> = Vec::new();
let part_rows = work_parts::table
.filter(work_parts::work.eq(&row.id))
.load::<WorkPartRow>(&self.connection)?;
for part_row in part_rows {
parts.push(WorkPart {
title: part_row.title,
});
}
let person_id = row.composer;
let person = self
.get_person(&person_id)?
.ok_or(Error::MissingItem("person", person_id))?;
Ok(Work {
id: row.id,
composer: person,
title: row.title,
instruments,
parts,
last_used: row.last_used.map(|t| Utc.timestamp(t, 0)),
last_played: row.last_played.map(|t| Utc.timestamp(t, 0)),
})
}
/// Delete an existing work. This will fail if there are still other tables that relate to
/// this work except for the things that are part of the information on the work it
pub fn delete_work(&self, id: &str) -> Result<()> {
info!("Deleting work {}", id);
diesel::delete(works::table.filter(works::id.eq(id))).execute(&self.connection)?;
Ok(())
}
/// Get all existing works by a composer and related information from other tables.
pub fn get_works(&self, composer_id: &str) -> Result<Vec<Work>> {
let mut works: Vec<Work> = Vec::new();
let rows = works::table
.filter(works::composer.eq(composer_id))
.load::<WorkRow>(&self.connection)?;
for row in rows {
works.push(self.get_work_data(row)?);
}
Ok(works)
}
}

16
crates/import/Cargo.toml Normal file
View file

@ -0,0 +1,16 @@
[package]
name = "musicus_import"
version = "0.1.0"
edition = "2021"
[dependencies]
base64 = "0.13.0"
glib = "0.15.11"
gstreamer = "0.18.8"
gstreamer-pbutils = "0.18.7"
log = "0.4.16"
once_cell = "1.10.0"
rand = "0.8.5"
thiserror = "1.0.31"
sha2 = "0.10.2"
tokio = { version = "1.18.0", features = ["sync"] }

174
crates/import/src/disc.rs Normal file
View file

@ -0,0 +1,174 @@
use crate::error::{Error, Result};
use crate::session::{ImportSession, ImportTrack, State};
use gstreamer::prelude::*;
use gstreamer::tags::{Duration, TrackNumber};
use gstreamer::{ClockTime, ElementFactory, MessageType, MessageView, TocEntryType};
use log::info;
use sha2::{Digest, Sha256};
use std::path::PathBuf;
use tokio::sync::watch;
/// Create a new import session for the default disc drive.
pub(super) fn new() -> Result<ImportSession> {
let (state_sender, state_receiver) = watch::channel(State::Waiting);
let mut tracks = Vec::new();
let mut hasher = Sha256::new();
// Build the GStreamer pipeline. It will contain a fakesink initially to be able to run it
// forward to the paused state without specifying a file name before knowing the tracks.
let cdparanoiasrc = ElementFactory::make("cdparanoiasrc", None)?;
let queue = ElementFactory::make("queue", None)?;
let audioconvert = ElementFactory::make("audioconvert", None)?;
let flacenc = ElementFactory::make("flacenc", None)?;
let fakesink = gstreamer::ElementFactory::make("fakesink", None)?;
let pipeline = gstreamer::Pipeline::new(None);
pipeline.add_many(&[&cdparanoiasrc, &queue, &audioconvert, &flacenc, &fakesink])?;
gstreamer::Element::link_many(&[&cdparanoiasrc, &queue, &audioconvert, &flacenc, &fakesink])?;
let bus = pipeline
.bus()
.ok_or_else(|| Error::u(String::from("Failed to get bus from pipeline.")))?;
// Run the pipeline into the paused state and wait for the resulting TOC message on the bus.
pipeline.set_state(gstreamer::State::Paused)?;
let msg = bus.timed_pop_filtered(
ClockTime::from_seconds(5),
&[MessageType::Toc, MessageType::Error],
);
let toc = match msg {
Some(msg) => match msg.view() {
MessageView::Error(err) => Err(Error::os(err.error())),
MessageView::Toc(toc) => Ok(toc.toc().0),
_ => Err(Error::u(format!(
"Unexpected message from GStreamer: {:?}",
msg
))),
},
None => Err(Error::Timeout(
"Timeout while waiting for first message from GStreamer.".to_string(),
)),
}?;
pipeline.set_state(gstreamer::State::Ready)?;
// Replace the fakesink with the real filesink. This won't need to be synced to the pipeline
// state, because we will set the whole pipeline's state to playing later.
gstreamer::Element::unlink(&flacenc, &fakesink);
fakesink.set_state(gstreamer::State::Null)?;
pipeline.remove(&fakesink)?;
let filesink = gstreamer::ElementFactory::make("filesink", None)?;
pipeline.add(&filesink)?;
gstreamer::Element::link(&flacenc, &filesink)?;
// Get track data from the toc message that was received above.
let tmp_dir = create_tmp_dir()?;
for entry in toc.entries() {
if entry.entry_type() == TocEntryType::Track {
let duration = entry
.tags()
.ok_or_else(|| Error::u(String::from("No tags in TOC entry.")))?
.get::<Duration>()
.ok_or_else(|| Error::u(String::from("No duration tag found in TOC entry.")))?
.get()
.mseconds();
let number = entry
.tags()
.ok_or_else(|| Error::u(String::from("No tags in TOC entry.")))?
.get::<TrackNumber>()
.ok_or_else(|| Error::u(String::from("No track number tag found in TOC entry.")))?
.get();
hasher.update(duration.to_le_bytes());
let name = format!("Track {}", number);
let file_name = format!("track_{:02}.flac", number);
let mut path = tmp_dir.clone();
path.push(file_name);
let track = ImportTrack {
number,
name,
path,
duration,
};
tracks.push(track);
}
}
let source_id = base64::encode_config(hasher.finalize(), base64::URL_SAFE);
info!("Successfully loaded audio CD with {} tracks.", tracks.len());
info!("Source ID: {}", source_id);
let tracks_clone = tracks.clone();
let copy = move || {
for track in &tracks_clone {
info!("Starting to rip track {}.", track.number);
cdparanoiasrc.set_property("track", &track.number);
// The filesink needs to be reset to be able to change the file location.
filesink.set_state(gstreamer::State::Null)?;
let path = track.path.to_str().unwrap();
filesink.set_property("location", &path);
// This will also affect the filesink as expected.
pipeline.set_state(gstreamer::State::Playing)?;
for msg in bus.iter_timed(None) {
match msg.view() {
MessageView::Eos(..) => {
info!("Finished ripping track {}.", track.number);
pipeline.set_state(gstreamer::State::Ready)?;
break;
}
MessageView::Error(err) => {
pipeline.set_state(gstreamer::State::Null)?;
return Err(Error::os(err.error()));
}
_ => (),
}
}
}
pipeline.set_state(gstreamer::State::Null)?;
Ok(())
};
let session = ImportSession {
source_id,
tracks,
copy: Some(Box::new(copy)),
state_sender,
state_receiver,
};
Ok(session)
}
/// Create a new temporary directory and return its path.
fn create_tmp_dir() -> Result<PathBuf> {
let mut tmp_dir = glib::tmp_dir();
let dir_name = format!("musicus-{}", rand::random::<u64>());
tmp_dir.push(dir_name);
std::fs::create_dir(&tmp_dir)?;
Ok(tmp_dir)
}

View file

@ -0,0 +1,84 @@
use std::error;
/// An error within an import session.
#[derive(thiserror::Error, Debug)]
pub enum Error {
/// A timeout was reached.
#[error("{0}")]
Timeout(String),
/// Some common error.
#[error("{msg}")]
Other {
/// The error message.
msg: String,
#[source]
source: Option<Box<dyn error::Error + Send + Sync>>,
},
/// Something unexpected happened.
#[error("{msg}")]
Unexpected {
/// The error message.
msg: String,
#[source]
source: Option<Box<dyn error::Error + Send + Sync>>,
},
}
impl Error {
/// Create a new error with an explicit source.
pub(super) fn os(source: impl error::Error + Send + Sync + 'static) -> Self {
Self::Unexpected {
msg: format!("An error has happened: {}", source),
source: Some(Box::new(source)),
}
}
/// Create a new unexpected error without an explicit source.
pub(super) fn u(msg: String) -> Self {
Self::Unexpected { msg, source: None }
}
/// Create a new unexpected error with an explicit source.
pub(super) fn us(source: impl error::Error + Send + Sync + 'static) -> Self {
Self::Unexpected {
msg: format!("An unexpected error has happened: {}", source),
source: Some(Box::new(source)),
}
}
}
impl From<tokio::sync::oneshot::error::RecvError> for Error {
fn from(err: tokio::sync::oneshot::error::RecvError) -> Self {
Self::us(err)
}
}
impl From<gstreamer::glib::Error> for Error {
fn from(err: gstreamer::glib::Error) -> Self {
Self::us(err)
}
}
impl From<gstreamer::glib::BoolError> for Error {
fn from(err: gstreamer::glib::BoolError) -> Self {
Self::us(err)
}
}
impl From<gstreamer::StateChangeError> for Error {
fn from(err: gstreamer::StateChangeError) -> Self {
Self::us(err)
}
}
impl From<std::io::Error> for Error {
fn from(err: std::io::Error) -> Self {
Self::us(err)
}
}
pub type Result<T> = std::result::Result<T, Error>;

View file

@ -0,0 +1,80 @@
use crate::error::{Error, Result};
use crate::session::{ImportSession, ImportTrack, State};
use gstreamer::ClockTime;
use gstreamer_pbutils::Discoverer;
use log::{info, warn};
use sha2::{Digest, Sha256};
use std::fs::DirEntry;
use std::path::PathBuf;
use tokio::sync::watch;
/// Create a new import session for the specified folder.
pub(super) fn new(path: PathBuf) -> Result<ImportSession> {
let (state_sender, state_receiver) = watch::channel(State::Ready);
let mut tracks = Vec::new();
let mut number: u32 = 1;
let mut hasher = Sha256::new();
let discoverer = Discoverer::new(ClockTime::from_seconds(1))?;
let mut entries =
std::fs::read_dir(path)?.collect::<std::result::Result<Vec<DirEntry>, std::io::Error>>()?;
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
if entry.file_type()?.is_file() {
let path = entry.path();
let uri = glib::filename_to_uri(&path, None)
.map_err(|_| Error::u(format!("Failed to create URI from path: {:?}", path)))?;
let info = discoverer.discover_uri(&uri)?;
if !info.audio_streams().is_empty() {
let duration = info
.duration()
.ok_or_else(|| Error::u(format!("Failed to get duration for {}.", uri)))?
.mseconds();
let file_name = entry.file_name();
let name = file_name.into_string().map_err(|_| {
Error::u(format!(
"Failed to convert OsString to String: {:?}",
entry.file_name()
))
})?;
hasher.update(duration.to_le_bytes());
let track = ImportTrack {
number,
name,
path,
duration,
};
tracks.push(track);
number += 1;
} else {
warn!(
"File {} skipped, because it doesn't contain any audio streams.",
uri
);
}
}
}
let source_id = base64::encode_config(hasher.finalize(), base64::URL_SAFE);
info!("Source ID: {}", source_id);
let session = ImportSession {
source_id,
tracks,
copy: None,
state_sender,
state_receiver,
};
Ok(session)
}

8
crates/import/src/lib.rs Normal file
View file

@ -0,0 +1,8 @@
pub use error::{Error, Result};
pub use session::{ImportSession, ImportTrack, State};
pub mod error;
pub mod session;
mod disc;
mod folder;

View file

@ -0,0 +1,127 @@
use crate::error::Result;
use crate::{disc, folder};
use std::path::PathBuf;
use std::sync::Arc;
use std::thread;
use tokio::sync::{oneshot, watch};
/// The current state of the import process.
#[derive(Clone, Debug)]
pub enum State {
/// The import process has not been started yet.
Waiting,
/// The audio is copied from the source.
Copying,
/// The audio files are ready to be imported into the music library.
Ready,
/// An error has happened.
Error,
}
/// Interface for importing audio tracks from a medium or folder.
pub struct ImportSession {
/// A string identifying the source as specific as possible across platforms and formats.
pub(super) source_id: String,
/// The tracks that are available on the source.
pub(super) tracks: Vec<ImportTrack>,
/// A closure that has to be called to copy the tracks if set.
pub(super) copy: Option<Box<dyn Fn() -> Result<()> + Send + Sync>>,
/// Sender through which listeners are notified of state changes.
pub(super) state_sender: watch::Sender<State>,
/// Receiver for state changes.
pub(super) state_receiver: watch::Receiver<State>,
}
impl ImportSession {
/// Create a new import session for an audio CD.
pub async fn audio_cd() -> Result<Arc<Self>> {
let (sender, receiver) = oneshot::channel();
thread::spawn(move || {
let result = disc::new();
let _ = sender.send(result);
});
Ok(Arc::new(receiver.await??))
}
/// Create a new import session for a folder.
pub async fn folder(path: PathBuf) -> Result<Arc<Self>> {
let (sender, receiver) = oneshot::channel();
thread::spawn(move || {
let result = folder::new(path);
let _ = sender.send(result);
});
Ok(Arc::new(receiver.await??))
}
/// Get a string identifying the source as specific as possible across platforms and mediums.
pub fn source_id(&self) -> &str {
&self.source_id
}
/// Get the tracks that are available on the source.
pub fn tracks(&self) -> &[ImportTrack] {
&self.tracks
}
/// Retrieve the current state of the import process.
pub fn state(&self) -> State {
self.state_receiver.borrow().clone()
}
/// Wait for the next state change and get the new state.
pub async fn state_change(&self) -> State {
let mut receiver = self.state_receiver.clone();
match receiver.changed().await {
Ok(()) => self.state(),
Err(_) => State::Error,
}
}
/// Copy the tracks to their advertised locations in the background, if neccessary. The state
/// will be updated as the import is done.
pub fn copy(self: &Arc<Self>) {
if self.copy.is_some() {
let clone = Arc::clone(self);
thread::spawn(move || {
let copy = clone.copy.as_ref().unwrap();
match copy() {
Ok(()) => clone.state_sender.send(State::Ready).unwrap(),
Err(_) => clone.state_sender.send(State::Error).unwrap(),
}
});
}
}
}
/// A track on an import source.
#[derive(Clone, Debug)]
pub struct ImportTrack {
/// The track number.
pub number: u32,
/// A human readable identifier for the track. This will be used to present the track for
/// selection.
pub name: String,
/// The path to the file where the corresponding audio file is. This file is only required to
/// exist, once the import was successfully completed. This will not be the actual file within
/// the user's music library, but the temporary location from which it can be copied to the
/// music library.
pub path: PathBuf,
/// The track's duration in milliseconds.
pub duration: u64,
}

2
crates/musicus/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/src/config.rs
/src/resources.rs

20
crates/musicus/Cargo.toml Normal file
View file

@ -0,0 +1,20 @@
[package]
name = "musicus"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1.0.57"
adw = { git = "https://gitlab.gnome.org/World/Rust/libadwaita-rs.git", package = "libadwaita", features = ["v1_2"] }
futures-channel = "0.3.21"
gettext-rs = { version = "0.7.0", features = ["gettext-system"] }
gio = {git = "https://github.com/gtk-rs/gtk-rs-core"}
glib = {git = "https://github.com/gtk-rs/gtk-rs-core"}
gstreamer = "0.18.8"
gtk = { git = "https://github.com/gtk-rs/gtk4-rs.git", package = "gtk4" }
gtk-macros = "0.3.0"
log = "0.4.16"
musicus_backend = { version = "0.1.0", path = "../backend" }
once_cell = "1.10.0"
rand = "0.8.5"
sanitize-filename = "0.3.0"

View file

@ -0,0 +1,8 @@
[Desktop Entry]
Name=Musicus
Icon=de.johrpan.musicus
Exec=musicus
Terminal=false
Type=Application
Categories=GTK;
StartupNotify=true

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<schemalist gettext-domain="musicus">
<schema id="de.johrpan.musicus" path="/de/johrpan/musicus/">
<key name="music-library-path" type="s">
<default>""</default>
<summary>Path to the music library folder</summary>
</key>
<key name="keep-playing" type="b">
<default>false</default>
<summary>Keep playing after the playlist ends</summary>
</key>
<key name="play-full-recordings" type="b">
<default>true</default>
<summary>Choose full recordings for random playback</summary>
</key>
</schema>
</schemalist>

View file

@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="128"
height="128"
viewBox="0 0 33.866666 33.866668"
version="1.1"
id="svg8"
inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)"
sodipodi:docname="musicus.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="3.6046466"
inkscape:cx="31.109369"
inkscape:cy="42.493912"
inkscape:document-units="px"
inkscape:current-layer="layer1"
inkscape:document-rotation="0"
showgrid="true"
inkscape:window-width="1396"
inkscape:window-height="1016"
inkscape:window-x="50"
inkscape:window-y="27"
inkscape:window-maximized="0"
units="px">
<inkscape:grid
type="xygrid"
id="grid851"
spacingx="1.0583333"
spacingy="1.0583333"
empspacing="4" />
</sodipodi:namedview>
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Ebene 1"
inkscape:groupmode="layer"
id="layer1">
<circle
style="fill:#303030;fill-opacity:1;stroke:none;stroke-width:0.2;stroke-opacity:0.5;opacity:1"
id="path853"
cx="16.933332"
cy="16.933332"
r="15.874999" />
<path
d="M 13.553183,9.5568091 C 13.056762,12.40878 12.41526,15.765311 11.641666,19.579166 11.112514,19.117684 10.363554,18.82791 9.5303854,18.82791 c -1.6023905,0 -2.9013699,1.06575 -2.9013699,2.380533 0,1.314781 1.2989794,2.380531 2.9013699,2.380531 1.6023916,0 2.6414696,-0.72268 2.9013706,-2.380531 0.371458,-2.606532 0.693542,-4.484063 1.272743,-8.053734 h 0.08454 c 0.97349,2.045774 1.97525,4.277025 3.004977,6.691673 l 1.051893,-2.149423 c -1.146154,-2.433005 -2.36032,-5.134786 -3.650597,-8.1403239 z m 9.830992,0 c -1.922644,3.9360329 -4.028599,8.2312419 -5.872099,11.9996389 0.214174,0.478841 0.213832,0.473733 0.706271,0.806455 1.391414,-3.185731 2.821015,-6.23621 4.287978,-9.15126 l 0.07567,0.0096 c 0.497372,3.746054 0.852824,6.812033 1.066876,9.198452 0.195174,-0.03778 0.513002,-0.05658 0.953701,-0.05658 0.52257,0 0.881268,0.01883 1.076442,0.05658 -0.705145,-4.230869 -1.308981,-8.518284 -1.812675,-12.8625029 z"
id="path4"
inkscape:connector-curvature="0"
style="fill:#ffc107;fill-opacity:1;stroke-width:0.349035" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3 KiB

View file

@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="16"
height="16"
viewBox="0 0 4.2333332 4.2333332"
version="1.1"
id="svg2197"
inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)"
sodipodi:docname="musicus_symbolic.svg">
<defs
id="defs2191" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="31.083523"
inkscape:cx="9.8959183"
inkscape:cy="9.0432761"
inkscape:document-units="px"
inkscape:current-layer="layer1"
inkscape:document-rotation="0"
showgrid="true"
inkscape:window-width="1396"
inkscape:window-height="1016"
inkscape:window-x="236"
inkscape:window-y="185"
inkscape:window-maximized="0"
units="px">
<inkscape:grid
type="xygrid"
id="grid2786"
spacingx="0.52916665"
spacingy="0.52916665"
empspacing="2" />
</sodipodi:namedview>
<metadata
id="metadata2194">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Ebene 1"
inkscape:groupmode="layer"
id="layer1">
<path
d="M 1.6109484,0.75244585 C 1.5144212,1.3069954 1.3896858,1.9596541 1.2392649,2.7012366 1.1363746,2.6115036 0.99074332,2.5551592 0.82873813,2.5551592 c -0.31157571,0 -0.56415494,0.207229 -0.56415494,0.4628812 0,0.2556517 0.25257923,0.4628809 0.56415494,0.4628809 0.31157607,0 0.51361887,-0.1405212 0.56415527,-0.4628809 C 1.4651224,2.511215 1.5277477,2.1461397 1.6403711,1.4520373 h 0.016433 c 0.1892954,0.397789 0.3840822,0.8316432 0.5843067,1.3011582 L 2.4456455,2.3352522 C 2.2227823,1.8621681 1.9866947,1.3368218 1.7358073,0.7524119 Z m 1.9115815,0 C 3.1486824,1.5177853 2.7391913,2.3529645 2.380733,3.0857081 2.42238,3.1788171 2.422311,3.1778251 2.5180621,3.2425184 2.7886154,2.6230712 3.0665935,2.0299227 3.3518361,1.4631079 l 0.014716,0.00186 c 0.09671,0.7283991 0.1658266,1.3245612 0.2074481,1.7885873 0.037951,-0.00736 0.09975,-0.011014 0.1854421,-0.011014 0.1016105,0 0.1713582,0.00368 0.2093077,0.011014 C 3.8316365,2.4308883 3.7142245,1.5972244 3.6162839,0.7525155 Z"
id="path4"
inkscape:connector-curvature="0"
style="fill:#000000;fill-opacity:1;stroke-width:0.0678681" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -0,0 +1,41 @@
datadir = get_option('datadir')
scalable_dir = join_paths('icons', 'hicolor', 'scalable', 'apps')
install_data(
join_paths(scalable_dir, 'de.johrpan.musicus.svg'),
install_dir: join_paths(datadir, scalable_dir),
)
symbolic_dir = join_paths('icons', 'hicolor', 'symbolic', 'apps')
install_data(
join_paths(symbolic_dir, 'de.johrpan.musicus-symbolic.svg'),
install_dir: join_paths(datadir, symbolic_dir),
)
desktop_file = i18n.merge_file(
input: 'de.johrpan.musicus.desktop.in',
output: 'de.johrpan.musicus.desktop',
type: 'desktop',
po_dir: '../po',
install: true,
install_dir: join_paths(datadir, 'applications')
)
desktop_utils = find_program('desktop-file-validate', required: false)
if desktop_utils.found()
test('Validate desktop file', desktop_utils,
args: [desktop_file]
)
endif
install_data('de.johrpan.musicus.gschema.xml',
install_dir: join_paths(get_option('datadir'), 'glib-2.0/schemas')
)
compile_schemas = find_program('glib-compile-schemas', required: false)
if compile_schemas.found()
test('Validate schema file', compile_schemas,
args: ['--strict', '--dry-run', meson.current_source_dir()]
)
endif

View file

@ -0,0 +1 @@
de

View file

@ -0,0 +1,71 @@
../res/ui/medium_preview.ui
../res/ui/performance_editor.ui
../res/ui/player_bar.ui
../res/ui/player_screen.ui
../res/ui/screen.ui
../res/ui/section.ui
../res/ui/source_selector.ui
../res/ui/track_editor.ui
../res/ui/track_selector.ui
../res/ui/track_set_editor.ui
../res/ui/work_part_editor.ui
../res/ui/import_screen.ui
../res/ui/medium_editor.ui
../res/ui/preferences.ui
../res/ui/recording_editor.ui
../res/ui/selector.ui
../res/ui/work_editor.ui
../res/ui/editor.ui
../res/ui/main_screen.ui
../res/ui/track_row.ui
../src/editors/mod.rs
../src/editors/performance.rs
../src/editors/work_part.rs
../src/editors/ensemble.rs
../src/editors/instrument.rs
../src/editors/person.rs
../src/editors/recording.rs
../src/editors/work.rs
../src/import/mod.rs
../src/import/source_selector.rs
../src/import/track_editor.rs
../src/import/track_selector.rs
../src/import/track_set_editor.rs
../src/import/medium_editor.rs
../src/import/import_screen.rs
../src/import/medium_preview.rs
../src/macros.rs
../src/main.rs
../src/navigator/mod.rs
../src/navigator/window.rs
../src/screens/medium.rs
../src/screens/ensemble.rs
../src/screens/person.rs
../src/screens/recording.rs
../src/screens/welcome.rs
../src/screens/work.rs
../src/screens/mod.rs
../src/screens/main.rs
../src/screens/player.rs
../src/selectors/mod.rs
../src/selectors/ensemble.rs
../src/selectors/instrument.rs
../src/selectors/medium.rs
../src/selectors/person.rs
../src/selectors/recording.rs
../src/selectors/selector.rs
../src/selectors/work.rs
../src/widgets/button_row.rs
../src/widgets/entry_row.rs
../src/widgets/indexed_list_model.rs
../src/widgets/list.rs
../src/widgets/screen.rs
../src/widgets/section.rs
../src/widgets/mod.rs
../src/widgets/editor.rs
../src/widgets/player_bar.rs
../src/widgets/track_row.rs
../src/config.rs
../src/resources.rs
../src/preferences.rs
../src/window.rs

515
crates/musicus/po/de.po Normal file
View file

@ -0,0 +1,515 @@
#
# <>, YEAR-2022.
#
msgid ""
msgstr ""
"Project-Id-Version: unnamed project\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-04-08 19:52+0200\n"
"PO-Revision-Date: 2022-02-10 12:34+0100\n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: de\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Gtranslator 41.0\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: ../res/ui/medium_preview.ui:19
msgid "Preview"
msgstr "Vorschau"
#: ../res/ui/medium_preview.ui:50
msgid "Import"
msgstr "Importieren"
#: ../res/ui/medium_preview.ui:117 ../res/ui/source_selector.ui:69
msgid "Loading"
msgstr "Lade…"
#: ../res/ui/medium_preview.ui:149 ../res/ui/source_selector.ui:101
#: ../res/ui/medium_editor.ui:151 ../res/ui/medium_editor.ui:188
#: ../res/ui/editor.ui:68
msgid "Error"
msgstr "Fehler"
#: ../res/ui/medium_preview.ui:161 ../res/ui/source_selector.ui:113
#: ../res/ui/medium_editor.ui:163 ../res/ui/editor.ui:80
msgid "Try again"
msgstr "Nochmal versuchen"
#: ../res/ui/performance_editor.ui:13
msgid "Performance"
msgstr "Auftritt"
#: ../res/ui/performance_editor.ui:52
msgid "Select a person"
msgstr "Person auswählen"
#: ../res/ui/performance_editor.ui:56 ../res/ui/performance_editor.ui:69
#: ../res/ui/performance_editor.ui:89 ../res/ui/track_set_editor.ui:67
#: ../res/ui/import_screen.ui:160 ../res/ui/preferences.ui:23
#: ../res/ui/recording_editor.ui:81 ../res/ui/work_editor.ui:81
#: ../src/import/source_selector.rs:51 ../src/screens/welcome.rs:56
#: ../src/preferences.rs:44
msgid "Select"
msgstr "Auswählen"
#: ../res/ui/performance_editor.ui:65
msgid "Select an ensemble"
msgstr "Ensemble auswählen"
#: ../res/ui/performance_editor.ui:78
msgid "Select a role"
msgstr "Rolle auswählen"
#: ../res/ui/player_bar.ui:65 ../res/ui/player_screen.ui:97
#: ../res/ui/work_part_editor.ui:56 ../res/ui/work_editor.ui:90
msgid "Title"
msgstr "Titel"
#: ../res/ui/player_bar.ui:75 ../res/ui/player_screen.ui:107
msgid "Subtitle"
msgstr "Untertitel"
#: ../res/ui/player_bar.ui:86 ../res/ui/player_bar.ui:96
#: ../res/ui/player_screen.ui:131 ../res/ui/player_screen.ui:142
msgid "0:00"
msgstr "0:00"
#: ../res/ui/player_bar.ui:91
msgid "/"
msgstr "/"
#: ../res/ui/player_screen.ui:19
msgid "Player"
msgstr "Wiedergabe"
#: ../res/ui/source_selector.ui:31 ../res/ui/track_set_editor.ui:13
#: ../res/ui/import_screen.ui:13 ../res/ui/medium_editor.ui:19
msgid "Import music"
msgstr "Musik importieren"
#: ../res/ui/source_selector.ui:32
msgid "Select the source which contains the new audio files below."
msgstr "Wählen Sie die Quelle mit den Audiodateien unten aus."
#: ../res/ui/source_selector.ui:41 ../src/import/source_selector.rs:46
#: ../src/screens/welcome.rs:28
msgid "Select folder"
msgstr "Ordner auswählen"
#: ../res/ui/source_selector.ui:46
msgid "Copy audio CD"
msgstr "Audio-CD kopieren"
#: ../res/ui/track_editor.ui:13
msgid "Track"
msgstr "Track"
#: ../res/ui/track_selector.ui:13
msgid "Select tracks"
msgstr "Tracks auswählen"
#: ../res/ui/track_set_editor.ui:51 ../res/ui/recording_editor.ui:18
#: ../res/ui/recording_editor.ui:154
msgid "Recording"
msgstr "Aufnahme"
#: ../res/ui/track_set_editor.ui:63
msgid "Select a recording"
msgstr "Aufnahme auswählen"
#: ../res/ui/track_set_editor.ui:88 ../src/screens/recording.rs:30
msgid "Tracks"
msgstr "Tracks"
#: ../res/ui/work_part_editor.ui:13
msgid "Work part"
msgstr "Werkabschnitt"
#: ../res/ui/import_screen.ui:42
msgid "Matching metadata"
msgstr "Passende Metadaten"
#: ../res/ui/import_screen.ui:64
msgid "Loading…"
msgstr "Lade…"
#: ../res/ui/import_screen.ui:88
msgid "Error while searching for matching metadata"
msgstr "Fehler bei der Suche nach passenden Metadaten"
#: ../res/ui/import_screen.ui:114
msgid "No matching metadata found"
msgstr "Keine passenden Metadaten gefunden"
#: ../res/ui/import_screen.ui:144
msgid "Manually add metadata"
msgstr "Metadaten manuell hinzufügen"
#: ../res/ui/import_screen.ui:156
msgid "Select existing medium"
msgstr "Existierendes Medium auswählen"
#: ../res/ui/import_screen.ui:169
msgid "Add a new medium"
msgstr "Neues Medium hinzufügen"
#: ../res/ui/import_screen.ui:173
msgid "Add"
msgstr "Hinzufügen"
#: ../res/ui/medium_editor.ui:61
msgid "Medium"
msgstr "Medium"
#: ../res/ui/medium_editor.ui:73
msgid "Name of the medium"
msgstr "Name des Mediums"
#: ../res/ui/medium_editor.ui:98
msgid "Recordings"
msgstr "Aufnahmen"
#: ../res/ui/medium_editor.ui:200 ../res/ui/editor.ui:22
#: ../src/import/source_selector.rs:50 ../src/screens/welcome.rs:55
#: ../src/preferences.rs:43
msgid "Cancel"
msgstr "Abbrechen"
#: ../res/ui/preferences.ui:11 ../src/editors/ensemble.rs:35
#: ../src/editors/instrument.rs:35 ../src/editors/person.rs:39
msgid "General"
msgstr "Allgemein"
#: ../res/ui/preferences.ui:14
msgid "Music library"
msgstr "Musikbibliothek"
#: ../res/ui/preferences.ui:18
msgid "Music library folder"
msgstr "Ordner der Musikbibliothek"
#: ../res/ui/preferences.ui:20
msgid "None selected"
msgstr "Keiner ausgewählt"
#: ../res/ui/preferences.ui:34
msgid "Playlist"
msgstr "Wiedergabeliste"
#: ../res/ui/preferences.ui:38
msgid "Keep playing"
msgstr "Weiter abspielen"
#: ../res/ui/preferences.ui:40
msgid "Whether to keep playing random tracks after the playlist ends."
msgstr "Nach dem Ende der Wiedergabeliste weiter im Zufallsmodus abspielen."
#: ../res/ui/preferences.ui:51
msgid "Choose full recordings"
msgstr "Komplette Aufnahmen abspielen"
#: ../res/ui/preferences.ui:53
msgid ""
"Whether to choose full recordings instead of single tracks for random "
"playback."
msgstr "Im Zufallsmodus vollständige Aufnahmen anstatt einzelner Tracks verwenden."
#: ../res/ui/recording_editor.ui:65 ../res/ui/work_editor.ui:65
msgid "Overview"
msgstr "Überblick"
#: ../res/ui/recording_editor.ui:77
msgid "Select a work"
msgstr "Werk auswählen"
#: ../res/ui/recording_editor.ui:90
msgid "Comment"
msgstr "Kommentar"
#: ../res/ui/recording_editor.ui:115
msgid "Performers"
msgstr "Interpreten"
#: ../res/ui/selector.ui:60
msgid "Search …"
msgstr "Suchen…"
#: ../res/ui/work_editor.ui:18 ../res/ui/work_editor.ui:181
#: ../src/editors/recording.rs:173
msgid "Work"
msgstr "Werk"
#: ../res/ui/work_editor.ui:77
msgid "Select a composer"
msgstr "Komponisten auswählen"
#: ../res/ui/work_editor.ui:115
msgid "Instruments"
msgstr "Instrumente"
#: ../res/ui/work_editor.ui:142
msgid "Structure"
msgstr "Struktur"
#: ../res/ui/editor.ui:27
msgid "Save"
msgstr "Speichern"
#: ../res/ui/main_screen.ui:17 ../src/screens/welcome.rs:33
msgid "Welcome to Musicus!"
msgstr "Willkommen bei Musicus!"
#: ../res/ui/main_screen.ui:18
msgid ""
"Get startet by selecting something from the sidebar or adding new things to "
"your library using the button in the top left corner."
msgstr ""
"Legen Sie los, indem Sie etwas in der Seitenleiste auswählen oder fügen Sie "
"mit dem Knopf oben links neue Aufnahmen zu Ihrer Musikbibliothek hinzu."
#: ../res/ui/main_screen.ui:27
msgid "Play something"
msgstr "Musik abspielen"
#: ../res/ui/main_screen.ui:88
msgid "Search persons and ensembles …"
msgstr "Personen und Ensembles durchsuchen …"
#: ../res/ui/main_screen.ui:150
msgid "Preferences"
msgstr "Einstellungen"
#: ../res/ui/main_screen.ui:154
msgid "About Musicus"
msgstr "Über Musicus"
#: ../src/editors/performance.rs:43
msgid "Performer"
msgstr "Interpret"
#: ../src/editors/performance.rs:45
msgid "Select either a person or an ensemble as a performer."
msgstr "Wählen Sie entweder eine Person oder ein Ensemble als Interpreten aus."
#: ../src/editors/performance.rs:64
msgid "Role"
msgstr "Rolle"
#: ../src/editors/performance.rs:66
msgid "Optionally, choose a role to specify what the performer does."
msgstr ""
"Wählen Sie optional eine Rolle aus, die angibt, was der Interpret macht."
#: ../src/editors/ensemble.rs:32 ../src/editors/instrument.rs:32
msgid "Name"
msgstr "Name"
#: ../src/editors/ensemble.rs:66
msgid "Failed to save ensemble!"
msgstr "Ensemble konnte nicht gespeichert werden!"
#: ../src/editors/instrument.rs:66
msgid "Failed to save instrument!"
msgstr "Instrument konnte nicht gespeichert werden!"
#: ../src/editors/person.rs:33
msgid "First name"
msgstr "Vorname"
#: ../src/editors/person.rs:34
msgid "Last name"
msgstr "Nachname"
#: ../src/editors/person.rs:73
msgid "Failed to save person!"
msgstr "Person konnte nicht gespeichert werden!"
#: ../src/editors/work.rs:238
msgid "Composer"
msgstr "Komponist"
#: ../src/import/track_set_editor.rs:133 ../src/import/medium_preview.rs:170
#: ../src/screens/medium.rs:72 ../src/screens/recording.rs:82
msgid "Unknown"
msgstr "Unbekannt"
#: ../src/screens/medium.rs:42
msgid "Edit medium"
msgstr "Medium bearbeiten"
#: ../src/screens/medium.rs:49
msgid "Delete medium"
msgstr "Medium löschen"
#: ../src/screens/ensemble.rs:49
msgid "Edit ensemble"
msgstr "Ensemble bearbeiten"
#: ../src/screens/ensemble.rs:59
msgid "Delete ensemble"
msgstr "Ensemble löschen"
#: ../src/screens/person.rs:54
msgid "Edit person"
msgstr "Person bearbeiten"
#: ../src/screens/person.rs:64
msgid "Delete person"
msgstr "Person löschen"
#: ../src/screens/recording.rs:53
msgid "Edit recording"
msgstr "Aufnahme bearbeiten"
#: ../src/screens/recording.rs:63
msgid "Delete recording"
msgstr "Aufnahme löschen"
#: ../src/screens/welcome.rs:35
msgid ""
"Get startet by selecting the folder containing your music "
"files! Musicus will create a new database there or open one that already "
"exists."
msgstr ""
"Wählen Sie als Erstes den Ordner aus, worin sich Ihre Musik befindet. "
"Musicus wird dort eine neue Datenbank anlegen oder eine bereits existierende "
"öffnen."
#: ../src/screens/welcome.rs:51 ../src/preferences.rs:39
msgid "Select music library folder"
msgstr "Ordner der Musikbibliothek auswählen"
#: ../src/screens/work.rs:45
msgid "Edit work"
msgstr "Werk bearbeiten"
#: ../src/screens/work.rs:55
msgid "Delete work"
msgstr "Werk löschen"
#: ../src/screens/main.rs:200
msgid "Musicus"
msgstr "Musicus"
#: ../src/screens/main.rs:202
msgid "The classical music player and organizer."
msgstr "Das Programm zum Abspielen und Organisieren von Klassik."
#: ../src/screens/main.rs:204
msgid "Further information and source code"
msgstr "Weitere Informationen und Quellcode"
#: ../src/selectors/ensemble.rs:23
msgid "Select ensemble"
msgstr "Ensemble auswählen"
#: ../src/selectors/instrument.rs:23
msgid "Select instrument"
msgstr "Instrument auswählen"
#: ../src/selectors/medium.rs:21
msgid "Select performer"
msgstr "Interpreten auswählen"
#: ../src/selectors/medium.rs:90
msgid "Select medium"
msgstr "Medium auswählen"
#: ../src/selectors/person.rs:23
msgid "Select person"
msgstr "Person auswählen"
#: ../src/selectors/recording.rs:22 ../src/selectors/work.rs:22
msgid "Select composer"
msgstr "Komponisten auswählen"
#: ../src/selectors/recording.rs:105 ../src/selectors/work.rs:95
msgid "Select work"
msgstr "Werk auswählen"
#: ../src/selectors/recording.rs:168
msgid "Select recording"
msgstr "Aufnahme auswählen"
#, fuzzy
#~ msgid "Personal data"
#~ msgstr "Person"
#~ msgid "Ensemble"
#~ msgstr "Ensemble"
#~ msgid "Search recordings …"
#~ msgstr "Aufnahmen durchsuchen …"
#~ msgid "No recordings found."
#~ msgstr "Keine Aufnahmen gefunden."
#~ msgid "No ensembles found."
#~ msgstr "Keine Ensembles gefunden."
#~ msgid "Instrument"
#~ msgstr "Instrument"
#~ msgid "No instruments found."
#~ msgstr "Keine Instrumente gefunden."
#~ msgid "Select …"
#~ msgstr "Auswählen …"
#~ msgid "Type"
#~ msgstr "Typ"
#~ msgid "Search persons …"
#~ msgstr "Personen durchsuchen …"
#~ msgid "Search works and recordings …"
#~ msgstr "Werke und Aufnahmen durchsuchen …"
#~ msgid "Works"
#~ msgstr "Werke"
#~ msgid "No works or recordings found."
#~ msgstr "Keine Werke oder Aufnahmen gefunden."
#~ msgid "Select a composer on the left."
#~ msgstr "Wählen Sie einen Komponisten aus."
#~ msgid "Work section"
#~ msgstr "Werkteil"
#~ msgid "Select a recording of a work with multiple parts."
#~ msgstr "Wählen Sie eine Aufnahme eines mehrteiligen Werks aus."
#~ msgid "No performers added."
#~ msgstr "Keine Interpreten hinzugefügt."
#~ msgid "No works found."
#~ msgstr "Keine Werke gefunden."
#~ msgid "Add some tracks."
#~ msgstr "Fügen Sie Tracks hinzu."
#~ msgid "Select audio files"
#~ msgstr "Audiodateien auswählen"
#~ msgid "No instruments added."
#~ msgstr "Keine Instrumente hinzugefügt."
#~ msgid "No work parts added."
#~ msgstr "Keine Werkabschnitte hinzugefügt."
#~ msgid "Edit tracks"
#~ msgstr "Tracks bearbeiten"
#~ msgid "No tracks found."
#~ msgstr "Keine Tracks gefunden."
#~ msgid "No persons found."
#~ msgstr "Keine Personen gefunden."
#~ msgid "No persons or ensembles found."
#~ msgstr "Keine Personen oder Ensembles gefunden."

View file

@ -0,0 +1 @@
i18n.gettext('musicus', preset: 'glib')

View file

@ -0,0 +1,431 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-04-08 19:52+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: ../res/ui/medium_preview.ui:19
msgid "Preview"
msgstr ""
#: ../res/ui/medium_preview.ui:50
msgid "Import"
msgstr ""
#: ../res/ui/medium_preview.ui:117 ../res/ui/source_selector.ui:69
msgid "Loading"
msgstr ""
#: ../res/ui/medium_preview.ui:149 ../res/ui/source_selector.ui:101
#: ../res/ui/medium_editor.ui:151 ../res/ui/medium_editor.ui:188
#: ../res/ui/editor.ui:68
msgid "Error"
msgstr ""
#: ../res/ui/medium_preview.ui:161 ../res/ui/source_selector.ui:113
#: ../res/ui/medium_editor.ui:163 ../res/ui/editor.ui:80
msgid "Try again"
msgstr ""
#: ../res/ui/performance_editor.ui:13
msgid "Performance"
msgstr ""
#: ../res/ui/performance_editor.ui:52
msgid "Select a person"
msgstr ""
#: ../res/ui/performance_editor.ui:56 ../res/ui/performance_editor.ui:69
#: ../res/ui/performance_editor.ui:89 ../res/ui/track_set_editor.ui:67
#: ../res/ui/import_screen.ui:160 ../res/ui/preferences.ui:23
#: ../res/ui/recording_editor.ui:81 ../res/ui/work_editor.ui:81
#: ../src/import/source_selector.rs:51 ../src/screens/welcome.rs:56
#: ../src/preferences.rs:44
msgid "Select"
msgstr ""
#: ../res/ui/performance_editor.ui:65
msgid "Select an ensemble"
msgstr ""
#: ../res/ui/performance_editor.ui:78
msgid "Select a role"
msgstr ""
#: ../res/ui/player_bar.ui:65 ../res/ui/player_screen.ui:97
#: ../res/ui/work_part_editor.ui:56 ../res/ui/work_editor.ui:90
msgid "Title"
msgstr ""
#: ../res/ui/player_bar.ui:75 ../res/ui/player_screen.ui:107
msgid "Subtitle"
msgstr ""
#: ../res/ui/player_bar.ui:86 ../res/ui/player_bar.ui:96
#: ../res/ui/player_screen.ui:131 ../res/ui/player_screen.ui:142
msgid "0:00"
msgstr ""
#: ../res/ui/player_bar.ui:91
msgid "/"
msgstr ""
#: ../res/ui/player_screen.ui:19
msgid "Player"
msgstr ""
#: ../res/ui/source_selector.ui:31 ../res/ui/track_set_editor.ui:13
#: ../res/ui/import_screen.ui:13 ../res/ui/medium_editor.ui:19
msgid "Import music"
msgstr ""
#: ../res/ui/source_selector.ui:32
msgid "Select the source which contains the new audio files below."
msgstr ""
#: ../res/ui/source_selector.ui:41 ../src/import/source_selector.rs:46
#: ../src/screens/welcome.rs:28
msgid "Select folder"
msgstr ""
#: ../res/ui/source_selector.ui:46
msgid "Copy audio CD"
msgstr ""
#: ../res/ui/track_editor.ui:13
msgid "Track"
msgstr ""
#: ../res/ui/track_selector.ui:13
msgid "Select tracks"
msgstr ""
#: ../res/ui/track_set_editor.ui:51 ../res/ui/recording_editor.ui:18
#: ../res/ui/recording_editor.ui:154
msgid "Recording"
msgstr ""
#: ../res/ui/track_set_editor.ui:63
msgid "Select a recording"
msgstr ""
#: ../res/ui/track_set_editor.ui:88 ../src/screens/recording.rs:30
msgid "Tracks"
msgstr ""
#: ../res/ui/work_part_editor.ui:13
msgid "Work part"
msgstr ""
#: ../res/ui/import_screen.ui:42
msgid "Matching metadata"
msgstr ""
#: ../res/ui/import_screen.ui:64
msgid "Loading…"
msgstr ""
#: ../res/ui/import_screen.ui:88
msgid "Error while searching for matching metadata"
msgstr ""
#: ../res/ui/import_screen.ui:114
msgid "No matching metadata found"
msgstr ""
#: ../res/ui/import_screen.ui:144
msgid "Manually add metadata"
msgstr ""
#: ../res/ui/import_screen.ui:156
msgid "Select existing medium"
msgstr ""
#: ../res/ui/import_screen.ui:169
msgid "Add a new medium"
msgstr ""
#: ../res/ui/import_screen.ui:173
msgid "Add"
msgstr ""
#: ../res/ui/medium_editor.ui:61
msgid "Medium"
msgstr ""
#: ../res/ui/medium_editor.ui:73
msgid "Name of the medium"
msgstr ""
#: ../res/ui/medium_editor.ui:98
msgid "Recordings"
msgstr ""
#: ../res/ui/medium_editor.ui:200 ../res/ui/editor.ui:22
#: ../src/import/source_selector.rs:50 ../src/screens/welcome.rs:55
#: ../src/preferences.rs:43
msgid "Cancel"
msgstr ""
#: ../res/ui/preferences.ui:11 ../src/editors/ensemble.rs:35
#: ../src/editors/instrument.rs:35 ../src/editors/person.rs:39
msgid "General"
msgstr ""
#: ../res/ui/preferences.ui:14
msgid "Music library"
msgstr ""
#: ../res/ui/preferences.ui:18
msgid "Music library folder"
msgstr ""
#: ../res/ui/preferences.ui:20
msgid "None selected"
msgstr ""
#: ../res/ui/preferences.ui:34
msgid "Playlist"
msgstr ""
#: ../res/ui/preferences.ui:38
msgid "Keep playing"
msgstr ""
#: ../res/ui/preferences.ui:40
msgid "Whether to keep playing random tracks after the playlist ends."
msgstr ""
#: ../res/ui/preferences.ui:51
msgid "Choose full recordings"
msgstr ""
#: ../res/ui/preferences.ui:53
msgid ""
"Whether to choose full recordings instead of single tracks for random "
"playback."
msgstr ""
#: ../res/ui/recording_editor.ui:65 ../res/ui/work_editor.ui:65
msgid "Overview"
msgstr ""
#: ../res/ui/recording_editor.ui:77
msgid "Select a work"
msgstr ""
#: ../res/ui/recording_editor.ui:90
msgid "Comment"
msgstr ""
#: ../res/ui/recording_editor.ui:115
msgid "Performers"
msgstr ""
#: ../res/ui/selector.ui:60
msgid "Search …"
msgstr ""
#: ../res/ui/work_editor.ui:18 ../res/ui/work_editor.ui:181
#: ../src/editors/recording.rs:173
msgid "Work"
msgstr ""
#: ../res/ui/work_editor.ui:77
msgid "Select a composer"
msgstr ""
#: ../res/ui/work_editor.ui:115
msgid "Instruments"
msgstr ""
#: ../res/ui/work_editor.ui:142
msgid "Structure"
msgstr ""
#: ../res/ui/editor.ui:27
msgid "Save"
msgstr ""
#: ../res/ui/main_screen.ui:17 ../src/screens/welcome.rs:33
msgid "Welcome to Musicus!"
msgstr ""
#: ../res/ui/main_screen.ui:18
msgid ""
"Get startet by selecting something from the sidebar or adding new things to "
"your library using the button in the top left corner."
msgstr ""
#: ../res/ui/main_screen.ui:27
msgid "Play something"
msgstr ""
#: ../res/ui/main_screen.ui:88
msgid "Search persons and ensembles …"
msgstr ""
#: ../res/ui/main_screen.ui:150
msgid "Preferences"
msgstr ""
#: ../res/ui/main_screen.ui:154
msgid "About Musicus"
msgstr ""
#: ../src/editors/performance.rs:43
msgid "Performer"
msgstr ""
#: ../src/editors/performance.rs:45
msgid "Select either a person or an ensemble as a performer."
msgstr ""
#: ../src/editors/performance.rs:64
msgid "Role"
msgstr ""
#: ../src/editors/performance.rs:66
msgid "Optionally, choose a role to specify what the performer does."
msgstr ""
#: ../src/editors/ensemble.rs:32 ../src/editors/instrument.rs:32
msgid "Name"
msgstr ""
#: ../src/editors/ensemble.rs:66
msgid "Failed to save ensemble!"
msgstr ""
#: ../src/editors/instrument.rs:66
msgid "Failed to save instrument!"
msgstr ""
#: ../src/editors/person.rs:33
msgid "First name"
msgstr ""
#: ../src/editors/person.rs:34
msgid "Last name"
msgstr ""
#: ../src/editors/person.rs:73
msgid "Failed to save person!"
msgstr ""
#: ../src/editors/work.rs:238
msgid "Composer"
msgstr ""
#: ../src/import/track_set_editor.rs:133 ../src/import/medium_preview.rs:170
#: ../src/screens/medium.rs:72 ../src/screens/recording.rs:82
msgid "Unknown"
msgstr ""
#: ../src/screens/medium.rs:42
msgid "Edit medium"
msgstr ""
#: ../src/screens/medium.rs:49
msgid "Delete medium"
msgstr ""
#: ../src/screens/ensemble.rs:49
msgid "Edit ensemble"
msgstr ""
#: ../src/screens/ensemble.rs:59
msgid "Delete ensemble"
msgstr ""
#: ../src/screens/person.rs:54
msgid "Edit person"
msgstr ""
#: ../src/screens/person.rs:64
msgid "Delete person"
msgstr ""
#: ../src/screens/recording.rs:53
msgid "Edit recording"
msgstr ""
#: ../src/screens/recording.rs:63
msgid "Delete recording"
msgstr ""
#: ../src/screens/welcome.rs:35
msgid ""
"Get startet by selecting the folder containing your music "
"files! Musicus will create a new database there or open one that already "
"exists."
msgstr ""
#: ../src/screens/welcome.rs:51 ../src/preferences.rs:39
msgid "Select music library folder"
msgstr ""
#: ../src/screens/work.rs:45
msgid "Edit work"
msgstr ""
#: ../src/screens/work.rs:55
msgid "Delete work"
msgstr ""
#: ../src/screens/main.rs:200
msgid "Musicus"
msgstr ""
#: ../src/screens/main.rs:202
msgid "The classical music player and organizer."
msgstr ""
#: ../src/screens/main.rs:204
msgid "Further information and source code"
msgstr ""
#: ../src/selectors/ensemble.rs:23
msgid "Select ensemble"
msgstr ""
#: ../src/selectors/instrument.rs:23
msgid "Select instrument"
msgstr ""
#: ../src/selectors/medium.rs:21
msgid "Select performer"
msgstr ""
#: ../src/selectors/medium.rs:90
msgid "Select medium"
msgstr ""
#: ../src/selectors/person.rs:23
msgid "Select person"
msgstr ""
#: ../src/selectors/recording.rs:22 ../src/selectors/work.rs:22
msgid "Select composer"
msgstr ""
#: ../src/selectors/recording.rs:105 ../src/selectors/work.rs:95
msgid "Select work"
msgstr ""
#: ../src/selectors/recording.rs:168
msgid "Select recording"
msgstr ""

View file

@ -0,0 +1,9 @@
pkgdatadir = join_paths(get_option('prefix'), get_option('datadir'), meson.project_name())
gnome = import('gnome')
resources = gnome.compile_resources('musicus',
'musicus.gresource.xml',
gresource_bundle: true,
install: true,
install_dir: pkgdatadir,
)

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/de/johrpan/musicus">
<file preprocess="xml-stripblanks">ui/editor.ui</file>
<file preprocess="xml-stripblanks">ui/import_screen.ui</file>
<file preprocess="xml-stripblanks">ui/main_screen.ui</file>
<file preprocess="xml-stripblanks">ui/medium_editor.ui</file>
<file preprocess="xml-stripblanks">ui/medium_preview.ui</file>
<file preprocess="xml-stripblanks">ui/performance_editor.ui</file>
<file preprocess="xml-stripblanks">ui/player_bar.ui</file>
<file preprocess="xml-stripblanks">ui/player_screen.ui</file>
<file preprocess="xml-stripblanks">ui/preferences.ui</file>
<file preprocess="xml-stripblanks">ui/recording_editor.ui</file>
<file preprocess="xml-stripblanks">ui/screen.ui</file>
<file preprocess="xml-stripblanks">ui/section.ui</file>
<file preprocess="xml-stripblanks">ui/selector.ui</file>
<file preprocess="xml-stripblanks">ui/source_selector.ui</file>
<file preprocess="xml-stripblanks">ui/track_editor.ui</file>
<file preprocess="xml-stripblanks">ui/track_row.ui</file>
<file preprocess="xml-stripblanks">ui/track_selector.ui</file>
<file preprocess="xml-stripblanks">ui/track_set_editor.ui</file>
<file preprocess="xml-stripblanks">ui/work_editor.ui</file>
<file preprocess="xml-stripblanks">ui/work_part_editor.ui</file>
</gresource>
</gresources>

View file

@ -0,0 +1,94 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0"/>
<requires lib="libadwaita" version="1.0"/>
<object class="GtkStack" id="widget">
<property name="transition-type">crossfade</property>
<child>
<object class="GtkStackPage">
<property name="name">content</property>
<property name="child">
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="AdwHeaderBar">
<property name="show-start-title-buttons">false</property>
<property name="show-end-title-buttons">false</property>
<property name="title-widget">
<object class="AdwWindowTitle" id="window_title"/>
</property>
<child>
<object class="GtkButton" id="back_button">
<property name="label" translatable="yes">Cancel</property>
</object>
</child>
<child type="end">
<object class="GtkButton" id="save_button">
<property name="label" translatable="yes">Save</property>
<style>
<class name="suggested-action"/>
</style>
</object>
</child>
</object>
</child>
<child>
<object class="GtkScrolledWindow">
<property name="vexpand">true</property>
<child>
<object class="AdwClamp">
<child>
<object class="GtkBox" id="content_box">
<property name="orientation">vertical</property>
<property name="margin-start">12</property>
<property name="margin-end">12</property>
<property name="margin-bottom">36</property>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">error</property>
<property name="child">
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="AdwHeaderBar">
<property name="show-start-title-buttons">false</property>
<property name="show-end-title-buttons">false</property>
<property name="title-widget">
<object class="AdwWindowTitle">
<property name="title" translatable="yes">Error</property>
</object>
</property>
</object>
</child>
<child>
<object class="AdwStatusPage" id="status_page">
<property name="icon-name">network-error-symbolic</property>
<property name="title">Error</property>
<property name="vexpand">true</property>
<child>
<object class="GtkButton" id="try_again_button">
<property name="label" translatable="yes">Try again</property>
<property name="hexpand">true</property>
<property name="vexpand">true</property>
<property name="halign">center</property>
<property name="valign">center</property>
</object>
</child>
</object>
</child>
</object>
</property>
</object>
</child>
</object>
</interface>

View file

@ -0,0 +1,191 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0" />
<requires lib="libadwaita" version="1.0" />
<object class="GtkBox" id="widget">
<property name="orientation">vertical</property>
<child>
<object class="AdwHeaderBar">
<property name="show-start-title-buttons">false</property>
<property name="show-end-title-buttons">false</property>
<property name="title-widget">
<object class="AdwWindowTitle" id="window_title">
<property name="title" translatable="yes">Import music</property>
</object>
</property>
<child>
<object class="GtkButton" id="back_button">
<property name="icon-name">go-previous-symbolic</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkScrolledWindow">
<property name="vexpand">True</property>
<child>
<object class="AdwClamp">
<child>
<object class="GtkBox">
<property name="margin-start">6</property>
<property name="margin-end">6</property>
<property name="margin-bottom">6</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkBox">
<property name="margin-top">12</property>
<property name="margin-bottom">6</property>
<child>
<object class="GtkLabel">
<property name="hexpand">true</property>
<property name="halign">start</property>
<property name="label" translatable="yes">Matching metadata</property>
<attributes>
<attribute name="weight" value="bold" />
</attributes>
</object>
</child>
</object>
</child>
<child>
<object class="GtkStack" id="matching_stack">
<property name="transition-type">crossfade</property>
<property name="vhomogeneous">false</property>
<property name="interpolate-size">true</property>
<child>
<object class="GtkStackPage">
<property name="name">loading</property>
<property name="child">
<object class="GtkListBox">
<property name="selection-mode">none</property>
<child>
<object class="AdwActionRow">
<property name="activatable">False</property>
<property name="title" translatable="yes">Loading…</property>
<child>
<object class="GtkSpinner">
<property name="spinning">True</property>
</object>
</child>
</object>
</child>
<style>
<class name="boxed-list" />
</style>
</object>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">error</property>
<property name="child">
<object class="GtkListBox">
<property name="selection-mode">none</property>
<child>
<object class="AdwActionRow" id="error_row">
<property name="focusable">False</property>
<property name="title" translatable="yes">Error while searching for matching metadata</property>
<property name="activatable-widget">try_again_button</property>
<child>
<object class="GtkButton" id="try_again_button">
<property name="icon-name">view-refresh-symbolic</property>
<property name="valign">center</property>
</object>
</child>
</object>
</child>
<style>
<class name="boxed-list" />
</style>
</object>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">empty</property>
<property name="child">
<object class="GtkListBox">
<property name="selection-mode">none</property>
<child>
<object class="AdwActionRow">
<property name="activatable">False</property>
<property name="title" translatable="yes">No matching metadata found</property>
</object>
</child>
<style>
<class name="boxed-list" />
</style>
</object>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">content</property>
<property name="child">
<object class="GtkListBox" id="matching_list">
<property name="selection-mode">none</property>
<style>
<class name="boxed-list" />
</style>
</object>
</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkLabel">
<property name="halign">start</property>
<property name="margin-top">12</property>
<property name="margin-bottom">6</property>
<property name="label" translatable="yes">Manually add metadata</property>
<attributes>
<attribute name="weight" value="bold" />
</attributes>
</object>
</child>
<child>
<object class="GtkListBox">
<property name="selection-mode">none</property>
<child>
<object class="AdwActionRow">
<property name="focusable">False</property>
<property name="title" translatable="yes">Select existing medium</property>
<property name="activatable-widget">select_button</property>
<child>
<object class="GtkButton" id="select_button">
<property name="label" translatable="yes">Select</property>
<property name="valign">center</property>
</object>
</child>
</object>
</child>
<child>
<object class="AdwActionRow">
<property name="focusable">False</property>
<property name="title" translatable="yes">Add a new medium</property>
<property name="activatable-widget">add_button</property>
<child>
<object class="GtkButton" id="add_button">
<property name="label" translatable="yes">Add</property>
<property name="valign">center</property>
</object>
</child>
</object>
</child>
<style>
<class name="boxed-list" />
</style>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</interface>

View file

@ -0,0 +1,159 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0" />
<requires lib="libadwaita" version="1.0" />
<object class="GtkBox" id="empty_screen">
<property name="orientation">vertical</property>
<child>
<object class="AdwHeaderBar">
<property name="title-widget">
<object class="GtkLabel" />
</property>
</object>
</child>
<child>
<object class="AdwStatusPage">
<property name="icon-name">folder-music-symbolic</property>
<property name="title" translatable="yes">Welcome to Musicus!</property>
<property name="description" translatable="yes">Get startet by selecting something from the sidebar or adding new things to your library using the button in the top left corner.</property>
<property name="vexpand">true</property>
<child>
<object class="GtkRevealer" id="play_button_revealer">
<property name="reveal-child">true</property>
<property name="transition-type">crossfade</property>
<child>
<object class="GtkButton" id="play_button">
<property name="halign">center</property>
<property name="label" translatable="yes">Play something</property>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
<object class="GtkBox" id="widget">
<property name="orientation">vertical</property>
<child>
<object class="AdwLeaflet" id="leaflet">
<property name="vexpand">true</property>
<child>
<object class="AdwLeafletPage">
<property name="name">sidebar</property>
<property name="child">
<object class="GtkBox">
<property name="hexpand">False</property>
<property name="orientation">vertical</property>
<child>
<object class="AdwHeaderBar">
<property name="show-start-title-buttons">false</property>
<property name="show-end-title-buttons">false</property>
<property name="title-widget">
<object class="GtkLabel">
<property name="label">Musicus</property>
<style>
<class name="title" />
</style>
</object>
</property>
<child>
<object class="GtkButton" id="add_button">
<property name="receives-default">True</property>
<child>
<object class="GtkImage">
<property name="icon-name">list-add-symbolic</property>
</object>
</child>
</object>
</child>
<child type="end">
<object class="GtkMenuButton">
<property name="receives-default">True</property>
<property name="icon-name">open-menu-symbolic</property>
<property name="menu-model">menu</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkSearchBar">
<property name="search-mode-enabled">True</property>
<child>
<object class="AdwClamp">
<property name="maximum-size">400</property>
<property name="tightening-threshold">300</property>
<property name="hexpand">true</property>
<child>
<object class="GtkSearchEntry" id="search_entry">
<property name="placeholder-text" translatable="yes">Search persons and ensembles …</property>
</object>
</child>
</object>
</child>
</object>
</child>
<child>
<object class="GtkStack" id="stack">
<property name="hexpand">True</property>
<property name="transition-type">crossfade</property>
<child>
<object class="GtkStackPage">
<property name="name">loading</property>
<property name="child">
<object class="GtkSpinner">
<property name="spinning">True</property>
<property name="hexpand">True</property>
<property name="vexpand">True</property>
<property name="halign">center</property>
<property name="valign">center</property>
</object>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">content</property>
<property name="child">
<object class="GtkScrolledWindow" id="scroll">
<child>
<placeholder />
</child>
</object>
</property>
</object>
</child>
</object>
</child>
</object>
</property>
</object>
</child>
<child>
<object class="AdwLeafletPage">
<property name="navigatable">False</property>
<property name="child">
<object class="GtkSeparator">
<property name="orientation">vertical</property>
<style>
<class name="sidebar" />
</style>
</object>
</property>
</object>
</child>
</object>
</child>
</object>
<menu id="menu">
<section>
<item>
<attribute name="label" translatable="yes">Preferences</attribute>
<attribute name="action">widget.preferences</attribute>
</item>
<item>
<attribute name="label" translatable="yes">About Musicus</attribute>
<attribute name="action">widget.about</attribute>
</item>
</section>
</menu>
</interface>

View file

@ -0,0 +1,206 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0" />
<requires lib="libadwaita" version="1.0" />
<object class="GtkStack" id="widget">
<property name="transition-type">crossfade</property>
<child>
<object class="GtkStackPage">
<property name="name">content</property>
<property name="child">
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="AdwHeaderBar">
<property name="show-start-title-buttons">false</property>
<property name="show-end-title-buttons">false</property>
<property name="title-widget">
<object class="GtkLabel">
<property name="label" translatable="yes">Import music</property>
<style>
<class name="title" />
</style>
</object>
</property>
<child>
<object class="GtkButton" id="back_button">
<property name="icon-name">go-previous-symbolic</property>
</object>
</child>
<child type="end">
<object class="GtkButton" id="done_button">
<property name="icon-name">object-select-symbolic</property>
<style>
<class name="suggested-action" />
</style>
</object>
</child>
</object>
</child>
<child>
<object class="GtkInfoBar" id="info_bar">
<property name="revealed">False</property>
</object>
</child>
<child>
<object class="GtkScrolledWindow">
<property name="vexpand">True</property>
<child>
<object class="AdwClamp">
<child>
<object class="GtkBox">
<property name="margin-start">6</property>
<property name="margin-end">6</property>
<property name="margin-bottom">6</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkLabel">
<property name="halign">start</property>
<property name="margin-top">12</property>
<property name="margin-bottom">6</property>
<property name="label" translatable="yes">Medium</property>
<attributes>
<attribute name="weight" value="bold" />
</attributes>
</object>
</child>
<child>
<object class="GtkListBox">
<property name="selection-mode">none</property>
<child>
<object class="AdwEntryRow" id="name_row">
<property name="title" translatable="yes">Name of the medium</property>
</object>
</child>
<style>
<class name="boxed-list" />
</style>
</object>
</child>
<child>
<object class="GtkBox">
<property name="orientation">horizontal</property>
<property name="margin-top">12</property>
<property name="margin-bottom">6</property>
<child>
<object class="GtkLabel">
<property name="halign">start</property>
<property name="valign">end</property>
<property name="hexpand">True</property>
<property name="label" translatable="yes">Recordings</property>
<attributes>
<attribute name="weight" value="bold" />
</attributes>
</object>
</child>
<child>
<object class="GtkButton" id="add_button">
<property name="has-frame">false</property>
<property name="icon-name">list-add-symbolic</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkFrame" id="frame" />
</child>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">loading</property>
<property name="child">
<object class="GtkSpinner">
<property name="spinning">true</property>
<property name="hexpand">true</property>
<property name="vexpand">true</property>
<property name="halign">center</property>
<property name="valign">center</property>
</object>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">error</property>
<property name="child">
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="AdwHeaderBar">
<property name="show-start-title-buttons">false</property>
<property name="show-end-title-buttons">false</property>
<property name="title-widget">
<object class="AdwWindowTitle">
<property name="title" translatable="yes">Error</property>
</object>
</property>
</object>
</child>
<child>
<object class="AdwStatusPage" id="status_page">
<property name="icon-name">dialog-error-symbolic</property>
<property name="title">Error</property>
<property name="vexpand">true</property>
<child>
<object class="GtkButton" id="try_again_button">
<property name="label" translatable="yes">Try again</property>
<property name="hexpand">true</property>
<property name="vexpand">true</property>
<property name="halign">center</property>
<property name="valign">center</property>
</object>
</child>
</object>
</child>
</object>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">disc_error</property>
<property name="child">
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="AdwHeaderBar">
<property name="show-start-title-buttons">false</property>
<property name="show-end-title-buttons">false</property>
<property name="title-widget">
<object class="AdwWindowTitle">
<property name="title" translatable="yes">Error</property>
</object>
</property>
</object>
</child>
<child>
<object class="AdwStatusPage" id="disc_status_page">
<property name="icon-name">action-unavailable-symbolic</property>
<property name="title">Error</property>
<property name="vexpand">true</property>
<child>
<object class="GtkButton" id="cancel_button">
<property name="label" translatable="yes">Cancel</property>
<property name="hexpand">true</property>
<property name="vexpand">true</property>
<property name="halign">center</property>
<property name="valign">center</property>
</object>
</child>
</object>
</child>
</object>
</property>
</object>
</child>
</object>
</interface>

View file

@ -0,0 +1,175 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0"/>
<requires lib="libadwaita" version="1.0"/>
<object class="GtkStack" id="widget">
<property name="transition-type">crossfade</property>
<child>
<object class="GtkStackPage">
<property name="name">content</property>
<property name="child">
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="AdwHeaderBar">
<property name="show-start-title-buttons">false</property>
<property name="show-end-title-buttons">false</property>
<property name="title-widget">
<object class="AdwWindowTitle" id="window_title">
<property name="title" translatable="yes">Preview</property>
</object>
</property>
<child>
<object class="GtkButton" id="back_button">
<property name="icon-name">go-previous-symbolic</property>
</object>
</child>
<child type="end">
<object class="GtkButton" id="import_button">
<property name="sensitive">False</property>
<child>
<object class="GtkStack" id="done_stack">
<property name="transition-type">crossfade</property>
<property name="interpolate-size">true</property>
<property name="hhomogeneous">false</property>
<child>
<object class="GtkStackPage">
<property name="name">loading</property>
<property name="child">
<object class="GtkSpinner">
<property name="spinning">True</property>
</object>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">ready</property>
<property name="child">
<object class="GtkLabel">
<property name="label" translatable="yes">Import</property>
</object>
</property>
</object>
</child>
</object>
</child>
<style>
<class name="suggested-action"/>
</style>
</object>
</child>
<child type="end">
<object class="GtkButton" id="edit_button">
<property name="icon-name">document-edit-symbolic</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkScrolledWindow">
<property name="vexpand">True</property>
<child>
<object class="AdwClamp">
<child>
<object class="GtkBox">
<property name="margin-start">6</property>
<property name="margin-end">6</property>
<property name="margin-bottom">6</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkLabel" id="name_label">
<property name="halign">start</property>
<property name="margin-top">12</property>
<property name="margin-bottom">6</property>
<attributes>
<attribute name="weight" value="bold"/>
</attributes>
</object>
</child>
<child>
<object class="GtkBox" id="medium_box">
<property name="orientation">vertical</property>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">loading</property>
<property name="child">
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="AdwHeaderBar">
<property name="show-start-title-buttons">false</property>
<property name="show-end-title-buttons">false</property>
<property name="title-widget">
<object class="AdwWindowTitle">
<property name="title" translatable="yes">Loading</property>
</object>
</property>
</object>
</child>
<child>
<object class="GtkSpinner">
<property name="hexpand">true</property>
<property name="vexpand">true</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="width-request">32</property>
<property name="height-request">32</property>
<property name="spinning">true</property>
</object>
</child>
</object>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">error</property>
<property name="child">
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="AdwHeaderBar">
<property name="show-start-title-buttons">false</property>
<property name="show-end-title-buttons">false</property>
<property name="title-widget">
<object class="AdwWindowTitle">
<property name="title" translatable="yes">Error</property>
</object>
</property>
</object>
</child>
<child>
<object class="AdwStatusPage" id="status_page">
<property name="icon-name">dialog-error-symbolic</property>
<property name="title">Error</property>
<property name="vexpand">true</property>
<child>
<object class="GtkButton" id="try_again_button">
<property name="label" translatable="yes">Try again</property>
<property name="hexpand">true</property>
<property name="vexpand">true</property>
<property name="halign">center</property>
<property name="valign">center</property>
</object>
</child>
</object>
</child>
</object>
</property>
</object>
</child>
</object>
</interface>

View file

@ -0,0 +1,105 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0" />
<requires lib="libadwaita" version="1.0" />
<object class="GtkBox" id="widget">
<property name="orientation">vertical</property>
<child>
<object class="AdwHeaderBar">
<property name="show-start-title-buttons">false</property>
<property name="show-end-title-buttons">false</property>
<property name="title-widget">
<object class="GtkLabel">
<property name="label" translatable="yes">Performance</property>
<style>
<class name="title" />
</style>
</object>
</property>
<child>
<object class="GtkButton" id="back_button">
<property name="icon-name">go-previous-symbolic</property>
</object>
</child>
<child type="end">
<object class="GtkButton" id="save_button">
<property name="sensitive">False</property>
<property name="icon-name">object-select-symbolic</property>
<style>
<class name="suggested-action" />
</style>
</object>
</child>
</object>
</child>
<child>
<object class="GtkScrolledWindow">
<property name="vexpand">true</property>
<child>
<object class="AdwClamp">
<property name="margin-start">12</property>
<property name="margin-end">12</property>
<property name="margin-top">18</property>
<property name="margin-bottom">12</property>
<property name="maximum-size">500</property>
<property name="tightening-threshold">300</property>
<child>
<object class="GtkListBox">
<property name="selection-mode">none</property>
<child>
<object class="AdwActionRow" id="person_row">
<property name="focusable">False</property>
<property name="title" translatable="yes">Select a person</property>
<property name="activatable-widget">person_button</property>
<child>
<object class="GtkButton" id="person_button">
<property name="label" translatable="yes">Select</property>
<property name="valign">center</property>
</object>
</child>
</object>
</child>
<child>
<object class="AdwActionRow" id="ensemble_row">
<property name="focusable">False</property>
<property name="title" translatable="yes">Select an ensemble</property>
<property name="activatable-widget">ensemble_button</property>
<child>
<object class="GtkButton" id="ensemble_button">
<property name="label" translatable="yes">Select</property>
<property name="valign">center</property>
</object>
</child>
</object>
</child>
<child>
<object class="AdwActionRow" id="role_row">
<property name="focusable">False</property>
<property name="title" translatable="yes">Select a role</property>
<property name="activatable-widget">role_button</property>
<child>
<object class="GtkButton" id="reset_role_button">
<property name="visible">false</property>
<property name="icon-name">user-trash-symbolic</property>
<property name="valign">center</property>
</object>
</child>
<child>
<object class="GtkButton" id="role_button">
<property name="label" translatable="yes">Select</property>
<property name="valign">center</property>
</object>
</child>
</object>
</child>
<style>
<class name="boxed-list" />
</style>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</interface>

View file

@ -0,0 +1,116 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0"/>
<object class="GtkImage" id="play_image">
<property name="icon-name">media-playback-start-symbolic</property>
</object>
<object class="GtkRevealer" id="widget">
<property name="transition-type">slide-up</property>
<child>
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="GtkSeparator"/>
</child>
<child>
<object class="GtkBox">
<property name="margin-top">6</property>
<property name="margin-bottom">6</property>
<property name="margin-start">6</property>
<property name="margin-end">6</property>
<property name="spacing">12</property>
<child>
<object class="GtkBox">
<property name="valign">center</property>
<property name="spacing">6</property>
<child>
<object class="GtkButton" id="previous_button">
<property name="sensitive">False</property>
<child>
<object class="GtkImage">
<property name="icon-name">media-skip-backward-symbolic</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkButton" id="play_button">
<child>
<object class="GtkImage" id="pause_image">
<property name="icon-name">media-playback-pause-symbolic</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkButton" id="next_button">
<property name="sensitive">False</property>
<property name="receives-default">True</property>
<child>
<object class="GtkImage">
<property name="icon-name">media-skip-forward-symbolic</property>
</object>
</child>
</object>
</child>
</object>
</child>
<child>
<object class="GtkBox">
<property name="orientation">vertical</property>
<property name="hexpand">True</property>
<child>
<object class="GtkLabel" id="title_label">
<property name="halign">start</property>
<property name="label" translatable="yes">Title</property>
<property name="ellipsize">end</property>
<attributes>
<attribute name="weight" value="bold"/>
</attributes>
</object>
</child>
<child>
<object class="GtkLabel" id="subtitle_label">
<property name="halign">start</property>
<property name="label" translatable="yes">Subtitle</property>
<property name="ellipsize">end</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkBox">
<property name="spacing">2</property>
<child>
<object class="GtkLabel" id="position_label">
<property name="label" translatable="yes">0:00</property>
</object>
</child>
<child>
<object class="GtkLabel">
<property name="label" translatable="yes">/</property>
</object>
</child>
<child>
<object class="GtkLabel" id="duration_label">
<property name="label" translatable="yes">0:00</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkButton" id="playlist_button">
<property name="valign">center</property>
<child>
<object class="GtkImage">
<property name="icon-name">view-list-bullet-symbolic</property>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</interface>

View file

@ -0,0 +1,154 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0"/>
<requires lib="libadwaita" version="1.0"/>
<object class="GtkImage" id="play_image">
<property name="icon-name">media-playback-start-symbolic</property>
</object>
<object class="GtkAdjustment" id="position">
<property name="upper">1</property>
<property name="step-increment">0.01</property>
<property name="page-increment">0.05</property>
</object>
<object class="GtkBox" id="widget">
<property name="orientation">vertical</property>
<child>
<object class="AdwHeaderBar">
<property name="title-widget">
<object class="GtkLabel">
<property name="label" translatable="yes">Player</property>
<style>
<class name="title"/>
</style>
</object>
</property>
<child>
<object class="GtkButton" id="back_button">
<child>
<object class="GtkImage">
<property name="icon-name">go-previous-symbolic</property>
</object>
</child>
</object>
</child>
</object>
</child>
<child>
<object class="GtkScrolledWindow">
<property name="vexpand">true</property>
<child>
<object class="AdwClamp">
<property name="margin-start">12</property>
<property name="margin-end">12</property>
<property name="margin-top">18</property>
<property name="margin-bottom">12</property>
<property name="maximum-size">800</property>
<child>
<object class="GtkBox" id="content">
<property name="orientation">vertical</property>
<property name="spacing">12</property>
<child>
<object class="GtkBox">
<property name="spacing">12</property>
<child>
<object class="GtkBox">
<property name="valign">center</property>
<property name="spacing">6</property>
<child>
<object class="GtkButton" id="previous_button">
<property name="sensitive">False</property>
<child>
<object class="GtkImage">
<property name="icon-name">media-skip-backward-symbolic</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkButton" id="play_button">
<property name="receives-default">True</property>
<child>
<object class="GtkImage" id="pause_image">
<property name="icon-name">media-playback-pause-symbolic</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkButton" id="next_button">
<property name="sensitive">False</property>
<property name="receives-default">True</property>
<child>
<object class="GtkImage">
<property name="icon-name">media-skip-forward-symbolic</property>
</object>
</child>
</object>
</child>
</object>
</child>
<child>
<object class="GtkBox">
<property name="orientation">vertical</property>
<property name="hexpand">True</property>
<child>
<object class="GtkLabel" id="title_label">
<property name="halign">start</property>
<property name="label" translatable="yes">Title</property>
<property name="ellipsize">end</property>
<attributes>
<attribute name="weight" value="bold"/>
</attributes>
</object>
</child>
<child>
<object class="GtkLabel" id="subtitle_label">
<property name="halign">start</property>
<property name="label" translatable="yes">Subtitle</property>
<property name="ellipsize">end</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkButton" id="stop_button">
<property name="receives-default">True</property>
<property name="valign">center</property>
<child>
<object class="GtkImage">
<property name="icon-name">media-playback-stop-symbolic</property>
</object>
</child>
</object>
</child>
</object>
</child>
<child>
<object class="GtkBox">
<property name="spacing">6</property>
<child>
<object class="GtkLabel" id="position_label">
<property name="label" translatable="yes">0:00</property>
</object>
</child>
<child>
<object class="GtkScale" id="position_scale">
<property name="adjustment">position</property>
<property name="hexpand">True</property>
</object>
</child>
<child>
<object class="GtkLabel" id="duration_label">
<property name="label" translatable="yes">0:00</property>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</interface>

View file

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0" />
<requires lib="libadwaita" version="1.0" />
<object class="AdwPreferencesWindow" id="window">
<property name="modal">True</property>
<property name="default-width">400</property>
<property name="default-height">400</property>
<child>
<object class="AdwPreferencesPage">
<property name="title" translatable="yes">General</property>
<child>
<object class="AdwPreferencesGroup">
<property name="title" translatable="yes">Music library</property>
<child>
<object class="AdwActionRow" id="music_library_path_row">
<property name="focusable">False</property>
<property name="title" translatable="yes">Music library folder</property>
<property name="activatable-widget">select_music_library_path_button</property>
<property name="subtitle" translatable="yes">None selected</property>
<child>
<object class="GtkButton" id="select_music_library_path_button">
<property name="label" translatable="yes">Select</property>
<property name="receives-default">True</property>
<property name="valign">center</property>
</object>
</child>
</object>
</child>
</object>
</child>
<child>
<object class="AdwPreferencesGroup">
<property name="title" translatable="yes">Playlist</property>
<child>
<object class="AdwActionRow">
<property name="focusable">False</property>
<property name="title" translatable="yes">Keep playing</property>
<property name="activatable-widget">keep_playing_switch</property>
<property name="subtitle" translatable="yes">Whether to keep playing random tracks after the playlist ends.</property>
<child>
<object class="GtkSwitch" id="keep_playing_switch">
<property name="valign">center</property>
</object>
</child>
</object>
</child>
<child>
<object class="AdwActionRow">
<property name="focusable">False</property>
<property name="title" translatable="yes">Choose full recordings</property>
<property name="activatable-widget">play_full_recordings_switch</property>
<property name="subtitle" translatable="yes">Whether to choose full recordings instead of single tracks for random playback.</property>
<child>
<object class="GtkSwitch" id="play_full_recordings_switch">
<property name="valign">center</property>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</interface>

View file

@ -0,0 +1,168 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0" />
<requires lib="libadwaita" version="1.0" />
<object class="GtkStack" id="widget">
<child>
<object class="GtkStackPage">
<property name="name">content</property>
<property name="child">
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="AdwHeaderBar">
<property name="show-start-title-buttons">false</property>
<property name="show-end-title-buttons">false</property>
<property name="title-widget">
<object class="GtkLabel">
<property name="label" translatable="yes">Recording</property>
<style>
<class name="title" />
</style>
</object>
</property>
<child>
<object class="GtkButton" id="back_button">
<property name="icon-name">go-previous-symbolic</property>
</object>
</child>
<child type="end">
<object class="GtkButton" id="save_button">
<property name="sensitive">False</property>
<property name="icon-name">object-select-symbolic</property>
<style>
<class name="suggested-action" />
</style>
</object>
</child>
</object>
</child>
<child>
<object class="GtkInfoBar" id="info_bar">
<property name="revealed">False</property>
</object>
</child>
<child>
<object class="GtkScrolledWindow">
<property name="vexpand">true</property>
<child>
<object class="AdwClamp">
<property name="margin-start">12</property>
<property name="margin-end">12</property>
<property name="margin-top">18</property>
<property name="margin-bottom">12</property>
<child>
<object class="GtkBox">
<property name="margin-start">6</property>
<property name="margin-end">6</property>
<property name="margin-bottom">6</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkLabel">
<property name="halign">start</property>
<property name="margin-top">12</property>
<property name="margin-bottom">6</property>
<property name="label" translatable="yes">Overview</property>
<attributes>
<attribute name="weight" value="bold" />
</attributes>
</object>
</child>
<child>
<object class="GtkListBox">
<property name="selection-mode">none</property>
<child>
<object class="AdwActionRow" id="work_row">
<property name="focusable">False</property>
<property name="title" translatable="yes">Select a work</property>
<property name="activatable-widget">work_button</property>
<child>
<object class="GtkButton" id="work_button">
<property name="label" translatable="yes">Select</property>
<property name="valign">center</property>
</object>
</child>
</object>
</child>
<child>
<object class="AdwEntryRow" id="comment_row">
<property name="title" translatable="yes">Comment</property>
</object>
</child>
<style>
<class name="boxed-list" />
</style>
</object>
</child>
<child>
<object class="GtkBox">
<property name="orientation">horizontal</property>
<property name="margin-top">12</property>
<property name="margin-bottom">6</property>
<child>
<object class="GtkLabel">
<property name="halign">start</property>
<property name="valign">end</property>
<property name="hexpand">True</property>
<property name="label" translatable="yes">Performers</property>
<attributes>
<attribute name="weight" value="bold" />
</attributes>
</object>
</child>
<child>
<object class="GtkButton" id="add_performer_button">
<property name="has-frame">false</property>
<property name="icon-name">list-add-symbolic</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkFrame" id="performance_frame" />
</child>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">loading</property>
<property name="child">
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="AdwHeaderBar">
<property name="show-start-title-buttons">false</property>
<property name="show-end-title-buttons">false</property>
<property name="title-widget">
<object class="GtkLabel">
<property name="label" translatable="yes">Recording</property>
<style>
<class name="title" />
</style>
</object>
</property>
</object>
</child>
<child>
<object class="GtkSpinner">
<property name="hexpand">true</property>
<property name="vexpand">true</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="spinning">true</property>
</object>
</child>
</object>
</property>
</object>
</child>
</object>
</interface>

View file

@ -0,0 +1,86 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0"/>
<requires lib="libadwaita" version="1.0"/>
<object class="GtkBox" id="widget">
<property name="orientation">vertical</property>
<child>
<object class="AdwHeaderBar">
<property name="title-widget">
<object class="AdwWindowTitle" id="window_title"/>
</property>
<child>
<object class="GtkButton" id="back_button">
<property name="icon-name">go-previous-symbolic</property>
</object>
</child>
<child type="end">
<object class="GtkMenuButton">
<property name="menu-model">menu</property>
<property name="icon-name">view-more-symbolic</property>
</object>
</child>
<child type="end">
<object class="GtkToggleButton" id="search_button">
<property name="icon-name">edit-find-symbolic</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkSearchBar">
<property name="search-mode-enabled" bind-source="search_button" bind-property="active" bind-flags="bidirectional|sync-create">False</property>
<child>
<object class="AdwClamp">
<property name="hexpand">true</property>
<child>
<object class="GtkSearchEntry" id="search_entry"/>
</child>
</object>
</child>
</object>
</child>
<child>
<object class="GtkStack" id="stack">
<child>
<object class="GtkStackPage">
<property name="name">loading</property>
<property name="child">
<object class="GtkSpinner">
<property name="hexpand">true</property>
<property name="vexpand">true</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="width-request">32</property>
<property name="height-request">32</property>
<property name="spinning">true</property>
</object>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">content</property>
<property name="child">
<object class="GtkScrolledWindow">
<child>
<object class="AdwClamp">
<child>
<object class="GtkBox" id="content_box">
<property name="orientation">vertical</property>
<property name="margin-start">12</property>
<property name="margin-end">12</property>
<property name="margin-bottom">36</property>
</object>
</child>
</object>
</child>
</object>
</property>
</object>
</child>
</object>
</child>
</object>
<menu id="menu"/>
</interface>

View file

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0"/>
<object class="GtkBox" id="widget">
<property name="orientation">vertical</property>
<property name="spacing">6</property>
<child>
<object class="GtkBox" id="title_box">
<property name="spacing">12</property>
<child>
<object class="GtkBox">
<property name="orientation">vertical</property>
<property name="margin-top">18</property>
<property name="valign">end</property>
<property name="hexpand">true</property>
<child>
<object class="GtkLabel" id="title_label">
<property name="ellipsize">end</property>
<property name="xalign">0.0</property>
<attributes>
<attribute name="weight" value="bold"/>
</attributes>
</object>
</child>
<child>
<object class="GtkLabel" id="subtitle_label">
<property name="wrap">true</property>
<property name="xalign">0.0</property>
<property name="visible">false</property>
<property name="margin-bottom">6</property>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</interface>

View file

@ -0,0 +1,112 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0"/>
<requires lib="libadwaita" version="1.0"/>
<object class="GtkBox" id="widget">
<property name="width-request">250</property>
<property name="hexpand">False</property>
<property name="orientation">vertical</property>
<child>
<object class="AdwHeaderBar" id="header">
<property name="show-start-title-buttons">false</property>
<property name="show-end-title-buttons">false</property>
<property name="title-widget">
<object class="GtkBox">
<property name="orientation">vertical</property>
<property name="valign">center</property>
<child>
<object class="GtkLabel" id="title_label">
<style>
<class name="title"/>
</style>
</object>
</child>
<child>
<object class="GtkLabel" id="subtitle_label">
<property name="visible">false</property>
<style>
<class name="subtitle"/>
</style>
</object>
</child>
</object>
</property>
<child>
<object class="GtkButton" id="back_button">
<property name="icon-name">go-previous-symbolic</property>
</object>
</child>
<child type="end">
<object class="GtkButton" id="add_button">
<property name="icon-name">list-add-symbolic</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkSearchBar">
<property name="search-mode-enabled">True</property>
<child>
<object class="AdwClamp">
<property name="maximum-size">500</property>
<property name="tightening-threshold">300</property>
<property name="hexpand">true</property>
<child>
<object class="GtkBox">
<property name="orientation">vertical</property>
<property name="spacing">6</property>
<child>
<object class="GtkSearchEntry" id="search_entry">
<property name="placeholder-text" translatable="yes">Search …</property>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</child>
<child>
<object class="GtkStack" id="stack">
<property name="hhomogeneous">False</property>
<property name="vhomogeneous">False</property>
<property name="transition-type">crossfade</property>
<property name="interpolate-size">True</property>
<child>
<object class="GtkStackPage">
<property name="name">loading</property>
<property name="child">
<object class="GtkSpinner">
<property name="margin-top">12</property>
<property name="halign">center</property>
<property name="valign">start</property>
<property name="spinning">True</property>
</object>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">content</property>
<property name="child">
<object class="GtkScrolledWindow">
<property name="height-request">200</property>
<property name="vexpand">true</property>
<child>
<object class="AdwClamp" id="clamp">
<property name="maximum-size">500</property>
<property name="tightening-threshold">300</property>
<property name="margin-start">6</property>
<property name="margin-end">6</property>
<property name="margin-top">12</property>
<property name="margin-bottom">6</property>
</object>
</child>
</object>
</property>
</object>
</child>
</object>
</child>
</object>
</interface>

View file

@ -0,0 +1,127 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0"/>
<requires lib="libadwaita" version="1.0"/>
<object class="GtkStack" id="widget">
<property name="transition-type">crossfade</property>
<child>
<object class="GtkStackPage">
<property name="name">content</property>
<property name="child">
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="AdwHeaderBar">
<property name="show-start-title-buttons">false</property>
<property name="show-end-title-buttons">false</property>
<property name="title-widget">
<object class="AdwWindowTitle" id="window_title"/>
</property>
<child>
<object class="GtkButton" id="back_button">
<property name="icon-name">go-previous-symbolic</property>
</object>
</child>
</object>
</child>
<child>
<object class="AdwStatusPage">
<property name="vexpand">true</property>
<property name="icon-name">folder-music-symbolic</property>
<property name="title" translatable="yes">Import music</property>
<property name="description" translatable="yes">Select the source which contains the new audio files below.</property>
<child>
<object class="GtkBox">
<property name="orientation">horizontal</property>
<property name="homogeneous">true</property>
<property name="spacing">6</property>
<property name="halign">center</property>
<child>
<object class="GtkButton" id="folder_button">
<property name="label" translatable="yes">Select folder</property>
</object>
</child>
<child>
<object class="GtkButton" id="disc_button">
<property name="label" translatable="yes">Copy audio CD</property>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">loading</property>
<property name="child">
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="AdwHeaderBar">
<property name="show-start-title-buttons">false</property>
<property name="show-end-title-buttons">false</property>
<property name="title-widget">
<object class="AdwWindowTitle">
<property name="title" translatable="yes">Loading</property>
</object>
</property>
</object>
</child>
<child>
<object class="GtkSpinner">
<property name="hexpand">true</property>
<property name="vexpand">true</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="width-request">32</property>
<property name="height-request">32</property>
<property name="spinning">true</property>
</object>
</child>
</object>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">error</property>
<property name="child">
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="AdwHeaderBar">
<property name="show-start-title-buttons">false</property>
<property name="show-end-title-buttons">false</property>
<property name="title-widget">
<object class="AdwWindowTitle">
<property name="title" translatable="yes">Error</property>
</object>
</property>
</object>
</child>
<child>
<object class="AdwStatusPage" id="status_page">
<property name="icon-name">dialog-error-symbolic</property>
<property name="title">Error</property>
<property name="vexpand">true</property>
<child>
<object class="GtkButton" id="try_again_button">
<property name="label" translatable="yes">Try again</property>
<property name="hexpand">true</property>
<property name="vexpand">true</property>
<property name="halign">center</property>
<property name="valign">center</property>
</object>
</child>
</object>
</child>
</object>
</property>
</object>
</child>
</object>
</interface>

View file

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0" />
<requires lib="libadwaita" version="1.0" />
<object class="GtkBox" id="widget">
<property name="orientation">vertical</property>
<child>
<object class="AdwHeaderBar">
<property name="show-start-title-buttons">false</property>
<property name="show-end-title-buttons">false</property>
<property name="title-widget">
<object class="GtkLabel">
<property name="label" translatable="yes">Track</property>
<style>
<class name="title" />
</style>
</object>
</property>
<child>
<object class="GtkButton" id="back_button">
<property name="icon-name">go-previous-symbolic</property>
</object>
</child>
<child type="end">
<object class="GtkButton" id="select_button">
<property name="icon-name">object-select-symbolic</property>
<style>
<class name="suggested-action" />
</style>
</object>
</child>
</object>
</child>
<child>
<object class="GtkScrolledWindow">
<property name="vexpand">True</property>
<child>
<object class="AdwClamp" id="clamp">
<property name="margin-top">12</property>
<property name="margin-start">6</property>
<property name="margin-end">6</property>
<property name="margin-bottom">6</property>
</object>
</child>
</object>
</child>
</object>
</interface>

View file

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0"/>
<object class="GtkListBoxRow" id="widget">
<child>
<object class="GtkBox">
<property name="orientation">horizontal</property>
<property name="margin-top">12</property>
<property name="margin-start">6</property>
<property name="margin-end">6</property>
<child>
<object class="GtkRevealer" id="playing_revealer">
<child>
<object class="GtkImage" id="playing_image">
<property name="icon-name">media-playback-start-symbolic</property>
<property name="margin-top">6</property>
<property name="margin-start">12</property>
<property name="margin-end">18</property>
<property name="valign">start</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkBox">
<property name="orientation">vertical</property>
<property name="hexpand">true</property>
<child>
<object class="GtkBox" id="header_box">
<property name="orientation">vertical</property>
<property name="visible">false</property>
<property name="margin-bottom">12</property>
<child>
<object class="GtkLabel" id="work_title_label">
<property name="wrap">true</property>
<property name="xalign">0.0</property>
<attributes>
<attribute name="weight" value="bold"/>
</attributes>
</object>
</child>
<child>
<object class="GtkLabel" id="performances_label">
<property name="wrap">true</property>
<property name="xalign">0.0</property>
<style>
<class name="subtitle"/>
</style>
</object>
</child>
</object>
</child>
<child>
<object class="GtkLabel" id="track_title_label">
<property name="wrap">true</property>
<property name="xalign">0.0</property>
<property name="margin-bottom">12</property>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</interface>

View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0"/>
<requires lib="libadwaita" version="1.0"/>
<object class="GtkBox" id="widget">
<property name="orientation">vertical</property>
<child>
<object class="AdwHeaderBar">
<property name="show-start-title-buttons">false</property>
<property name="show-end-title-buttons">false</property>
<property name="title-widget">
<object class="GtkLabel">
<property name="label" translatable="yes">Select tracks</property>
<style>
<class name="title"/>
</style>
</object>
</property>
<child>
<object class="GtkButton" id="back_button">
<property name="icon-name">go-previous-symbolic</property>
</object>
</child>
<child type="end">
<object class="GtkButton" id="select_button">
<property name="sensitive">False</property>
<property name="icon-name">object-select-symbolic</property>
<style>
<class name="suggested-action"/>
</style>
</object>
</child>
</object>
</child>
<child>
<object class="GtkScrolledWindow">
<property name="vexpand">True</property>
<child>
<object class="AdwClamp" id="clamp">
<property name="margin-top">12</property>
<property name="margin-start">6</property>
<property name="margin-end">6</property>
<property name="margin-bottom">6</property>
</object>
</child>
</object>
</child>
</object>
</interface>

View file

@ -0,0 +1,112 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0" />
<requires lib="libadwaita" version="1.0" />
<object class="GtkBox" id="widget">
<property name="orientation">vertical</property>
<child>
<object class="AdwHeaderBar">
<property name="show-start-title-buttons">false</property>
<property name="show-end-title-buttons">false</property>
<property name="title-widget">
<object class="GtkLabel">
<property name="label" translatable="yes">Import music</property>
<style>
<class name="title" />
</style>
</object>
</property>
<child>
<object class="GtkButton" id="back_button">
<property name="icon-name">go-previous-symbolic</property>
</object>
</child>
<child type="end">
<object class="GtkButton" id="save_button">
<property name="icon-name">object-select-symbolic</property>
<property name="sensitive">False</property>
<style>
<class name="suggested-action" />
</style>
</object>
</child>
</object>
</child>
<child>
<object class="GtkScrolledWindow">
<property name="vexpand">True</property>
<child>
<object class="AdwClamp">
<child>
<object class="GtkBox">
<property name="margin-start">6</property>
<property name="margin-end">6</property>
<property name="margin-bottom">6</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkLabel">
<property name="halign">start</property>
<property name="margin-top">12</property>
<property name="margin-bottom">6</property>
<property name="label" translatable="yes">Recording</property>
<attributes>
<attribute name="weight" value="bold" />
</attributes>
</object>
</child>
<child>
<object class="GtkListBox">
<property name="selection-mode">none</property>
<child>
<object class="AdwActionRow" id="recording_row">
<property name="focusable">False</property>
<property name="title" translatable="yes">Select a recording</property>
<property name="activatable-widget">select_recording_button</property>
<child>
<object class="GtkButton" id="select_recording_button">
<property name="label" translatable="yes">Select</property>
<property name="valign">center</property>
</object>
</child>
</object>
</child>
<style>
<class name="boxed-list" />
</style>
</object>
</child>
<child>
<object class="GtkBox">
<property name="orientation">horizontal</property>
<property name="margin-top">12</property>
<property name="margin-bottom">6</property>
<child>
<object class="GtkLabel">
<property name="halign">start</property>
<property name="valign">end</property>
<property name="hexpand">True</property>
<property name="label" translatable="yes">Tracks</property>
<attributes>
<attribute name="weight" value="bold" />
</attributes>
</object>
</child>
<child>
<object class="GtkButton" id="edit_tracks_button">
<property name="has-frame">false</property>
<property name="icon-name">document-edit-symbolic</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkFrame" id="tracks_frame" />
</child>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</interface>

View file

@ -0,0 +1,195 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0" />
<requires lib="libadwaita" version="1.0" />
<object class="GtkStack" id="widget">
<child>
<object class="GtkStackPage">
<property name="name">content</property>
<property name="child">
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="AdwHeaderBar">
<property name="show-start-title-buttons">false</property>
<property name="show-end-title-buttons">false</property>
<property name="title-widget">
<object class="GtkLabel">
<property name="label" translatable="yes">Work</property>
<style>
<class name="title" />
</style>
</object>
</property>
<child>
<object class="GtkButton" id="back_button">
<property name="icon-name">go-previous-symbolic</property>
</object>
</child>
<child type="end">
<object class="GtkButton" id="save_button">
<property name="sensitive">False</property>
<property name="icon-name">object-select-symbolic</property>
<style>
<class name="suggested-action" />
</style>
</object>
</child>
</object>
</child>
<child>
<object class="GtkInfoBar" id="info_bar">
<property name="revealed">False</property>
</object>
</child>
<child>
<object class="GtkScrolledWindow">
<property name="vexpand">true</property>
<child>
<object class="AdwClamp">
<property name="margin-start">12</property>
<property name="margin-end">12</property>
<property name="margin-top">18</property>
<property name="margin-bottom">12</property>
<child>
<object class="GtkBox">
<property name="margin-start">6</property>
<property name="margin-end">6</property>
<property name="margin-bottom">6</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkLabel">
<property name="halign">start</property>
<property name="margin-top">12</property>
<property name="margin-bottom">6</property>
<property name="label" translatable="yes">Overview</property>
<attributes>
<attribute name="weight" value="bold" />
</attributes>
</object>
</child>
<child>
<object class="GtkListBox">
<property name="selection-mode">none</property>
<child>
<object class="AdwActionRow" id="composer_row">
<property name="focusable">False</property>
<property name="title" translatable="yes">Select a composer</property>
<property name="activatable-widget">composer_button</property>
<child>
<object class="GtkButton" id="composer_button">
<property name="label" translatable="yes">Select</property>
<property name="valign">center</property>
</object>
</child>
</object>
</child>
<child>
<object class="AdwEntryRow" id="title_row">
<property name="title" translatable="yes">Title</property>
</object>
</child>
<style>
<class name="boxed-list" />
</style>
</object>
</child>
<child>
<object class="GtkBox">
<property name="orientation">horizontal</property>
<property name="margin-top">12</property>
<property name="margin-bottom">6</property>
<child>
<object class="GtkLabel">
<property name="halign">start</property>
<property name="valign">end</property>
<property name="hexpand">True</property>
<property name="label" translatable="yes">Instruments</property>
<attributes>
<attribute name="weight" value="bold" />
</attributes>
</object>
</child>
<child>
<object class="GtkButton" id="add_instrument_button">
<property name="has-frame">false</property>
<property name="icon-name">list-add-symbolic</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkFrame" id="instrument_frame" />
</child>
<child>
<object class="GtkBox">
<property name="orientation">horizontal</property>
<property name="margin-top">12</property>
<property name="margin-bottom">6</property>
<child>
<object class="GtkLabel">
<property name="halign">start</property>
<property name="valign">end</property>
<property name="hexpand">True</property>
<property name="label" translatable="yes">Structure</property>
<attributes>
<attribute name="weight" value="bold" />
</attributes>
</object>
</child>
<child>
<object class="GtkButton" id="add_part_button">
<property name="has-frame">false</property>
<property name="icon-name">list-add-symbolic</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkFrame" id="structure_frame" />
</child>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">loading</property>
<property name="child">
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="AdwHeaderBar">
<property name="show-start-title-buttons">false</property>
<property name="show-end-title-buttons">false</property>
<property name="title-widget">
<object class="GtkLabel">
<property name="label" translatable="yes">Work</property>
<style>
<class name="title" />
</style>
</object>
</property>
</object>
</child>
<child>
<object class="GtkSpinner">
<property name="hexpand">true</property>
<property name="vexpand">true</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="spinning">True</property>
</object>
</child>
</object>
</property>
</object>
</child>
</object>
</interface>

View file

@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0" />
<requires lib="libadwaita" version="1.0" />
<object class="GtkBox" id="widget">
<property name="orientation">vertical</property>
<child>
<object class="AdwHeaderBar">
<property name="show-start-title-buttons">false</property>
<property name="show-end-title-buttons">false</property>
<property name="title-widget">
<object class="GtkLabel">
<property name="label" translatable="yes">Work part</property>
<style>
<class name="title" />
</style>
</object>
</property>
<child>
<object class="GtkButton" id="back_button">
<property name="icon-name">go-previous-symbolic</property>
</object>
</child>
<child type="end">
<object class="GtkButton" id="save_button">
<property name="icon-name">object-select-symbolic</property>
<style>
<class name="suggested-action" />
</style>
</object>
</child>
</object>
</child>
<child>
<object class="GtkInfoBar" id="info_bar">
<property name="revealed">False</property>
</object>
</child>
<child>
<object class="GtkScrolledWindow">
<property name="vexpand">true</property>
<child>
<object class="AdwClamp">
<property name="margin-start">12</property>
<property name="margin-end">12</property>
<property name="margin-top">18</property>
<property name="margin-bottom">12</property>
<property name="maximum-size">500</property>
<property name="tightening-threshold">300</property>
<child>
<object class="GtkListBox">
<property name="selection-mode">none</property>
<property name="valign">start</property>
<child>
<object class="AdwEntryRow" id="title_row">
<property name="title" translatable="yes">Title</property>
</object>
</child>
<style>
<class name="boxed-list" />
</style>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</interface>

View file

@ -0,0 +1,2 @@
pub static VERSION: &str = @VERSION@;
pub static LOCALEDIR: &str = @LOCALEDIR@;

View file

@ -0,0 +1,102 @@
use crate::navigator::{NavigationHandle, Screen};
use crate::widgets::{Editor, Section, Widget};
use anyhow::Result;
use gettextrs::gettext;
use gtk::{builders::ListBoxBuilder, glib::clone, prelude::*};
use musicus_backend::db::{generate_id, Ensemble};
use std::rc::Rc;
/// A dialog for creating or editing a ensemble.
pub struct EnsembleEditor {
handle: NavigationHandle<Ensemble>,
/// The ID of the ensemble that is edited or a newly generated one.
id: String,
editor: Editor,
name: adw::EntryRow,
}
impl Screen<Option<Ensemble>, Ensemble> for EnsembleEditor {
/// Create a new ensemble editor and optionally initialize it.
fn new(ensemble: Option<Ensemble>, handle: NavigationHandle<Ensemble>) -> Rc<Self> {
let editor = Editor::new();
editor.set_title("Ensemble");
let list = ListBoxBuilder::new()
.selection_mode(gtk::SelectionMode::None)
.css_classes(vec![String::from("boxed-list")])
.build();
let name = adw::EntryRow::builder().title(&gettext("Name")).build();
list.append(&name);
let section = Section::new(&gettext("General"), &list);
editor.add_content(&section.widget);
let id = match ensemble {
Some(ensemble) => {
name.set_text(&ensemble.name);
ensemble.id
}
None => generate_id(),
};
let this = Rc::new(Self {
handle,
id,
editor,
name,
});
// Connect signals and callbacks
this.editor.set_back_cb(clone!(@weak this => move || {
this.handle.pop(None);
}));
this.editor.set_save_cb(clone!(@weak this => move || {
match this.save() {
Ok(ensemble) => {
this.handle.pop(Some(ensemble));
}
Err(err) => {
let description = gettext!("Cause: {}", err);
this.editor.error(&gettext("Failed to save ensemble!"), &description);
}
}
}));
this.name
.connect_changed(clone!(@weak this => move |_| this.validate()));
this.validate();
this
}
}
impl EnsembleEditor {
/// Validate inputs and enable/disable saving.
fn validate(&self) {
self.editor.set_may_save(!self.name.text().is_empty());
}
/// Save the ensemble.
fn save(&self) -> Result<Ensemble> {
let name = self.name.text();
let ensemble = Ensemble::new(self.id.clone(), name.to_string());
self.handle.backend.db().update_ensemble(ensemble.clone())?;
self.handle.backend.library_changed();
Ok(ensemble)
}
}
impl Widget for EnsembleEditor {
fn get_widget(&self) -> gtk::Widget {
self.editor.widget.clone().upcast()
}
}

View file

@ -0,0 +1,105 @@
use crate::navigator::{NavigationHandle, Screen};
use crate::widgets::{Editor, Section, Widget};
use anyhow::Result;
use gettextrs::gettext;
use gtk::{builders::ListBoxBuilder, glib::clone, prelude::*};
use musicus_backend::db::{generate_id, Instrument};
use std::rc::Rc;
/// A dialog for creating or editing a instrument.
pub struct InstrumentEditor {
handle: NavigationHandle<Instrument>,
/// The ID of the instrument that is edited or a newly generated one.
id: String,
editor: Editor,
name: adw::EntryRow,
}
impl Screen<Option<Instrument>, Instrument> for InstrumentEditor {
/// Create a new instrument editor and optionally initialize it.
fn new(instrument: Option<Instrument>, handle: NavigationHandle<Instrument>) -> Rc<Self> {
let editor = Editor::new();
editor.set_title("Instrument/Role");
let list = ListBoxBuilder::new()
.selection_mode(gtk::SelectionMode::None)
.css_classes(vec![String::from("boxed-list")])
.build();
let name = adw::EntryRow::builder().title(&gettext("Name")).build();
list.append(&name);
let section = Section::new(&gettext("General"), &list);
editor.add_content(&section.widget);
let id = match instrument {
Some(instrument) => {
name.set_text(&instrument.name);
instrument.id
}
None => generate_id(),
};
let this = Rc::new(Self {
handle,
id,
editor,
name,
});
// Connect signals and callbacks
this.editor.set_back_cb(clone!(@weak this => move || {
this.handle.pop(None);
}));
this.editor.set_save_cb(clone!(@weak this => move || {
match this.save() {
Ok(instrument) => {
this.handle.pop(Some(instrument));
}
Err(err) => {
let description = gettext!("Cause: {}", err);
this.editor.error(&gettext("Failed to save instrument!"), &description);
}
}
}));
this.name
.connect_changed(clone!(@weak this => move |_| this.validate()));
this.validate();
this
}
}
impl InstrumentEditor {
/// Validate inputs and enable/disable saving.
fn validate(&self) {
self.editor.set_may_save(!self.name.text().is_empty());
}
/// Save the instrument.
fn save(&self) -> Result<Instrument> {
let name = self.name.text();
let instrument = Instrument::new(self.id.clone(), name.to_string());
self.handle
.backend
.db()
.update_instrument(instrument.clone())?;
self.handle.backend.library_changed();
Ok(instrument)
}
}
impl Widget for InstrumentEditor {
fn get_widget(&self) -> gtk::Widget {
self.editor.widget.clone().upcast()
}
}

View file

@ -0,0 +1,17 @@
pub mod ensemble;
pub use ensemble::*;
pub mod instrument;
pub use instrument::*;
pub mod person;
pub use person::*;
pub mod recording;
pub use recording::*;
pub mod work;
pub use work::*;
mod performance;
mod work_part;

View file

@ -0,0 +1,203 @@
use crate::navigator::{NavigationHandle, Screen};
use crate::selectors::{EnsembleSelector, InstrumentSelector, PersonSelector};
use crate::widgets::{ButtonRow, Editor, Section, Widget};
use adw::prelude::*;
use gettextrs::gettext;
use gtk::builders::ButtonBuilder;
use gtk::{builders::ListBoxBuilder, glib::clone};
use log::error;
use musicus_backend::db::{Ensemble, Instrument, Performance, Person, PersonOrEnsemble};
use std::cell::RefCell;
use std::rc::Rc;
/// A dialog for editing a performance within a recording.
pub struct PerformanceEditor {
handle: NavigationHandle<Performance>,
editor: Editor,
person_row: ButtonRow,
ensemble_row: ButtonRow,
role_row: ButtonRow,
reset_role_button: gtk::Button,
person: RefCell<Option<Person>>,
ensemble: RefCell<Option<Ensemble>>,
role: RefCell<Option<Instrument>>,
}
impl Screen<Option<Performance>, Performance> for PerformanceEditor {
/// Create a new performance editor.
fn new(performance: Option<Performance>, handle: NavigationHandle<Performance>) -> Rc<Self> {
let editor = Editor::new();
editor.set_title("Performance");
editor.set_may_save(false);
let performer_list = ListBoxBuilder::new()
.selection_mode(gtk::SelectionMode::None)
.css_classes(vec![String::from("boxed-list")])
.build();
let person_row = ButtonRow::new("Person", "Select");
let ensemble_row = ButtonRow::new("Ensemble", "Select");
performer_list.append(&person_row.get_widget());
performer_list.append(&ensemble_row.get_widget());
let performer_section = Section::new(&gettext("Performer"), &performer_list);
performer_section.set_subtitle(&gettext(
"Select either a person or an ensemble as a performer.",
));
let role_list = ListBoxBuilder::new()
.selection_mode(gtk::SelectionMode::None)
.css_classes(vec![String::from("boxed-list")])
.build();
let reset_role_button = ButtonBuilder::new()
.icon_name("user-trash-symbolic")
.valign(gtk::Align::Center)
.visible(false)
.build();
let role_row = ButtonRow::new("Role", "Select");
role_row.widget.add_suffix(&reset_role_button);
role_list.append(&role_row.get_widget());
let role_section = Section::new(&gettext("Role"), &role_list);
role_section.set_subtitle(&gettext(
"Optionally, choose a role to specify what the performer does.",
));
editor.add_content(&performer_section);
editor.add_content(&role_section);
let this = Rc::new(PerformanceEditor {
handle,
editor,
person_row,
ensemble_row,
role_row,
reset_role_button,
person: RefCell::new(None),
ensemble: RefCell::new(None),
role: RefCell::new(None),
});
this.editor.set_back_cb(clone!(@weak this => move || {
this.handle.pop(None);
}));
this.editor.set_save_cb(clone!(@weak this => move || {
let performance = Performance {
performer: if let Some(person) = this.person.borrow().clone() {
PersonOrEnsemble::Person(person)
} else if let Some(ensemble) = this.ensemble.borrow().clone() {
PersonOrEnsemble::Ensemble(ensemble)
} else {
error!("Tried to save performance without performer");
return;
},
role: this.role.borrow().clone(),
};
this.handle.pop(Some(performance));
}));
this.person_row.set_cb(clone!(@weak this => move || {
spawn!(@clone this, async move {
if let Some(person) = push!(this.handle, PersonSelector).await {
this.show_person(Some(&person));
this.person.replace(Some(person));
this.show_ensemble(None);
this.ensemble.replace(None);
}
});
}));
this.ensemble_row.set_cb(clone!(@weak this => move || {
spawn!(@clone this, async move {
if let Some(ensemble) = push!(this.handle, EnsembleSelector).await {
this.show_person(None);
this.person.replace(None);
this.show_ensemble(Some(&ensemble));
this.ensemble.replace(Some(ensemble));
}
});
}));
this.role_row.set_cb(clone!(@weak this => move || {
spawn!(@clone this, async move {
if let Some(role) = push!(this.handle, InstrumentSelector).await {
this.show_role(Some(&role));
this.role.replace(Some(role));
}
});
}));
this.reset_role_button
.connect_clicked(clone!(@weak this => move |_| {
this.show_role(None);
this.role.replace(None);
}));
// Initialize
if let Some(performance) = performance {
match performance.performer {
PersonOrEnsemble::Person(person) => {
this.show_person(Some(&person));
this.person.replace(Some(person));
}
PersonOrEnsemble::Ensemble(ensemble) => {
this.show_ensemble(Some(&ensemble));
this.ensemble.replace(Some(ensemble));
}
};
if let Some(role) = performance.role {
this.show_role(Some(&role));
this.role.replace(Some(role));
}
}
this
}
}
impl PerformanceEditor {
/// Update the UI according to person.
fn show_person(&self, person: Option<&Person>) {
if let Some(person) = person {
self.person_row.set_subtitle(&person.name_fl());
self.editor.set_may_save(true);
} else {
self.person_row.set_subtitle("");
}
}
/// Update the UI according to ensemble.
fn show_ensemble(&self, ensemble: Option<&Ensemble>) {
if let Some(ensemble) = ensemble {
self.ensemble_row.set_subtitle(&ensemble.name);
self.editor.set_may_save(true);
} else {
self.ensemble_row.set_subtitle("");
}
}
/// Update the UI according to role.
fn show_role(&self, role: Option<&Instrument>) {
if let Some(role) = role {
self.role_row.set_subtitle(&role.name);
self.reset_role_button.show();
} else {
self.role_row.set_subtitle("");
self.reset_role_button.hide();
}
}
}
impl Widget for PerformanceEditor {
fn get_widget(&self) -> gtk::Widget {
self.editor.widget.clone().upcast()
}
}

View file

@ -0,0 +1,124 @@
use crate::navigator::{NavigationHandle, Screen};
use crate::widgets::{Editor, Section, Widget};
use anyhow::Result;
use gettextrs::gettext;
use glib::clone;
use gtk::{builders::ListBoxBuilder, prelude::*};
use musicus_backend::db::{generate_id, Person};
use std::rc::Rc;
/// A dialog for creating or editing a person.
pub struct PersonEditor {
handle: NavigationHandle<Person>,
/// The ID of the person that is edited or a newly generated one.
id: String,
editor: Editor,
first_name: adw::EntryRow,
last_name: adw::EntryRow,
}
impl Screen<Option<Person>, Person> for PersonEditor {
/// Create a new person editor and optionally initialize it.
fn new(person: Option<Person>, handle: NavigationHandle<Person>) -> Rc<Self> {
let editor = Editor::new();
editor.set_title("Person");
let list = ListBoxBuilder::new()
.selection_mode(gtk::SelectionMode::None)
.css_classes(vec![String::from("boxed-list")])
.build();
let first_name = adw::EntryRow::builder()
.title(&gettext("First name"))
.build();
let last_name = adw::EntryRow::builder()
.title(&gettext("Last name"))
.build();
list.append(&first_name);
list.append(&last_name);
let section = Section::new(&gettext("General"), &list);
editor.add_content(&section.widget);
let id = match person {
Some(person) => {
first_name.set_text(&person.first_name);
last_name.set_text(&person.last_name);
person.id
}
None => generate_id(),
};
let this = Rc::new(Self {
handle,
id,
editor,
first_name,
last_name,
});
// Connect signals and callbacks
this.editor.set_back_cb(clone!(@weak this => move || {
this.handle.pop(None);
}));
this.editor.set_save_cb(clone!(@strong this => move || {
match this.save() {
Ok(person) => {
this.handle.pop(Some(person));
}
Err(err) => {
let description = gettext!("Cause: {}", err);
this.editor.error(&gettext("Failed to save person!"), &description);
}
}
}));
this.first_name
.connect_changed(clone!(@weak this => move |_| this.validate()));
this.last_name
.connect_changed(clone!(@weak this => move |_| this.validate()));
this.validate();
this
}
}
impl PersonEditor {
/// Validate inputs and enable/disable saving.
fn validate(&self) {
self.editor
.set_may_save(!self.first_name.text().is_empty() && !self.last_name.text().is_empty());
}
/// Save the person.
fn save(self: &Rc<Self>) -> Result<Person> {
let first_name = self.first_name.text();
let last_name = self.last_name.text();
let person = Person::new(
self.id.clone(),
first_name.to_string(),
last_name.to_string(),
);
self.handle.backend.db().update_person(person.clone())?;
self.handle.backend.library_changed();
Ok(person)
}
}
impl Widget for PersonEditor {
fn get_widget(&self) -> gtk::Widget {
self.editor.widget.clone().upcast()
}
}

View file

@ -0,0 +1,205 @@
use super::performance::PerformanceEditor;
use crate::navigator::{NavigationHandle, Screen};
use crate::selectors::WorkSelector;
use crate::widgets::{List, Widget};
use adw::builders::ActionRowBuilder;
use adw::prelude::*;
use anyhow::Result;
use gettextrs::gettext;
use glib::clone;
use gtk_macros::get_widget;
use musicus_backend::db::{generate_id, Performance, Recording, Work};
use std::cell::RefCell;
use std::rc::Rc;
/// A widget for creating or editing a recording.
pub struct RecordingEditor {
handle: NavigationHandle<Recording>,
widget: gtk::Stack,
save_button: gtk::Button,
info_bar: gtk::InfoBar,
work_row: adw::ActionRow,
comment_row: adw::EntryRow,
performance_list: Rc<List>,
id: String,
work: RefCell<Option<Work>>,
performances: RefCell<Vec<Performance>>,
}
impl Screen<Option<Recording>, Recording> for RecordingEditor {
/// Create a new recording editor widget and optionally initialize it.
fn new(recording: Option<Recording>, handle: NavigationHandle<Recording>) -> Rc<Self> {
// Create UI
let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/recording_editor.ui");
get_widget!(builder, gtk::Stack, widget);
get_widget!(builder, gtk::Button, back_button);
get_widget!(builder, gtk::Button, save_button);
get_widget!(builder, gtk::InfoBar, info_bar);
get_widget!(builder, adw::ActionRow, work_row);
get_widget!(builder, gtk::Button, work_button);
get_widget!(builder, adw::EntryRow, comment_row);
get_widget!(builder, gtk::Frame, performance_frame);
get_widget!(builder, gtk::Button, add_performer_button);
let performance_list = List::new();
performance_frame.set_child(Some(&performance_list.widget));
let (id, work, performances) = match recording {
Some(recording) => {
comment_row.set_text(&recording.comment);
(recording.id, Some(recording.work), recording.performances)
}
None => (generate_id(), None, Vec::new()),
};
let this = Rc::new(RecordingEditor {
handle,
widget,
save_button,
info_bar,
work_row,
comment_row,
performance_list,
id,
work: RefCell::new(work),
performances: RefCell::new(performances),
});
// Connect signals and callbacks
back_button.connect_clicked(clone!(@weak this => move |_| {
this.handle.pop(None);
}));
this.save_button
.connect_clicked(clone!(@weak this => move |_| {
match this.save() {
Ok(recording) => {
this.handle.pop(Some(recording));
}
Err(_) => {
this.info_bar.set_revealed(true);
this.widget.set_visible_child_name("content");
}
}
}));
work_button.connect_clicked(clone!(@weak this => move |_| {
spawn!(@clone this, async move {
if let Some(work) = push!(this.handle, WorkSelector).await {
this.work_selected(&work);
this.work.replace(Some(work));
}
});
}));
this.performance_list.set_make_widget_cb(clone!(@weak this => @default-panic, move |index| {
let performance = &this.performances.borrow()[index];
let delete_button = gtk::Button::from_icon_name("user-trash-symbolic");
delete_button.set_valign(gtk::Align::Center);
delete_button.connect_clicked(clone!(@weak this => move |_| {
let length = {
let mut performances = this.performances.borrow_mut();
performances.remove(index);
performances.len()
};
this.performance_list.update(length);
}));
let edit_button = gtk::Button::from_icon_name("document-edit-symbolic");
edit_button.set_valign(gtk::Align::Center);
edit_button.connect_clicked(clone!(@weak this => move |_| {
spawn!(@clone this, async move {
let performance = this.performances.borrow()[index].clone();
if let Some(performance) = push!(this.handle, PerformanceEditor, Some(performance)).await {
let length = {
let mut performances = this.performances.borrow_mut();
performances[index] = performance;
performances.len()
};
this.performance_list.update(length);
}
});
}));
let row = ActionRowBuilder::new()
.focusable(false)
.activatable_widget(&edit_button)
.title(&performance.get_title())
.build();
row.add_suffix(&delete_button);
row.add_suffix(&edit_button);
row.upcast()
}));
add_performer_button.connect_clicked(clone!(@strong this => move |_| {
spawn!(@clone this, async move {
if let Some(performance) = push!(this.handle, PerformanceEditor, None).await {
let length = {
let mut performances = this.performances.borrow_mut();
performances.push(performance);
performances.len()
};
this.performance_list.update(length);
}
});
}));
// Initialize
if let Some(work) = &*this.work.borrow() {
this.work_selected(work);
}
let length = this.performances.borrow().len();
this.performance_list.update(length);
this
}
}
impl RecordingEditor {
/// Update the UI according to work.
fn work_selected(&self, work: &Work) {
self.work_row.set_title(&gettext("Work"));
self.work_row.set_subtitle(&work.get_title());
self.save_button.set_sensitive(true);
}
/// Save the recording.
fn save(self: &Rc<Self>) -> Result<Recording> {
let recording = Recording::new(
self.id.clone(),
self.work
.borrow()
.clone()
.expect("Tried to create recording without work!"),
self.comment_row.text().to_string(),
self.performances.borrow().clone(),
);
self.handle
.backend
.db()
.update_recording(recording.clone())?;
self.handle.backend.library_changed();
Ok(recording)
}
}
impl Widget for RecordingEditor {
fn get_widget(&self) -> gtk::Widget {
self.widget.clone().upcast()
}
}

View file

@ -0,0 +1,275 @@
use super::work_part::WorkPartEditor;
use crate::navigator::{NavigationHandle, Screen};
use crate::selectors::{InstrumentSelector, PersonSelector};
use crate::widgets::{List, Widget};
use adw::builders::ActionRowBuilder;
use adw::prelude::*;
use anyhow::Result;
use gettextrs::gettext;
use glib::clone;
use gtk_macros::get_widget;
use musicus_backend::db::{generate_id, Instrument, Person, Work, WorkPart};
use std::cell::RefCell;
use std::rc::Rc;
/// A widget for editing and creating works.
pub struct WorkEditor {
handle: NavigationHandle<Work>,
widget: gtk::Stack,
save_button: gtk::Button,
title_row: adw::EntryRow,
info_bar: gtk::InfoBar,
composer_row: adw::ActionRow,
instrument_list: Rc<List>,
part_list: Rc<List>,
id: String,
composer: RefCell<Option<Person>>,
instruments: RefCell<Vec<Instrument>>,
parts: RefCell<Vec<WorkPart>>,
}
impl Screen<Option<Work>, Work> for WorkEditor {
/// Create a new work editor widget and optionally initialize it.
fn new(work: Option<Work>, handle: NavigationHandle<Work>) -> Rc<Self> {
// Create UI
let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/work_editor.ui");
get_widget!(builder, gtk::Stack, widget);
get_widget!(builder, gtk::Button, back_button);
get_widget!(builder, gtk::Button, save_button);
get_widget!(builder, gtk::InfoBar, info_bar);
get_widget!(builder, adw::EntryRow, title_row);
get_widget!(builder, gtk::Button, composer_button);
get_widget!(builder, adw::ActionRow, composer_row);
get_widget!(builder, gtk::Frame, instrument_frame);
get_widget!(builder, gtk::Button, add_instrument_button);
get_widget!(builder, gtk::Frame, structure_frame);
get_widget!(builder, gtk::Button, add_part_button);
let instrument_list = List::new();
instrument_frame.set_child(Some(&instrument_list.widget));
let part_list = List::new();
part_list.set_enable_dnd(true);
structure_frame.set_child(Some(&part_list.widget));
let (id, composer, instruments, structure) = match work {
Some(work) => {
title_row.set_text(&work.title);
(work.id, Some(work.composer), work.instruments, work.parts)
}
None => (generate_id(), None, Vec::new(), Vec::new()),
};
let this = Rc::new(Self {
handle,
widget,
save_button,
id,
info_bar,
title_row,
composer_row,
instrument_list,
part_list,
composer: RefCell::new(composer),
instruments: RefCell::new(instruments),
parts: RefCell::new(structure),
});
// Connect signals and callbacks
back_button.connect_clicked(clone!(@weak this => move |_| {
this.handle.pop(None);
}));
this.save_button
.connect_clicked(clone!(@weak this => move |_| {
match this.save() {
Ok(work) => {
this.handle.pop(Some(work));
}
Err(_) => {
this.info_bar.set_revealed(true);
this.widget.set_visible_child_name("content");
}
}
}));
composer_button.connect_clicked(clone!(@weak this => move |_| {
spawn!(@clone this, async move {
if let Some(person) = push!(this.handle, PersonSelector).await {
this.show_composer(&person);
this.composer.replace(Some(person));
}
});
}));
this.title_row
.connect_changed(clone!(@weak this => move |_| this.validate()));
this.instrument_list.set_make_widget_cb(
clone!(@weak this => @default-panic, move |index| {
let instrument = &this.instruments.borrow()[index];
let delete_button = gtk::Button::from_icon_name("user-trash-symbolic");
delete_button.set_valign(gtk::Align::Center);
delete_button.connect_clicked(clone!(@strong this => move |_| {
let length = {
let mut instruments = this.instruments.borrow_mut();
instruments.remove(index);
instruments.len()
};
this.instrument_list.update(length);
}));
let row = ActionRowBuilder::new()
.title(&instrument.name)
.build();
row.add_suffix(&delete_button);
row.upcast()
}),
);
add_instrument_button.connect_clicked(clone!(@weak this => move |_| {
spawn!(@clone this, async move {
if let Some(instrument) = push!(this.handle, InstrumentSelector).await {
let length = {
let mut instruments = this.instruments.borrow_mut();
instruments.push(instrument);
instruments.len()
};
this.instrument_list.update(length);
}
});
}));
this.part_list
.set_make_widget_cb(clone!(@weak this => @default-panic, move |index| {
let part = &this.parts.borrow()[index];
let delete_button = gtk::Button::from_icon_name("user-trash-symbolic");
delete_button.set_valign(gtk::Align::Center);
delete_button.connect_clicked(clone!(@weak this => move |_| {
let length = {
let mut structure = this.parts.borrow_mut();
structure.remove(index);
structure.len()
};
this.part_list.update(length);
}));
let edit_button = gtk::Button::from_icon_name("document-edit-symbolic");
edit_button.set_valign(gtk::Align::Center);
edit_button.connect_clicked(clone!(@weak this => move |_| {
spawn!(@clone this, async move {
let part = this.parts.borrow()[index].clone();
if let Some(part) = push!(this.handle, WorkPartEditor, Some(part)).await {
let length = {
let mut structure = this.parts.borrow_mut();
structure[index] = part;
structure.len()
};
this.part_list.update(length);
}
});
}));
let row = ActionRowBuilder::new()
.focusable(false)
.title(&part.title)
.activatable_widget(&edit_button)
.build();
row.add_suffix(&delete_button);
row.add_suffix(&edit_button);
row.upcast()
}));
this.part_list
.set_move_cb(clone!(@weak this => move |old_index, new_index| {
let length = {
let mut parts = this.parts.borrow_mut();
parts.swap(old_index, new_index);
parts.len()
};
this.part_list.update(length);
}));
add_part_button.connect_clicked(clone!(@weak this => move |_| {
spawn!(@clone this, async move {
if let Some(part) = push!(this.handle, WorkPartEditor, None).await {
let length = {
let mut parts = this.parts.borrow_mut();
parts.push(part);
parts.len()
};
this.part_list.update(length);
}
});
}));
// Initialization
if let Some(composer) = &*this.composer.borrow() {
this.show_composer(composer);
}
this.instrument_list.update(this.instruments.borrow().len());
this.part_list.update(this.parts.borrow().len());
this
}
}
impl WorkEditor {
/// Update the UI according to person.
fn show_composer(&self, person: &Person) {
self.composer_row.set_title(&gettext("Composer"));
self.composer_row.set_subtitle(&person.name_fl());
self.validate();
}
/// Validate inputs and enable/disable saving.
fn validate(&self) {
self.save_button
.set_sensitive(!self.title_row.text().is_empty() && self.composer.borrow().is_some());
}
/// Save the work.
fn save(self: &Rc<Self>) -> Result<Work> {
let work = Work::new(
self.id.clone(),
self.title_row.text().to_string(),
self.composer
.borrow()
.clone()
.expect("Tried to create work without composer!"),
self.instruments.borrow().clone(),
self.parts.borrow().clone(),
);
self.handle.backend.db().update_work(work.clone())?;
self.handle.backend.library_changed();
Ok(work)
}
}
impl Widget for WorkEditor {
fn get_widget(&self) -> gtk::Widget {
self.widget.clone().upcast()
}
}

View file

@ -0,0 +1,76 @@
use crate::navigator::{NavigationHandle, Screen};
use crate::widgets::Widget;
use glib::clone;
use gtk::prelude::*;
use gtk_macros::get_widget;
use musicus_backend::db::WorkPart;
use std::rc::Rc;
/// A dialog for creating or editing a work section.
pub struct WorkPartEditor {
handle: NavigationHandle<WorkPart>,
widget: gtk::Box,
save_button: gtk::Button,
title_row: adw::EntryRow,
}
impl Screen<Option<WorkPart>, WorkPart> for WorkPartEditor {
/// Create a new part editor and optionally initialize it.
fn new(section: Option<WorkPart>, handle: NavigationHandle<WorkPart>) -> Rc<Self> {
// Create UI
let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/work_part_editor.ui");
get_widget!(builder, gtk::Box, widget);
get_widget!(builder, gtk::Button, back_button);
get_widget!(builder, gtk::Button, save_button);
get_widget!(builder, adw::EntryRow, title_row);
if let Some(section) = section {
title_row.set_text(&section.title);
}
let this = Rc::new(Self {
handle,
widget,
save_button,
title_row,
});
// Connect signals and callbacks
back_button.connect_clicked(clone!(@weak this => move |_| {
this.handle.pop(None);
}));
this.save_button
.connect_clicked(clone!(@weak this => move |_| {
let section = WorkPart {
title: this.title_row.text().to_string(),
};
this.handle.pop(Some(section));
}));
this.title_row
.connect_changed(clone!(@weak this => move |_| this.validate()));
this.validate();
this
}
}
impl WorkPartEditor {
/// Validate inputs and enable/disable saving.
fn validate(&self) {
self.save_button
.set_sensitive(!self.title_row.text().is_empty());
}
}
impl Widget for WorkPartEditor {
fn get_widget(&self) -> gtk::Widget {
self.widget.clone().upcast()
}
}

View file

@ -0,0 +1,165 @@
use super::medium_editor::MediumEditor;
use super::medium_preview::MediumPreview;
use crate::navigator::{NavigationHandle, Screen};
use crate::selectors::MediumSelector;
use crate::widgets::Widget;
use adw::builders::ActionRowBuilder;
use adw::prelude::*;
use glib::clone;
use gtk_macros::get_widget;
use musicus_backend::db::Medium;
use musicus_backend::import::ImportSession;
use std::rc::Rc;
use std::sync::Arc;
/// A dialog for selecting metadata when importing music.
pub struct ImportScreen {
handle: NavigationHandle<()>,
session: Arc<ImportSession>,
widget: gtk::Box,
matching_stack: gtk::Stack,
error_row: adw::ActionRow,
matching_list: gtk::ListBox,
}
impl ImportScreen {
/// Find matching mediums in the library.
fn load_matches(self: &Rc<Self>) {
self.matching_stack.set_visible_child_name("loading");
let this = self;
spawn!(@clone this, async move {
let mediums = this.handle.backend.db().get_mediums_by_source_id(this.session.source_id());
match mediums {
Ok(mediums) => {
if !mediums.is_empty() {
this.show_matches(mediums);
this.matching_stack.set_visible_child_name("content");
} else {
this.matching_stack.set_visible_child_name("empty");
}
}
Err(err) => {
this.error_row.set_subtitle(&err.to_string());
this.matching_stack.set_visible_child_name("error");
}
}
});
}
/// Populate the list of matches
fn show_matches(self: &Rc<Self>, mediums: Vec<Medium>) {
if let Some(mut child) = self.matching_list.first_child() {
loop {
let next_child = child.next_sibling();
self.matching_list.remove(&child);
match next_child {
Some(next_child) => child = next_child,
None => break,
}
}
}
let this = self;
for medium in mediums {
let row = ActionRowBuilder::new()
.activatable(true)
.title(&medium.name)
.subtitle(&format!("{} Tracks", medium.tracks.len()))
.build();
row.connect_activated(clone!(@weak this => move |_| {
let medium = medium.clone();
spawn!(@clone this, async move {
if let Some(()) = push!(this.handle, MediumPreview, (this.session.clone(), medium.clone())).await {
this.handle.pop(Some(()));
}
});
}));
this.matching_list.append(&row);
}
}
/// Select a medium from somewhere and present a preview.
fn select_medium(self: &Rc<Self>, medium: Medium) {
let this = self;
spawn!(@clone this, async move {
if let Some(()) = push!(this.handle, MediumPreview, (this.session.clone(), medium)).await {
this.handle.pop(Some(()));
}
});
}
}
impl Screen<Arc<ImportSession>, ()> for ImportScreen {
/// Create a new import screen.
fn new(session: Arc<ImportSession>, handle: NavigationHandle<()>) -> Rc<Self> {
// Create UI
let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/import_screen.ui");
get_widget!(builder, gtk::Box, widget);
get_widget!(builder, gtk::Button, back_button);
get_widget!(builder, gtk::Stack, matching_stack);
get_widget!(builder, gtk::Button, try_again_button);
get_widget!(builder, adw::ActionRow, error_row);
get_widget!(builder, gtk::ListBox, matching_list);
get_widget!(builder, gtk::Button, select_button);
get_widget!(builder, gtk::Button, add_button);
let this = Rc::new(Self {
handle,
session,
widget,
matching_stack,
error_row,
matching_list,
});
// Connect signals and callbacks
back_button.connect_clicked(clone!(@weak this => move |_| {
this.handle.pop(None);
}));
try_again_button.connect_clicked(clone!(@weak this => move |_| {
this.load_matches();
}));
select_button.connect_clicked(clone!(@weak this => move |_| {
spawn!(@clone this, async move {
if let Some(medium) = push!(this.handle, MediumSelector).await {
this.select_medium(medium);
}
});
}));
add_button.connect_clicked(clone!(@weak this => move |_| {
spawn!(@clone this, async move {
if let Some(medium) = push!(this.handle, MediumEditor, (Arc::clone(&this.session), None)).await {
this.select_medium(medium);
}
});
}));
// Initialize the view
this.load_matches();
// Copy the tracks in the background, if neccessary.
this.session.copy();
this
}
}
impl Widget for ImportScreen {
fn get_widget(&self) -> gtk::Widget {
self.widget.clone().upcast()
}
}

View file

@ -0,0 +1,220 @@
use super::track_set_editor::{TrackData, TrackSetData, TrackSetEditor};
use crate::navigator::{NavigationHandle, Screen};
use crate::widgets::{List, Widget};
use adw::builders::ActionRowBuilder;
use adw::prelude::*;
use anyhow::Result;
use glib::clone;
use gtk_macros::get_widget;
use musicus_backend::db::{generate_id, Medium, Track};
use musicus_backend::import::ImportSession;
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
/// A dialog for editing metadata while importing music into the music library.
pub struct MediumEditor {
handle: NavigationHandle<Medium>,
session: Arc<ImportSession>,
widget: gtk::Stack,
done_button: gtk::Button,
name_row: adw::EntryRow,
status_page: adw::StatusPage,
track_set_list: Rc<List>,
track_sets: RefCell<Vec<TrackSetData>>,
}
impl Screen<(Arc<ImportSession>, Option<Medium>), Medium> for MediumEditor {
/// Create a new medium editor.
fn new(
(session, medium): (Arc<ImportSession>, Option<Medium>),
handle: NavigationHandle<Medium>,
) -> Rc<Self> {
// Create UI
let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/medium_editor.ui");
get_widget!(builder, gtk::Stack, widget);
get_widget!(builder, gtk::Button, back_button);
get_widget!(builder, gtk::Button, done_button);
get_widget!(builder, adw::EntryRow, name_row);
get_widget!(builder, gtk::Button, add_button);
get_widget!(builder, gtk::Frame, frame);
get_widget!(builder, adw::StatusPage, status_page);
get_widget!(builder, gtk::Button, try_again_button);
get_widget!(builder, gtk::Button, cancel_button);
let list = List::new();
frame.set_child(Some(&list.widget));
let this = Rc::new(Self {
handle,
session,
widget,
done_button,
name_row,
status_page,
track_set_list: list,
track_sets: RefCell::new(Vec::new()),
});
// Connect signals and callbacks
back_button.connect_clicked(clone!(@weak this => move |_| {
this.handle.pop(None);
}));
this.done_button
.connect_clicked(clone!(@weak this => move |_| {
this.widget.set_visible_child_name("loading");
spawn!(@clone this, async move {
match this.save().await {
Ok(medium) => this.handle.pop(Some(medium)),
Err(err) => {
this.status_page.set_description(Some(&err.to_string()));
this.widget.set_visible_child_name("error");
}
}
});
}));
this.name_row
.connect_changed(clone!(@weak this => move |_| this.validate()));
add_button.connect_clicked(clone!(@weak this => move |_| {
spawn!(@clone this, async move {
if let Some(track_set) = push!(this.handle, TrackSetEditor, Arc::clone(&this.session)).await {
let length = {
let mut track_sets = this.track_sets.borrow_mut();
track_sets.push(track_set);
track_sets.len()
};
this.track_set_list.update(length);
this.validate();
}
});
}));
this.track_set_list.set_make_widget_cb(
clone!(@weak this => @default-panic, move |index| {
let track_set = &this.track_sets.borrow()[index];
let title = track_set.recording.work.get_title();
let subtitle = track_set.recording.get_performers();
let edit_image = gtk::Image::from_icon_name("document-edit-symbolic");
let edit_button = gtk::Button::new();
edit_button.set_has_frame(false);
edit_button.set_valign(gtk::Align::Center);
edit_button.set_child(Some(&edit_image));
let row = ActionRowBuilder::new()
.focusable(false)
.title(&title)
.subtitle(&subtitle)
.activatable_widget(&edit_button)
.build();
row.add_suffix(&edit_button);
edit_button.connect_clicked(clone!(@weak this => move |_| {
// TODO: Implement editing.
}));
row.upcast()
}),
);
try_again_button.connect_clicked(clone!(@weak this => move |_| {
this.widget.set_visible_child_name("content");
}));
cancel_button.connect_clicked(clone!(@weak this => move |_| {
this.handle.pop(None);
}));
// Initialize, if necessary.
if let Some(medium) = medium {
this.name_row.set_text(&medium.name);
let mut track_sets: Vec<TrackSetData> = Vec::new();
for track in medium.tracks {
let track_data = TrackData {
track_source: track.source_index,
work_parts: track.work_parts,
};
if let Some(track_set) = track_sets.last_mut() {
if track.recording.id == track_set.recording.id {
track_set.tracks.push(track_data);
continue;
}
}
track_sets.push(TrackSetData {
recording: track.recording,
tracks: vec![track_data],
});
}
let length = track_sets.len();
this.track_sets.replace(track_sets);
this.track_set_list.update(length);
}
this.validate();
this
}
}
impl MediumEditor {
/// Validate inputs and enable/disable saving.
fn validate(&self) {
self.done_button.set_sensitive(
!self.name_row.text().is_empty() && !self.track_sets.borrow().is_empty(),
);
}
/// Create the medium.
async fn save(&self) -> Result<Medium> {
// Convert the track set data to real track sets.
let mut tracks = Vec::new();
for track_set_data in &*self.track_sets.borrow() {
for track_data in &track_set_data.tracks {
let track = Track::new(
track_set_data.recording.clone(),
track_data.work_parts.clone(),
track_data.track_source,
String::new(),
);
tracks.push(track);
}
}
let medium = Medium::new(
generate_id(),
self.name_row.text().to_string(),
Some(self.session.source_id().to_owned()),
tracks,
);
// The medium is not added to the database, because the track paths are not known until the
// medium is actually imported into the music library. This step will be handled by the
// medium preview dialog.
Ok(medium)
}
}
impl Widget for MediumEditor {
fn get_widget(&self) -> gtk::Widget {
self.widget.clone().upcast()
}
}

View file

@ -0,0 +1,271 @@
use super::medium_editor::MediumEditor;
use crate::navigator::{NavigationHandle, Screen};
use crate::widgets::Widget;
use adw::builders::ActionRowBuilder;
use anyhow::{anyhow, Result};
use gettextrs::gettext;
use glib::clone;
use gtk::builders::{ListBoxBuilder, FrameBuilder};
use gtk::prelude::*;
use gtk_macros::get_widget;
use musicus_backend::db::Medium;
use musicus_backend::import::{ImportSession, State};
use std::cell::RefCell;
use std::path::PathBuf;
use std::rc::Rc;
use std::sync::Arc;
/// A dialog for presenting the selected medium when importing music.
pub struct MediumPreview {
handle: NavigationHandle<()>,
session: Arc<ImportSession>,
medium: RefCell<Option<Medium>>,
widget: gtk::Stack,
import_button: gtk::Button,
done_stack: gtk::Stack,
name_label: gtk::Label,
medium_box: gtk::Box,
status_page: adw::StatusPage,
}
impl Screen<(Arc<ImportSession>, Medium), ()> for MediumPreview {
/// Create a new medium preview screen.
fn new(
(session, medium): (Arc<ImportSession>, Medium),
handle: NavigationHandle<()>,
) -> Rc<Self> {
// Create UI
let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/medium_preview.ui");
get_widget!(builder, gtk::Stack, widget);
get_widget!(builder, gtk::Button, back_button);
get_widget!(builder, gtk::Button, edit_button);
get_widget!(builder, gtk::Button, import_button);
get_widget!(builder, gtk::Stack, done_stack);
get_widget!(builder, gtk::Box, medium_box);
get_widget!(builder, gtk::Label, name_label);
get_widget!(builder, adw::StatusPage, status_page);
get_widget!(builder, gtk::Button, try_again_button);
let this = Rc::new(Self {
handle,
session,
medium: RefCell::new(None),
widget,
import_button,
done_stack,
name_label,
medium_box,
status_page,
});
// Connect signals and callbacks
back_button.connect_clicked(clone!(@weak this => move |_| {
this.handle.pop(None);
}));
edit_button.connect_clicked(clone!(@weak this => move |_| {
spawn!(@clone this, async move {
let old_medium = this.medium.borrow().clone().unwrap();
if let Some(medium) = push!(this.handle, MediumEditor, (this.session.clone(), Some(old_medium))).await {
this.set_medium(medium);
}
});
}));
this.import_button
.connect_clicked(clone!(@weak this => move |_| {
this.widget.set_visible_child_name("loading");
spawn!(@clone this, async move {
match this.import().await {
Ok(()) => this.handle.pop(Some(())),
Err(err) => {
this.widget.set_visible_child_name("error");
this.status_page.set_description(Some(&err.to_string()));
}
}
});
}));
try_again_button.connect_clicked(clone!(@weak this => move |_| {
this.widget.set_visible_child_name("content");
}));
this.set_medium(medium);
this.handle_state(&this.session.state());
spawn!(@clone this, async move {
loop {
let state = this.session.state_change().await;
this.handle_state(&state);
match state {
State::Ready | State::Error => break,
_ => (),
}
}
});
this
}
}
impl MediumPreview {
/// Set a new medium and update the view accordingly.
fn set_medium(&self, medium: Medium) {
self.name_label.set_text(&medium.name);
if let Some(widget) = self.medium_box.first_child() {
let mut child = widget;
loop {
let next_child = child.next_sibling();
self.medium_box.remove(&child);
match next_child {
Some(widget) => child = widget,
None => break,
}
}
}
let mut last_recording_id = "";
let mut last_list = None::<gtk::ListBox>;
let import_tracks = self.session.tracks();
for track in &medium.tracks {
if track.recording.id != last_recording_id {
last_recording_id = &track.recording.id;
let list = ListBoxBuilder::new()
.selection_mode(gtk::SelectionMode::None)
.margin_bottom(12)
.css_classes(vec![String::from("boxed-list")])
.build();
let header = ActionRowBuilder::new()
.activatable(false)
.title(&track.recording.work.get_title())
.subtitle(&track.recording.get_performers())
.build();
list.append(&header);
if let Some(list) = &last_list {
self.medium_box.append(list);
}
last_list = Some(list);
}
if let Some(list) = &last_list {
let mut parts = Vec::<String>::new();
for part in &track.work_parts {
parts.push(track.recording.work.parts[*part].title.clone());
}
let title = if parts.is_empty() {
gettext("Unknown")
} else {
parts.join(", ")
};
let row = ActionRowBuilder::new()
.activatable(false)
.title(&title)
.subtitle(&import_tracks[track.source_index].name)
.margin_start(12)
.build();
list.append(&row);
}
}
if let Some(list) = &last_list {
let frame = FrameBuilder::new().margin_bottom(12).build();
frame.set_child(Some(list));
self.medium_box.append(&frame);
}
self.medium.replace(Some(medium));
}
/// Handle a state change of the import process.
fn handle_state(&self, state: &State) {
match state {
State::Waiting | State::Copying => self.done_stack.set_visible_child_name("loading"),
State::Ready => {
self.done_stack.set_visible_child_name("ready");
self.import_button.set_sensitive(true);
}
State::Error => todo!("Import error!"),
}
}
/// Copy the tracks to the music library and add the medium to the database.
async fn import(&self) -> Result<()> {
let medium = self.medium.borrow();
let medium = medium.as_ref().ok_or_else(|| anyhow!("No medium set!"))?;
// Create a new directory in the music library path for the imported medium.
let music_library_path = self.handle.backend.get_music_library_path().unwrap();
let directory_name = sanitize_filename::sanitize_with_options(
&medium.name,
sanitize_filename::Options {
windows: true,
truncate: true,
replacement: "",
},
);
let directory = PathBuf::from(&directory_name);
std::fs::create_dir(&music_library_path.join(&directory))?;
// Copy the tracks to the music library.
let mut tracks = Vec::new();
let import_tracks = self.session.tracks();
for track in &medium.tracks {
let mut track = track.clone();
// Set the track path to the new audio file location.
let import_track = &import_tracks[track.source_index];
let track_path = directory.join(import_track.path.file_name().unwrap());
track.path = track_path.to_str().unwrap().to_owned();
// Copy the corresponding audio file to the music library.
std::fs::copy(&import_track.path, &music_library_path.join(&track_path))?;
tracks.push(track);
}
// Add the modified medium to the database.
let medium = Medium::new(
medium.id.clone(),
medium.name.clone(),
medium.discid.clone(),
tracks,
);
self.handle.backend.db().update_medium(medium)?;
self.handle.backend.library_changed();
Ok(())
}
}
impl Widget for MediumPreview {
fn get_widget(&self) -> gtk::Widget {
self.widget.clone().upcast()
}
}

View file

@ -0,0 +1,9 @@
mod import_screen;
mod medium_editor;
mod medium_preview;
mod source_selector;
mod track_editor;
mod track_selector;
mod track_set_editor;
pub use source_selector::SourceSelector;

View file

@ -0,0 +1,113 @@
use super::import_screen::ImportScreen;
use crate::navigator::{NavigationHandle, Screen};
use crate::widgets::Widget;
use gettextrs::gettext;
use glib::clone;
use gtk::prelude::*;
use gtk_macros::get_widget;
use musicus_backend::import::ImportSession;
use std::rc::Rc;
/// A dialog for starting to import music.
pub struct SourceSelector {
handle: NavigationHandle<()>,
widget: gtk::Stack,
status_page: adw::StatusPage,
}
impl Screen<(), ()> for SourceSelector {
/// Create a new source selector.
fn new(_: (), handle: NavigationHandle<()>) -> Rc<Self> {
// Create UI
let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/source_selector.ui");
get_widget!(builder, gtk::Stack, widget);
get_widget!(builder, gtk::Button, back_button);
get_widget!(builder, gtk::Button, folder_button);
get_widget!(builder, gtk::Button, disc_button);
get_widget!(builder, adw::StatusPage, status_page);
get_widget!(builder, gtk::Button, try_again_button);
let this = Rc::new(Self {
handle,
widget,
status_page,
});
// Connect signals and callbacks
back_button.connect_clicked(clone!(@weak this => move |_| {
this.handle.pop(None);
}));
folder_button.connect_clicked(clone!(@weak this => move |_| {
let dialog = gtk::FileChooserDialog::new(
Some(&gettext("Select folder")),
Some(&this.handle.window),
gtk::FileChooserAction::SelectFolder,
&[
(&gettext("Cancel"), gtk::ResponseType::Cancel),
(&gettext("Select"), gtk::ResponseType::Accept),
]);
dialog.set_modal(true);
dialog.connect_response(clone!(@weak this => move |dialog, response| {
dialog.hide();
if let gtk::ResponseType::Accept = response {
if let Some(file) = dialog.file() {
if let Some(path) = file.path() {
this.widget.set_visible_child_name("loading");
spawn!(@clone this, async move {
match ImportSession::folder(path).await {
Ok(session) => {
let result = push!(this.handle, ImportScreen, session).await;
this.handle.pop(result);
}
Err(err) => {
this.status_page.set_description(Some(&err.to_string()));
this.widget.set_visible_child_name("error");
}
}
});
}
}
}
}));
dialog.show();
}));
disc_button.connect_clicked(clone!(@weak this => move |_| {
this.widget.set_visible_child_name("loading");
spawn!(@clone this, async move {
match ImportSession::audio_cd().await {
Ok(session) => {
let result = push!(this.handle, ImportScreen, session).await;
this.handle.pop(result);
}
Err(err) => {
this.status_page.set_description(Some(&err.to_string()));
this.widget.set_visible_child_name("error");
}
}
});
}));
try_again_button.connect_clicked(clone!(@weak this => move |_| {
this.widget.set_visible_child_name("content");
}));
this
}
}
impl Widget for SourceSelector {
fn get_widget(&self) -> gtk::Widget {
self.widget.clone().upcast()
}
}

View file

@ -0,0 +1,89 @@
use crate::navigator::{NavigationHandle, Screen};
use crate::widgets::Widget;
use adw::builders::ActionRowBuilder;
use adw::prelude::*;
use glib::clone;
use gtk::builders::ListBoxBuilder;
use gtk_macros::get_widget;
use musicus_backend::db::Recording;
use std::cell::RefCell;
use std::rc::Rc;
/// A screen for editing a single track.
pub struct TrackEditor {
handle: NavigationHandle<Vec<usize>>,
widget: gtk::Box,
selection: RefCell<Vec<usize>>,
}
impl Screen<(Recording, Vec<usize>), Vec<usize>> for TrackEditor {
/// Create a new track editor.
fn new(
(recording, selection): (Recording, Vec<usize>),
handle: NavigationHandle<Vec<usize>>,
) -> Rc<Self> {
// Create UI
let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/track_editor.ui");
get_widget!(builder, gtk::Box, widget);
get_widget!(builder, gtk::Button, back_button);
get_widget!(builder, gtk::Button, select_button);
get_widget!(builder, adw::Clamp, clamp);
let parts_list = ListBoxBuilder::new()
.selection_mode(gtk::SelectionMode::None)
.css_classes(vec![String::from("boxed-list")])
.build();
clamp.set_child(Some(&parts_list));
let this = Rc::new(Self {
handle,
widget,
selection: RefCell::new(selection),
});
// Connect signals and callbacks
back_button.connect_clicked(clone!(@weak this => move |_| {
this.handle.pop(None);
}));
select_button.connect_clicked(clone!(@weak this => move |_| {
let selection = this.selection.borrow().clone();
this.handle.pop(Some(selection));
}));
for (index, part) in recording.work.parts.iter().enumerate() {
let check = gtk::CheckButton::new();
check.set_active(this.selection.borrow().contains(&index));
check.connect_toggled(clone!(@weak this => move |check| {
let mut selection = this.selection.borrow_mut();
if check.is_active() {
selection.push(index);
} else if let Some(pos) = selection.iter().position(|part| *part == index) {
selection.remove(pos);
}
}));
let row = ActionRowBuilder::new()
.focusable(false)
.title(&part.title)
.activatable_widget(&check)
.build();
row.add_prefix(&check);
parts_list.append(&row);
}
this
}
}
impl Widget for TrackEditor {
fn get_widget(&self) -> gtk::Widget {
self.widget.clone().upcast()
}
}

View file

@ -0,0 +1,100 @@
use crate::navigator::{NavigationHandle, Screen};
use crate::widgets::Widget;
use adw::builders::ActionRowBuilder;
use adw::prelude::*;
use glib::clone;
use gtk::builders::ListBoxBuilder;
use gtk_macros::get_widget;
use musicus_backend::import::ImportSession;
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
/// A screen for selecting tracks from a source.
pub struct TrackSelector {
handle: NavigationHandle<Vec<usize>>,
session: Arc<ImportSession>,
widget: gtk::Box,
select_button: gtk::Button,
selection: RefCell<Vec<usize>>,
}
impl Screen<Arc<ImportSession>, Vec<usize>> for TrackSelector {
/// Create a new track selector.
fn new(session: Arc<ImportSession>, handle: NavigationHandle<Vec<usize>>) -> Rc<Self> {
// Create UI
let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/track_selector.ui");
get_widget!(builder, gtk::Box, widget);
get_widget!(builder, gtk::Button, back_button);
get_widget!(builder, gtk::Button, select_button);
get_widget!(builder, adw::Clamp, clamp);
let track_list = ListBoxBuilder::new()
.selection_mode(gtk::SelectionMode::None)
.css_classes(vec![String::from("boxed-list")])
.build();
clamp.set_child(Some(&track_list));
let this = Rc::new(Self {
handle,
session,
widget,
select_button,
selection: RefCell::new(Vec::new()),
});
// Connect signals and callbacks
back_button.connect_clicked(clone!(@weak this => move |_| {
this.handle.pop(None);
}));
this.select_button
.connect_clicked(clone!(@weak this => move |_| {
let selection = this.selection.borrow().clone();
this.handle.pop(Some(selection));
}));
let tracks = this.session.tracks();
for (index, track) in tracks.iter().enumerate() {
let check = gtk::CheckButton::new();
check.connect_toggled(clone!(@weak this => move |check| {
let mut selection = this.selection.borrow_mut();
if check.is_active() {
selection.push(index);
} else if let Some(pos) = selection.iter().position(|part| *part == index) {
selection.remove(pos);
}
if selection.is_empty() {
this.select_button.set_sensitive(false);
} else {
this.select_button.set_sensitive(true);
}
}));
let row = ActionRowBuilder::new()
.focusable(false)
.title(&track.name)
.activatable_widget(&check)
.build();
row.add_prefix(&check);
track_list.append(&row);
}
this
}
}
impl Widget for TrackSelector {
fn get_widget(&self) -> gtk::Widget {
self.widget.clone().upcast()
}
}

View file

@ -0,0 +1,233 @@
use super::track_editor::TrackEditor;
use super::track_selector::TrackSelector;
use crate::navigator::{NavigationHandle, Screen};
use crate::selectors::RecordingSelector;
use crate::widgets::{List, Widget};
use adw::builders::ActionRowBuilder;
use adw::prelude::*;
use gettextrs::gettext;
use glib::clone;
use gtk_macros::get_widget;
use musicus_backend::db::Recording;
use musicus_backend::import::ImportSession;
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
/// A track set before being imported.
#[derive(Clone, Debug)]
pub struct TrackSetData {
pub recording: Recording,
pub tracks: Vec<TrackData>,
}
/// A track before being imported.
#[derive(Clone, Debug)]
pub struct TrackData {
/// Index of the track source within the medium source's tracks.
pub track_source: usize,
/// Actual track data.
pub work_parts: Vec<usize>,
}
/// A screen for editing a set of tracks for one recording.
pub struct TrackSetEditor {
handle: NavigationHandle<TrackSetData>,
session: Arc<ImportSession>,
widget: gtk::Box,
save_button: gtk::Button,
recording_row: adw::ActionRow,
track_list: Rc<List>,
recording: RefCell<Option<Recording>>,
tracks: RefCell<Vec<TrackData>>,
}
impl Screen<Arc<ImportSession>, TrackSetData> for TrackSetEditor {
/// Create a new track set editor.
fn new(session: Arc<ImportSession>, handle: NavigationHandle<TrackSetData>) -> Rc<Self> {
// Create UI
let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/track_set_editor.ui");
get_widget!(builder, gtk::Box, widget);
get_widget!(builder, gtk::Button, back_button);
get_widget!(builder, gtk::Button, save_button);
get_widget!(builder, adw::ActionRow, recording_row);
get_widget!(builder, gtk::Button, select_recording_button);
get_widget!(builder, gtk::Button, edit_tracks_button);
get_widget!(builder, gtk::Frame, tracks_frame);
let track_list = List::new();
tracks_frame.set_child(Some(&track_list.widget));
let this = Rc::new(Self {
handle,
session,
widget,
save_button,
recording_row,
track_list,
recording: RefCell::new(None),
tracks: RefCell::new(Vec::new()),
});
// Connect signals and callbacks
back_button.connect_clicked(clone!(@weak this => move |_| {
this.handle.pop(None);
}));
this.save_button
.connect_clicked(clone!(@weak this => move |_| {
let data = TrackSetData {
recording: this.recording.borrow().clone().unwrap(),
tracks: this.tracks.borrow().clone(),
};
this.handle.pop(Some(data));
}));
select_recording_button.connect_clicked(clone!(@weak this => move |_| {
spawn!(@clone this, async move {
if let Some(recording) = push!(this.handle, RecordingSelector).await {
this.recording.replace(Some(recording));
this.recording_selected();
}
});
}));
edit_tracks_button.connect_clicked(clone!(@weak this => move |_| {
spawn!(@clone this, async move {
if let Some(selection) = push!(this.handle, TrackSelector, Arc::clone(&this.session)).await {
let mut tracks = Vec::new();
for index in selection {
let data = TrackData {
track_source: index,
work_parts: Vec::new(),
};
tracks.push(data);
}
let length = tracks.len();
this.tracks.replace(tracks);
this.track_list.update(length);
this.autofill_parts();
}
});
}));
this.track_list.set_make_widget_cb(clone!(@weak this => @default-panic, move |index| {
let track = &this.tracks.borrow()[index];
let mut title_parts = Vec::<String>::new();
if let Some(recording) = &*this.recording.borrow() {
for part in &track.work_parts {
title_parts.push(recording.work.parts[*part].title.clone());
}
}
let title = if title_parts.is_empty() {
gettext("Unknown")
} else {
title_parts.join(", ")
};
let tracks = this.session.tracks();
let track_name = &tracks[track.track_source].name;
let edit_image = gtk::Image::from_icon_name("document-edit-symbolic");
let edit_button = gtk::Button::new();
edit_button.set_has_frame(false);
edit_button.set_valign(gtk::Align::Center);
edit_button.set_child(Some(&edit_image));
let row = ActionRowBuilder::new()
.focusable(false)
.title(&title)
.subtitle(track_name)
.activatable_widget(&edit_button)
.build();
row.add_suffix(&edit_button);
edit_button.connect_clicked(clone!(@weak this => move |_| {
let recording = this.recording.borrow().clone();
if let Some(recording) = recording {
spawn!(@clone this, async move {
let work_parts = this.tracks.borrow()[index].work_parts.clone();
if let Some(selection) = push!(this.handle, TrackEditor, (recording, work_parts)).await {
{
let mut tracks = this.tracks.borrow_mut();
let mut track = &mut tracks[index];
track.work_parts = selection;
};
this.update_tracks();
}
});
}
}));
row.upcast()
}));
this.validate();
this
}
}
impl TrackSetEditor {
/// Set everything up after selecting a recording.
fn recording_selected(&self) {
if let Some(recording) = &*self.recording.borrow() {
self.recording_row.set_title(&recording.work.get_title());
self.recording_row.set_subtitle(&recording.get_performers());
self.save_button.set_sensitive(true);
}
// This will also call validate().
self.autofill_parts();
}
/// Automatically try to put work part information from the selected recording into the
/// selected tracks.
fn autofill_parts(&self) {
if let Some(recording) = &*self.recording.borrow() {
let mut tracks = self.tracks.borrow_mut();
for (index, _) in recording.work.parts.iter().enumerate() {
if let Some(mut track) = tracks.get_mut(index) {
track.work_parts = vec![index];
} else {
break;
}
}
}
self.update_tracks();
}
/// Update the track list.
fn update_tracks(&self) {
let length = self.tracks.borrow().len();
self.track_list.update(length);
self.validate();
}
/// Validate data and allow saving if possible.
fn validate(&self) {
self.save_button
.set_sensitive(self.recording.borrow().is_some() && !self.tracks.borrow().is_empty());
}
}
impl Widget for TrackSetEditor {
fn get_widget(&self) -> gtk::Widget {
self.widget.clone().upcast()
}
}

View file

@ -0,0 +1,80 @@
/// Simplification for pushing new screens.
///
/// This macro can be invoked in two forms.
///
/// 1. To push screens without an input value:
///
/// ```
/// let result = push!(handle, ScreenType).await;
/// ```
///
/// 2. To push screens with an input value:
///
/// ```
/// let result = push!(handle, ScreenType, input).await;
/// ```
#[macro_export]
macro_rules! push {
($handle:expr, $screen:ty) => {
$handle.push::<_, _, $screen>(())
};
($handle:expr, $screen:ty, $input:expr) => {
$handle.push::<_, _, $screen>($input)
};
}
/// Simplification for replacing the current navigator screen.
///
/// This macro can be invoked in two forms.
///
/// 1. To replace with screens without an input value:
///
/// ```
/// let result = replace!(navigator, ScreenType).await;
/// ```
///
/// 2. To replace with screens with an input value:
///
/// ```
/// let result = replace!(navigator, ScreenType, input).await;
/// ```
#[macro_export]
macro_rules! replace {
($navigator:expr, $screen:ty) => {
$navigator.replace::<_, _, $screen>(())
};
($navigator:expr, $screen:ty, $input:expr) => {
$navigator.replace::<_, _, $screen>($input)
};
}
/// Spawn a future on the GLib MainContext.
///
/// This can be invoked in the following forms:
///
/// 1. For spawning a future and nothing more:
///
/// ```
/// spawn!(async {
/// // Some code
/// });
///
/// 2. For spawning a future and cloning some data, that will be accessible
/// from the async code:
///
/// ```
/// spawn!(@clone data: Rc<_>, async move {
/// // Some code
/// });
#[macro_export]
macro_rules! spawn {
($future:expr) => {{
let context = glib::MainContext::default();
context.spawn_local($future);
}};
(@clone $data:ident, $future:expr) => {{
let context = glib::MainContext::default();
let $data = Rc::clone(&$data);
context.spawn_local($future);
}};
}

View file

@ -0,0 +1,45 @@
use gio::prelude::*;
use glib::clone;
use std::cell::RefCell;
use std::rc::Rc;
#[macro_use]
mod macros;
mod config;
mod editors;
mod import;
mod navigator;
mod preferences;
mod screens;
mod selectors;
mod widgets;
mod window;
use window::Window;
mod resources;
fn main() {
gettextrs::setlocale(gettextrs::LocaleCategory::LcAll, "");
gettextrs::bindtextdomain("musicus", config::LOCALEDIR).unwrap();
gettextrs::textdomain("musicus").unwrap();
gstreamer::init().expect("Failed to initialize GStreamer!");
gtk::init().expect("Failed to initialize GTK!");
adw::init();
resources::init().expect("Failed to initialize resources!");
let app = gtk::Application::new(Some("de.johrpan.musicus"), gio::ApplicationFlags::empty());
let window: RefCell<Option<Rc<Window>>> = RefCell::new(None);
app.connect_activate(clone!(@strong app => move |_| {
let mut window = window.borrow_mut();
if window.is_none() {
window.replace(Window::new(&app));
}
window.as_ref().unwrap().present();
}));
app.run();
}

View file

@ -0,0 +1,66 @@
prefix = get_option('prefix')
localedir = join_paths(prefix, get_option('localedir'))
global_conf = configuration_data()
global_conf.set_quoted('LOCALEDIR', localedir)
global_conf.set_quoted('VERSION', meson.project_version())
config_rs = configure_file(
input: 'config.rs.in',
output: 'config.rs',
configuration: global_conf
)
run_command(
'cp',
config_rs,
meson.current_source_dir(),
check: true
)
resource_conf = configuration_data()
resource_conf.set_quoted('RESOURCEFILE', resources.full_path())
resource_rs = configure_file(
input: 'resources.rs.in',
output: 'resources.rs',
configuration: resource_conf
)
run_command(
'cp',
resource_rs,
meson.current_source_dir(),
check: true
)
sources = files(
'config.rs',
'resources.rs',
)
system = host_machine.system()
if system == 'windows'
output = meson.project_name() + '.exe'
else
output = meson.project_name()
endif
cargo_script = find_program(join_paths(meson.source_root(), 'build-aux/cargo.sh'))
cargo_release = custom_target(
'cargo-build',
build_by_default: true,
input: sources,
build_always_stale: true,
depends: resources,
output: output,
console: true,
install: true,
install_dir: get_option('bindir'),
command: [
cargo_script,
meson.build_root(),
meson.source_root(),
'@OUTPUT@',
get_option('buildtype'),
output,
]
)

View file

@ -0,0 +1,225 @@
use crate::widgets::Widget;
use futures_channel::oneshot;
use futures_channel::oneshot::{Receiver, Sender};
use glib::clone;
use gtk::builders::StackBuilder;
use gtk::prelude::*;
use musicus_backend::Backend;
use std::cell::{Cell, RefCell};
use std::rc::{Rc, Weak};
pub mod window;
pub use window::*;
/// A widget that represents a logical unit of transient user interaction and
/// that optionally resolves to a specific return value.
pub trait Screen<I, O>: Widget {
/// Create a new screen and initialize it with the provided input value.
fn new(input: I, navigation_handle: NavigationHandle<O>) -> Rc<Self>
where
Self: Sized;
}
/// An accessor to navigation functionality for screens.
pub struct NavigationHandle<O> {
/// The backend, in case the screen needs it.
pub backend: Rc<Backend>,
/// The toplevel window, in case the screen needs it.
pub window: gtk::Window,
/// The navigator that created this navigation handle.
navigator: Weak<Navigator>,
/// The sender through which the result should be sent.
sender: Cell<Option<Sender<Option<O>>>>,
}
impl<O> NavigationHandle<O> {
/// Switch to another screen and wait for that screen's result.
pub async fn push<I, R, S: Screen<I, R> + 'static>(&self, input: I) -> Option<R> {
let navigator = self.unwrap_navigator();
let receiver = navigator.push::<I, R, S>(input);
// If the sender is dropped, return None.
receiver.await.unwrap_or(None)
}
/// Go back to the previous screen optionally returning something.
pub fn pop(&self, output: Option<O>) {
self.unwrap_navigator().pop();
let sender = self
.sender
.take()
.expect("Tried to send result from screen through a dropped sender.");
if sender.send(output).is_err() {
panic!("Tried to send result from screen to non-existing previous screen.");
}
}
/// Get the navigator and panic if it doesn't exist.
fn unwrap_navigator(&self) -> Rc<Navigator> {
Weak::upgrade(&self.navigator)
.expect("Tried to access non-existing navigator from a screen.")
}
}
/// A toplevel widget for managing screens.
pub struct Navigator {
/// The underlying GTK widget.
pub widget: gtk::Stack,
/// The backend, in case screens need it.
backend: Rc<Backend>,
/// The toplevel window of the navigator, in case screens need it.
window: gtk::Window,
/// The currently active screens. The last screen in this vector is the one
/// that is currently visible.
screens: RefCell<Vec<Rc<dyn Widget>>>,
/// A vector holding the widgets of the old screens that are waiting to be
/// removed after the animation has finished.
old_widgets: RefCell<Vec<gtk::Widget>>,
/// A closure that will be called when the last screen is popped.
back_cb: RefCell<Option<Box<dyn Fn()>>>,
}
impl Navigator {
/// Create a new navigator which will display the provided widget
/// initially.
pub fn new<W, E>(backend: Rc<Backend>, window: &W, empty_screen: &E) -> Rc<Self>
where
W: IsA<gtk::Window>,
E: IsA<gtk::Widget>,
{
let widget = StackBuilder::new()
.hhomogeneous(false)
.vhomogeneous(false)
.interpolate_size(true)
.transition_type(gtk::StackTransitionType::Crossfade)
.hexpand(true)
.vexpand(true)
.build();
widget.add_named(empty_screen, Some("empty_screen"));
let this = Rc::new(Self {
widget,
backend,
window: window.to_owned().upcast(),
screens: RefCell::new(Vec::new()),
old_widgets: RefCell::new(Vec::new()),
back_cb: RefCell::new(None),
});
this.widget
.connect_transition_running_notify(clone!(@strong this => move |_| {
if !this.widget.is_transition_running() {
this.clear_old_widgets();
}
}));
this
}
/// Set the closure to be called when the last screen is popped so that
/// the navigator shows its empty state.
pub fn set_back_cb<F: Fn() + 'static>(&self, cb: F) {
self.back_cb.replace(Some(Box::new(cb)));
}
/// Drop all screens and show the provided screen instead.
pub async fn replace<I, O, S: Screen<I, O> + 'static>(self: &Rc<Self>, input: I) -> Option<O> {
for screen in self.screens.replace(Vec::new()) {
self.old_widgets.borrow_mut().push(screen.get_widget());
}
let receiver = self.push::<I, O, S>(input);
if !self.widget.is_transition_running() {
self.clear_old_widgets();
}
// We ignore the case, if a sender is dropped.
receiver.await.unwrap_or(None)
}
/// Drop all screens and go back to the initial screen. The back callback
/// will not be called.
pub fn reset(&self) {
self.widget.set_visible_child_name("empty_screen");
for screen in self.screens.replace(Vec::new()) {
self.old_widgets.borrow_mut().push(screen.get_widget());
}
if !self.widget.is_transition_running() {
self.clear_old_widgets();
}
}
/// Show a screen with the provided input. This should only be called from
/// within a navigation handle.
fn push<I, O, S: Screen<I, O> + 'static>(self: &Rc<Self>, input: I) -> Receiver<Option<O>> {
let (sender, receiver) = oneshot::channel();
let handle = NavigationHandle {
backend: Rc::clone(&self.backend),
window: self.window.clone(),
navigator: Rc::downgrade(self),
sender: Cell::new(Some(sender)),
};
let screen = S::new(input, handle);
let widget = screen.get_widget();
self.widget.add_child(&widget);
self.widget.set_visible_child(&widget);
self.screens.borrow_mut().push(screen);
receiver
}
/// Pop the last screen from the list of screens.
fn pop(&self) {
let popped = if let Some(screen) = self.screens.borrow_mut().pop() {
let widget = screen.get_widget();
self.old_widgets.borrow_mut().push(widget);
true
} else {
false
};
if popped {
if let Some(screen) = self.screens.borrow().last() {
let widget = screen.get_widget();
self.widget.set_visible_child(&widget);
} else {
self.widget.set_visible_child_name("empty_screen");
if let Some(cb) = &*self.back_cb.borrow() {
cb()
}
}
if !self.widget.is_transition_running() {
self.clear_old_widgets();
}
}
}
/// Drop the old widgets.
fn clear_old_widgets(&self) {
for widget in self.old_widgets.borrow().iter() {
self.widget.remove(widget);
}
self.old_widgets.borrow_mut().clear();
}
}

View file

@ -0,0 +1,32 @@
use super::Navigator;
use adw::prelude::*;
use glib::clone;
use musicus_backend::Backend;
use std::rc::Rc;
/// A window hosting a navigator.
pub struct NavigatorWindow {
pub navigator: Rc<Navigator>,
window: adw::Window,
}
impl NavigatorWindow {
/// Create a new navigator window and show it.
pub fn new(backend: Rc<Backend>) -> Rc<Self> {
let window = adw::Window::new();
window.set_default_size(600, 424);
let placeholder = gtk::Label::new(None);
let navigator = Navigator::new(backend, &window, &placeholder);
window.set_content(Some(&navigator.widget));
let this = Rc::new(Self { navigator, window });
this.navigator.set_back_cb(clone!(@strong this => move || {
this.window.close();
}));
this.window.show();
this
}
}

View file

@ -0,0 +1,90 @@
use adw::prelude::*;
use gettextrs::gettext;
use glib::clone;
use gtk_macros::get_widget;
use musicus_backend::Backend;
use std::rc::Rc;
/// A dialog for configuring the app.
pub struct Preferences {
backend: Rc<Backend>,
window: adw::Window,
music_library_path_row: adw::ActionRow,
}
impl Preferences {
/// Create a new preferences dialog.
pub fn new<P: IsA<gtk::Window>>(backend: Rc<Backend>, parent: &P) -> Rc<Self> {
// Create UI
let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/preferences.ui");
get_widget!(builder, adw::Window, window);
get_widget!(builder, adw::ActionRow, music_library_path_row);
get_widget!(builder, gtk::Button, select_music_library_path_button);
get_widget!(builder, gtk::Switch, keep_playing_switch);
get_widget!(builder, gtk::Switch, play_full_recordings_switch);
window.set_transient_for(Some(parent));
let this = Rc::new(Self {
backend,
window,
music_library_path_row,
});
// Connect signals and callbacks
select_music_library_path_button.connect_clicked(clone!(@strong this => move |_| {
let dialog = gtk::FileChooserDialog::new(
Some(&gettext("Select music library folder")),
Some(&this.window),
gtk::FileChooserAction::SelectFolder,
&[
(&gettext("Cancel"), gtk::ResponseType::Cancel),
(&gettext("Select"), gtk::ResponseType::Accept),
]);
dialog.set_modal(true);
dialog.connect_response(clone!(@strong this => move |dialog, response| {
if let gtk::ResponseType::Accept = response {
if let Some(file) = dialog.file() {
if let Some(path) = file.path() {
Rc::clone(&this.backend).set_music_library_path(path.clone()).unwrap();
this.music_library_path_row.set_subtitle(path.to_str().unwrap());
}
}
}
dialog.hide();
}));
dialog.show();
}));
keep_playing_switch.connect_active_notify(clone!(@weak this => move |switch| {
Rc::clone(&this.backend).set_keep_playing(switch.is_active());
}));
play_full_recordings_switch.connect_active_notify(clone!(@weak this => move |switch| {
Rc::clone(&this.backend).set_play_full_recordings(switch.is_active());
}));
// Initialize
if let Some(path) = this.backend.get_music_library_path() {
this.music_library_path_row
.set_subtitle(path.to_str().unwrap());
}
keep_playing_switch.set_active(this.backend.keep_playing());
play_full_recordings_switch.set_active(this.backend.play_full_recordings());
this
}
/// Show the preferences dialog.
pub fn show(&self) {
self.window.show();
}
}

View file

@ -0,0 +1,9 @@
use anyhow::Result;
pub fn init() -> Result<()> {
let bytes = glib::Bytes::from(include_bytes!(@RESOURCEFILE@).as_ref());
let resource = gio::Resource::from_data(&bytes)?;
gio::resources_register(&resource);
Ok(())
}

View file

@ -0,0 +1,176 @@
use super::{MediumScreen, RecordingScreen};
use crate::editors::EnsembleEditor;
use crate::navigator::{NavigationHandle, NavigatorWindow, Screen};
use crate::widgets;
use crate::widgets::{List, Section, Widget};
use adw::builders::ActionRowBuilder;
use adw::prelude::*;
use gettextrs::gettext;
use glib::clone;
use musicus_backend::db::{Ensemble, Medium, Recording};
use std::cell::RefCell;
use std::rc::Rc;
/// A screen for showing recordings with a ensemble.
pub struct EnsembleScreen {
handle: NavigationHandle<()>,
ensemble: Ensemble,
widget: widgets::Screen,
recording_list: Rc<List>,
medium_list: Rc<List>,
recordings: RefCell<Vec<Recording>>,
mediums: RefCell<Vec<Medium>>,
}
impl Screen<Ensemble, ()> for EnsembleScreen {
/// Create a new ensemble screen for the specified ensemble and load the
/// contents asynchronously.
fn new(ensemble: Ensemble, handle: NavigationHandle<()>) -> Rc<Self> {
let widget = widgets::Screen::new();
widget.set_title(&ensemble.name);
let recording_list = List::new();
let medium_list = List::new();
let this = Rc::new(Self {
handle,
ensemble,
widget,
recording_list,
medium_list,
recordings: RefCell::new(Vec::new()),
mediums: RefCell::new(Vec::new()),
});
this.widget.set_back_cb(clone!(@weak this => move || {
this.handle.pop(None);
}));
this.widget.add_action(
&gettext("Edit ensemble"),
clone!(@weak this => move || {
spawn!(@clone this, async move {
let window = NavigatorWindow::new(this.handle.backend.clone());
replace!(window.navigator, EnsembleEditor, Some(this.ensemble.clone())).await;
});
}),
);
this.widget.add_action(
&gettext("Delete ensemble"),
clone!(@weak this => move || {
spawn!(@clone this, async move {
this.handle.backend.db().delete_ensemble(&this.ensemble.id).unwrap();
this.handle.backend.library_changed();
});
}),
);
this.widget.set_search_cb(clone!(@weak this => move || {
this.recording_list.invalidate_filter();
this.medium_list.invalidate_filter();
}));
this.recording_list.set_make_widget_cb(
clone!(@weak this => @default-panic, move |index| {
let recording = &this.recordings.borrow()[index];
let row = ActionRowBuilder::new()
.activatable(true)
.title(&recording.work.get_title())
.subtitle(&recording.get_performers())
.build();
let recording = recording.to_owned();
row.connect_activated(clone!(@weak this => move |_| {
let recording = recording.clone();
spawn!(@clone this, async move {
push!(this.handle, RecordingScreen, recording.clone()).await;
});
}));
row.upcast()
}),
);
this.recording_list
.set_filter_cb(clone!(@weak this => @default-panic, move |index| {
let recording = &this.recordings.borrow()[index];
let search = this.widget.get_search();
let text = recording.work.get_title() + &recording.get_performers();
search.is_empty() || text.to_lowercase().contains(&search)
}));
this.medium_list
.set_make_widget_cb(clone!(@weak this => @default-panic, move |index| {
let medium = &this.mediums.borrow()[index];
let row = ActionRowBuilder::new()
.activatable(true)
.title(&medium.name)
.build();
let medium = medium.to_owned();
row.connect_activated(clone!(@weak this => move |_| {
let medium = medium.clone();
spawn!(@clone this, async move {
push!(this.handle, MediumScreen, medium.clone()).await;
});
}));
row.upcast()
}));
this.medium_list
.set_filter_cb(clone!(@weak this => @default-panic, move |index| {
let medium = &this.mediums.borrow()[index];
let search = this.widget.get_search();
let name = medium.name.to_lowercase();
search.is_empty() || name.contains(&search)
}));
// Load the content.
let recordings = this
.handle
.backend
.db()
.get_recordings_for_ensemble(&this.ensemble.id)
.unwrap();
let mediums = this
.handle
.backend
.db()
.get_mediums_for_ensemble(&this.ensemble.id)
.unwrap();
if !recordings.is_empty() {
let length = recordings.len();
this.recordings.replace(recordings);
this.recording_list.update(length);
let section = Section::new("Recordings", &this.recording_list.widget);
this.widget.add_content(&section.widget);
}
if !mediums.is_empty() {
let length = mediums.len();
this.mediums.replace(mediums);
this.medium_list.update(length);
let section = Section::new("Mediums", &this.medium_list.widget);
this.widget.add_content(&section.widget);
}
this.widget.ready();
this
}
}
impl Widget for EnsembleScreen {
fn get_widget(&self) -> gtk::Widget {
self.widget.widget.clone().upcast()
}
}

View file

@ -0,0 +1,214 @@
use super::{EnsembleScreen, PersonScreen, PlayerScreen};
use crate::config;
use crate::import::SourceSelector;
use crate::navigator::{NavigationHandle, Navigator, NavigatorWindow, Screen};
use crate::preferences::Preferences;
use crate::widgets::{List, PlayerBar, Widget};
use adw::builders::ActionRowBuilder;
use adw::prelude::*;
use gettextrs::gettext;
use glib::clone;
use gtk::builders::AboutDialogBuilder;
use gtk_macros::get_widget;
use musicus_backend::db::PersonOrEnsemble;
use std::cell::RefCell;
use std::rc::Rc;
/// The main screen of the app, once it's set up and finished loading. The screen assumes that the
/// music library and the player are available and initialized.
pub struct MainScreen {
handle: NavigationHandle<()>,
widget: gtk::Box,
leaflet: adw::Leaflet,
search_entry: gtk::SearchEntry,
stack: gtk::Stack,
poe_list: Rc<List>,
navigator: Rc<Navigator>,
poes: RefCell<Vec<PersonOrEnsemble>>,
}
impl Screen<(), ()> for MainScreen {
/// Create a new main screen.
fn new(_: (), handle: NavigationHandle<()>) -> Rc<Self> {
let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/main_screen.ui");
get_widget!(builder, gtk::Box, widget);
get_widget!(builder, adw::Leaflet, leaflet);
get_widget!(builder, gtk::Revealer, play_button_revealer);
get_widget!(builder, gtk::Button, play_button);
get_widget!(builder, gtk::Button, add_button);
get_widget!(builder, gtk::SearchEntry, search_entry);
get_widget!(builder, gtk::Stack, stack);
get_widget!(builder, gtk::ScrolledWindow, scroll);
get_widget!(builder, gtk::Box, empty_screen);
let actions = gio::SimpleActionGroup::new();
let preferences_action = gio::SimpleAction::new("preferences", None);
let about_action = gio::SimpleAction::new("about", None);
actions.add_action(&preferences_action);
actions.add_action(&about_action);
widget.insert_action_group("widget", Some(&actions));
let poe_list = List::new();
poe_list.widget.set_css_classes(&["navigation-sidebar"]);
poe_list.enable_selection();
let navigator = Navigator::new(Rc::clone(&handle.backend), &handle.window, &empty_screen);
scroll.set_child(Some(&poe_list.widget));
leaflet.append(&navigator.widget);
let player_bar = PlayerBar::new();
widget.append(&player_bar.widget);
player_bar.set_player(Some(Rc::clone(&handle.backend.pl())));
let this = Rc::new(Self {
handle,
widget,
leaflet,
search_entry,
stack,
poe_list,
navigator,
poes: RefCell::new(Vec::new()),
});
preferences_action.connect_activate(clone!(@weak this => move |_, _| {
Preferences::new(Rc::clone(&this.handle.backend), &this.handle.window).show();
}));
about_action.connect_activate(clone!(@weak this => move |_, _| {
this.show_about_dialog();
}));
add_button.connect_clicked(clone!(@weak this => move |_| {
spawn!(@clone this, async move {
let window = NavigatorWindow::new(Rc::clone(&this.handle.backend));
replace!(window.navigator, SourceSelector).await;
});
}));
this.search_entry
.connect_search_changed(clone!(@weak this => move |_| {
this.poe_list.invalidate_filter();
}));
this.poe_list
.set_make_widget_cb(clone!(@weak this => @default-panic, move |index| {
let poe = &this.poes.borrow()[index];
let row = ActionRowBuilder::new()
.activatable(true)
.title(&poe.get_title())
.build();
let poe = poe.to_owned();
row.connect_activated(clone!(@weak this => move |_| {
let poe = poe.clone();
spawn!(@clone this, async move {
this.leaflet.set_visible_child(&this.navigator.widget);
match poe {
PersonOrEnsemble::Person(person) => {
replace!(this.navigator, PersonScreen, person).await;
}
PersonOrEnsemble::Ensemble(ensemble) => {
replace!(this.navigator, EnsembleScreen, ensemble).await;
}
}
});
}));
row.upcast()
}));
this.poe_list
.set_filter_cb(clone!(@weak this => @default-panic, move |index| {
let poe = &this.poes.borrow()[index];
let search = this.search_entry.text().to_string().to_lowercase();
let title = poe.get_title().to_lowercase();
search.is_empty() || title.contains(&search)
}));
this.handle.backend.pl().add_playlist_cb(
clone!(@weak play_button_revealer => move |new_playlist| {
play_button_revealer.set_reveal_child(new_playlist.is_empty());
}),
);
play_button.connect_clicked(clone!(@weak this => move |_| {
if let Ok(recording) = this.handle.backend.db().random_recording() {
this.handle.backend.pl().add_items(this.handle.backend.db().get_tracks(&recording.id).unwrap()).unwrap();
}
}));
this.navigator.set_back_cb(clone!(@weak this => move || {
this.leaflet.set_visible_child_name("sidebar");
}));
player_bar.set_playlist_cb(clone!(@weak this => move || {
spawn!(@clone this, async move {
push!(this.handle, PlayerScreen).await;
});
}));
// Load the content whenever there is a new library update.
spawn!(@clone this, async move {
loop {
this.navigator.reset();
let mut poes = Vec::new();
let persons = this.handle.backend.db().get_persons().unwrap();
let ensembles = this.handle.backend.db().get_ensembles().unwrap();
for person in persons {
poes.push(PersonOrEnsemble::Person(person));
}
for ensemble in ensembles {
poes.push(PersonOrEnsemble::Ensemble(ensemble));
}
let length = poes.len();
this.poes.replace(poes);
this.poe_list.update(length);
this.stack.set_visible_child_name("content");
if this.handle.backend.library_update().await.is_err() {
break;
}
}
});
this
}
}
impl Widget for MainScreen {
fn get_widget(&self) -> gtk::Widget {
self.widget.clone().upcast()
}
}
impl MainScreen {
/// Show a dialog with information on this application.
fn show_about_dialog(&self) {
let dialog = AboutDialogBuilder::new()
.transient_for(&self.handle.window)
.modal(true)
.logo_icon_name("de.johrpan.musicus")
.program_name(&gettext("Musicus"))
.version(config::VERSION)
.comments(&gettext("The classical music player and organizer."))
.website("https://code.johrpan.de/johrpan/musicus")
.website_label(&gettext("Further information and source code"))
.copyright("© 2020 Elias Projahn")
.license_type(gtk::License::Agpl30)
.authors(vec![String::from("Elias Projahn <elias@johrpan.de>")])
.build();
dialog.show();
}
}

View file

@ -0,0 +1,97 @@
use crate::navigator::{NavigationHandle, Screen};
use crate::widgets;
use crate::widgets::{List, Section, Widget};
use adw::builders::ActionRowBuilder;
use adw::prelude::*;
use gettextrs::gettext;
use glib::clone;
use musicus_backend::db::Medium;
use std::rc::Rc;
/// A screen for showing the contents of a medium.
pub struct MediumScreen {
handle: NavigationHandle<()>,
medium: Medium,
widget: widgets::Screen,
list: Rc<List>,
}
impl Screen<Medium, ()> for MediumScreen {
/// Create a new medium screen for the specified medium and load the
/// contents asynchronously.
fn new(medium: Medium, handle: NavigationHandle<()>) -> Rc<Self> {
let widget = widgets::Screen::new();
widget.set_title(&medium.name);
let list = List::new();
let section = Section::new("Recordings", &list.widget);
widget.add_content(&section.widget);
widget.ready();
let this = Rc::new(Self {
handle,
medium,
widget,
list,
});
this.widget.set_back_cb(clone!(@weak this => move || {
this.handle.pop(None);
}));
this.widget.add_action(
&gettext("Edit medium"),
clone!(@weak this => move || {
// TODO: Show medium editor.
}),
);
this.widget.add_action(
&gettext("Delete medium"),
clone!(@weak this => move || {
// TODO: Delete medium and maybe also the tracks?
}),
);
section.add_action(
"media-playback-start-symbolic",
clone!(@weak this => move || {
this.handle.backend.pl().add_items(this.medium.tracks.clone()).unwrap();
}),
);
this.list
.set_make_widget_cb(clone!(@weak this => @default-panic, move |index| {
let track = &this.medium.tracks[index];
let mut parts = Vec::<String>::new();
for part in &track.work_parts {
parts.push(track.recording.work.parts[*part].title.clone());
}
let title = if parts.is_empty() {
gettext("Unknown")
} else {
parts.join(", ")
};
let row = ActionRowBuilder::new()
.margin_start(12)
.selectable(false)
.title(&title)
.build();
row.upcast()
}));
this.list.update(this.medium.tracks.len());
this
}
}
impl Widget for MediumScreen {
fn get_widget(&self) -> gtk::Widget {
self.widget.widget.clone().upcast()
}
}

View file

@ -0,0 +1,23 @@
pub mod ensemble;
pub use ensemble::*;
pub mod main;
pub use main::*;
pub mod medium;
pub use medium::*;
pub mod person;
pub use person::*;
pub mod player;
pub use player::*;
pub mod work;
pub use work::*;
pub mod welcome;
pub use welcome::*;
pub mod recording;
pub use recording::*;

View file

@ -0,0 +1,221 @@
use super::{MediumScreen, RecordingScreen, WorkScreen};
use crate::editors::PersonEditor;
use crate::navigator::{NavigationHandle, NavigatorWindow, Screen};
use crate::widgets;
use crate::widgets::{List, Section, Widget};
use adw::builders::ActionRowBuilder;
use adw::prelude::*;
use gettextrs::gettext;
use glib::clone;
use musicus_backend::db::{Medium, Person, Recording, Work};
use std::cell::RefCell;
use std::rc::Rc;
/// A screen for showing works by and recordings with a person.
pub struct PersonScreen {
handle: NavigationHandle<()>,
person: Person,
widget: widgets::Screen,
work_list: Rc<List>,
recording_list: Rc<List>,
medium_list: Rc<List>,
works: RefCell<Vec<Work>>,
recordings: RefCell<Vec<Recording>>,
mediums: RefCell<Vec<Medium>>,
}
impl Screen<Person, ()> for PersonScreen {
/// Create a new person screen for the specified person and load the
/// contents asynchronously.
fn new(person: Person, handle: NavigationHandle<()>) -> Rc<Self> {
let widget = widgets::Screen::new();
widget.set_title(&person.name_fl());
let work_list = List::new();
let recording_list = List::new();
let medium_list = List::new();
let this = Rc::new(Self {
handle,
person,
widget,
work_list,
recording_list,
medium_list,
works: RefCell::new(Vec::new()),
recordings: RefCell::new(Vec::new()),
mediums: RefCell::new(Vec::new()),
});
this.widget.set_back_cb(clone!(@weak this => move || {
this.handle.pop(None);
}));
this.widget.add_action(
&gettext("Edit person"),
clone!(@weak this => move || {
spawn!(@clone this, async move {
let window = NavigatorWindow::new(this.handle.backend.clone());
replace!(window.navigator, PersonEditor, Some(this.person.clone())).await;
});
}),
);
this.widget.add_action(
&gettext("Delete person"),
clone!(@weak this => move || {
spawn!(@clone this, async move {
this.handle.backend.db().delete_person(&this.person.id).unwrap();
this.handle.backend.library_changed();
});
}),
);
this.widget.set_search_cb(clone!(@weak this => move || {
this.work_list.invalidate_filter();
this.recording_list.invalidate_filter();
this.medium_list.invalidate_filter();
}));
this.work_list
.set_make_widget_cb(clone!(@weak this => @default-panic, move |index| {
let work = &this.works.borrow()[index];
let row = ActionRowBuilder::new()
.activatable(true)
.title(&work.title)
.build();
let work = work.to_owned();
row.connect_activated(clone!(@weak this => move |_| {
let work = work.clone();
spawn!(@clone this, async move {
push!(this.handle, WorkScreen, work.clone()).await;
});
}));
row.upcast()
}));
this.work_list
.set_filter_cb(clone!(@weak this => @default-panic, move|index| {
let work = &this.works.borrow()[index];
let search = this.widget.get_search();
let title = work.title.to_lowercase();
search.is_empty() || title.contains(&search)
}));
this.recording_list.set_make_widget_cb(
clone!(@weak this => @default-panic, move |index| {
let recording = &this.recordings.borrow()[index];
let row = ActionRowBuilder::new()
.activatable(true)
.title(&recording.work.get_title())
.subtitle(&recording.get_performers())
.build();
let recording = recording.to_owned();
row.connect_activated(clone!(@weak this => move |_| {
let recording = recording.clone();
spawn!(@clone this, async move {
push!(this.handle, RecordingScreen, recording.clone()).await;
});
}));
row.upcast()
}),
);
this.recording_list
.set_filter_cb(clone!(@weak this => @default-panic,move |index| {
let recording = &this.recordings.borrow()[index];
let search = this.widget.get_search();
let text = recording.work.get_title() + &recording.get_performers();
search.is_empty() || text.to_lowercase().contains(&search)
}));
this.medium_list
.set_make_widget_cb(clone!(@weak this => @default-panic, move |index| {
let medium = &this.mediums.borrow()[index];
let row = ActionRowBuilder::new()
.activatable(true)
.title(&medium.name)
.build();
let medium = medium.to_owned();
row.connect_activated(clone!(@weak this => move |_| {
let medium = medium.clone();
spawn!(@clone this, async move {
push!(this.handle, MediumScreen, medium.clone()).await;
});
}));
row.upcast()
}));
this.medium_list
.set_filter_cb(clone!(@weak this => @default-panic, move |index| {
let medium = &this.mediums.borrow()[index];
let search = this.widget.get_search();
let name = medium.name.to_lowercase();
search.is_empty() || name.contains(&search)
}));
// Load the content.
let works = this.handle.backend.db().get_works(&this.person.id).unwrap();
let recordings = this
.handle
.backend
.db()
.get_recordings_for_person(&this.person.id)
.unwrap();
let mediums = this
.handle
.backend
.db()
.get_mediums_for_person(&this.person.id)
.unwrap();
if !works.is_empty() {
let length = works.len();
this.works.replace(works);
this.work_list.update(length);
let section = Section::new("Works", &this.work_list.widget);
this.widget.add_content(&section.widget);
}
if !recordings.is_empty() {
let length = recordings.len();
this.recordings.replace(recordings);
this.recording_list.update(length);
let section = Section::new("Recordings", &this.recording_list.widget);
this.widget.add_content(&section.widget);
}
if !mediums.is_empty() {
let length = mediums.len();
this.mediums.replace(mediums);
this.medium_list.update(length);
let section = Section::new("Mediums", &this.medium_list.widget);
this.widget.add_content(&section.widget);
}
this.widget.ready();
this
}
}
impl Widget for PersonScreen {
fn get_widget(&self) -> gtk::Widget {
self.widget.widget.clone().upcast()
}
}

View file

@ -0,0 +1,260 @@
use crate::navigator::{NavigationHandle, Screen};
use crate::widgets::{List, TrackRow, Widget};
use adw::prelude::*;
use glib::clone;
use gtk::gdk;
use gtk_macros::get_widget;
use musicus_backend::db::Track;
use std::cell::{Cell, RefCell};
use std::rc::Rc;
/// A playable track within the playlist.
#[derive(Clone)]
struct ListItem {
/// Index within the playlist.
index: usize,
/// Whether this is the first track of the recording.
first: bool,
/// Whether this is the currently played track.
playing: bool,
}
pub struct PlayerScreen {
handle: NavigationHandle<()>,
widget: gtk::Box,
title_label: gtk::Label,
subtitle_label: gtk::Label,
previous_button: gtk::Button,
play_button: gtk::Button,
next_button: gtk::Button,
position_label: gtk::Label,
position: gtk::Adjustment,
duration_label: gtk::Label,
play_image: gtk::Image,
pause_image: gtk::Image,
list: Rc<List>,
playlist: RefCell<Vec<Track>>,
items: RefCell<Vec<ListItem>>,
seeking: Cell<bool>,
current_track: Cell<usize>,
}
impl Screen<(), ()> for PlayerScreen {
fn new(_: (), handle: NavigationHandle<()>) -> Rc<Self> {
let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/player_screen.ui");
get_widget!(builder, gtk::Box, widget);
get_widget!(builder, gtk::Button, back_button);
get_widget!(builder, gtk::Box, content);
get_widget!(builder, gtk::Label, title_label);
get_widget!(builder, gtk::Label, subtitle_label);
get_widget!(builder, gtk::Button, previous_button);
get_widget!(builder, gtk::Button, play_button);
get_widget!(builder, gtk::Button, next_button);
get_widget!(builder, gtk::Button, stop_button);
get_widget!(builder, gtk::Label, position_label);
get_widget!(builder, gtk::Scale, position_scale);
get_widget!(builder, gtk::Adjustment, position);
get_widget!(builder, gtk::Label, duration_label);
get_widget!(builder, gtk::Image, play_image);
get_widget!(builder, gtk::Image, pause_image);
let list = List::new();
content.append(&list.widget);
let event_controller = gtk::EventControllerLegacy::new();
position_scale.add_controller(&event_controller);
let this = Rc::new(Self {
handle,
widget,
title_label,
subtitle_label,
previous_button,
play_button,
next_button,
position_label,
position,
duration_label,
play_image,
pause_image,
list,
items: RefCell::new(Vec::new()),
playlist: RefCell::new(Vec::new()),
seeking: Cell::new(false),
current_track: Cell::new(0),
});
let player = &this.handle.backend.pl();
player.add_playlist_cb(clone!(@weak this => move |playlist| {
if playlist.is_empty() {
this.handle.pop(None);
}
this.playlist.replace(playlist);
this.show_playlist();
}));
player.add_track_cb(clone!(@weak this, @weak player => move |current_track| {
this.previous_button.set_sensitive(this.handle.backend.pl().has_previous());
this.next_button.set_sensitive(this.handle.backend.pl().has_next());
let track = &this.playlist.borrow()[current_track];
let mut parts = Vec::<String>::new();
for part in &track.work_parts {
parts.push(track.recording.work.parts[*part].title.clone());
}
let mut title = track.recording.work.get_title();
if !parts.is_empty() {
title = format!("{}: {}", title, parts.join(", "));
}
this.title_label.set_text(&title);
this.subtitle_label.set_text(&track.recording.get_performers());
this.position_label.set_text("0:00");
this.current_track.set(current_track);
this.show_playlist();
}));
player.add_duration_cb(clone!(@weak this => move |ms| {
let min = ms / 60000;
let sec = (ms % 60000) / 1000;
this.duration_label.set_text(&format!("{}:{:02}", min, sec));
this.position.set_upper(ms as f64);
}));
player.add_playing_cb(clone!(@weak this => move |playing| {
this.play_button.set_child(Some(if playing {
&this.pause_image
} else {
&this.play_image
}));
}));
player.add_position_cb(clone!(@weak this => move |ms| {
if !this.seeking.get() {
let min = ms / 60000;
let sec = (ms % 60000) / 1000;
this.position_label.set_text(&format!("{}:{:02}", min, sec));
this.position.set_value(ms as f64);
}
}));
back_button.connect_clicked(clone!(@weak this => move |_| {
this.handle.pop(None);
}));
this.previous_button
.connect_clicked(clone!(@weak this => move |_| {
this.handle.backend.pl().previous().unwrap();
}));
this.play_button
.connect_clicked(clone!(@weak this => move |_| {
this.handle.backend.pl().play_pause().unwrap();
}));
this.next_button
.connect_clicked(clone!(@weak this => move |_| {
this.handle.backend.pl().next().unwrap();
}));
stop_button.connect_clicked(clone!(@weak this => move |_| {
this.handle.backend.pl().clear();
}));
event_controller.connect_event(
clone!(@weak this => @default-return glib::signal::Inhibit(false), move |_, event| {
if let Some(event) = event.downcast_ref::<gdk::ButtonEvent>() {
if event.button() == gdk::BUTTON_PRIMARY {
match event.event_type() {
gdk::EventType::ButtonPress => {
this.seeking.replace(true);
}
gdk::EventType::ButtonRelease => {
this.handle.backend.pl().seek(this.position.value() as u64);
this.seeking.replace(false);
}
_ => (),
}
}
}
glib::signal::Inhibit(false)
}),
);
position_scale.connect_value_changed(clone!(@weak this => move |_| {
if this.seeking.get() {
let ms = this.position.value() as u64;
let min = ms / 60000;
let sec = (ms % 60000) / 1000;
this.position_label.set_text(&format!("{}:{:02}", min, sec));
}
}));
this.list
.set_make_widget_cb(clone!(@weak this => @default-panic, move |index| {
let item = &this.items.borrow()[index];
let track = &this.playlist.borrow()[item.index];
TrackRow::new(track, item.first, item.playing).get_widget()
}));
this.list
.widget
.connect_row_activated(clone!(@weak this => move |_, row| {
let list_index = row.index();
let list_item = this.items.borrow()[list_index as usize].clone();
this.handle.backend.pl().set_track(list_item.index).unwrap();
}));
player.send_data();
this
}
}
impl PlayerScreen {
/// Update the user interface according to the playlist.
fn show_playlist(&self) {
let playlist = self.playlist.borrow();
let current_track = self.current_track.get();
let mut items = Vec::new();
let mut last_recording_id = "";
for (index, track) in playlist.iter().enumerate() {
let first_track = if track.recording.id != last_recording_id {
last_recording_id = &track.recording.id;
true
} else {
false
};
items.push(ListItem {
index,
first: first_track,
playing: index == current_track,
});
}
let length = items.len();
self.items.replace(items);
self.list.update(length);
}
}
impl Widget for PlayerScreen {
fn get_widget(&self) -> gtk::Widget {
self.widget.clone().upcast()
}
}

View file

@ -0,0 +1,124 @@
use crate::editors::RecordingEditor;
use crate::navigator::{NavigationHandle, NavigatorWindow, Screen};
use crate::widgets;
use crate::widgets::{List, Section, Widget};
use adw::builders::ActionRowBuilder;
use adw::prelude::*;
use gettextrs::gettext;
use glib::clone;
use musicus_backend::db::{Recording, Track};
use std::cell::RefCell;
use std::rc::Rc;
/// A screen for showing a recording.
pub struct RecordingScreen {
handle: NavigationHandle<()>,
recording: Recording,
widget: widgets::Screen,
list: Rc<List>,
tracks: RefCell<Vec<Track>>,
}
impl Screen<Recording, ()> for RecordingScreen {
/// Create a new recording screen for the specified recording and load the
/// contents asynchronously.
fn new(recording: Recording, handle: NavigationHandle<()>) -> Rc<Self> {
let widget = widgets::Screen::new();
widget.set_title(&recording.work.get_title());
widget.set_subtitle(&recording.get_performers());
let list = List::new();
let section = Section::new(&gettext("Tracks"), &list.widget);
widget.add_content(&section.widget);
let this = Rc::new(Self {
handle,
recording,
widget,
list,
tracks: RefCell::new(Vec::new()),
});
section.add_action(
"media-playback-start-symbolic",
clone!(@weak this => move || {
this.handle.backend.pl().add_items(this.tracks.borrow().clone()).unwrap();
}),
);
this.widget.set_back_cb(clone!(@weak this => move || {
this.handle.pop(None);
}));
this.widget.add_action(
&gettext("Edit recording"),
clone!(@weak this => move || {
spawn!(@clone this, async move {
let window = NavigatorWindow::new(this.handle.backend.clone());
replace!(window.navigator, RecordingEditor, Some(this.recording.clone())).await;
});
}),
);
this.widget.add_action(
&gettext("Delete recording"),
clone!(@weak this => move || {
spawn!(@clone this, async move {
this.handle.backend.db().delete_recording(&this.recording.id).unwrap();
this.handle.backend.library_changed();
});
}),
);
this.list
.set_make_widget_cb(clone!(@weak this => @default-panic, move |index| {
let track = &this.tracks.borrow()[index];
let mut title_parts = Vec::<String>::new();
for part in &track.work_parts {
title_parts.push(this.recording.work.parts[*part].title.clone());
}
let title = if title_parts.is_empty() {
gettext("Unknown")
} else {
title_parts.join(", ")
};
let row = ActionRowBuilder::new()
.title(&title)
.build();
row.upcast()
}));
// Load the content.
let tracks = this
.handle
.backend
.db()
.get_tracks(&this.recording.id)
.unwrap();
this.show_tracks(tracks);
this.widget.ready();
this
}
}
impl RecordingScreen {
/// Update the tracks variable as well as the user interface.
fn show_tracks(&self, tracks: Vec<Track>) {
let length = tracks.len();
self.tracks.replace(tracks);
self.list.update(length);
}
}
impl Widget for RecordingScreen {
fn get_widget(&self) -> gtk::Widget {
self.widget.widget.clone().upcast()
}
}

View file

@ -0,0 +1,86 @@
use crate::navigator::{NavigationHandle, Screen};
use crate::widgets::Widget;
use adw::builders::{HeaderBarBuilder, StatusPageBuilder};
use gettextrs::gettext;
use glib::clone;
use gtk::builders::{BoxBuilder, ButtonBuilder};
use gtk::prelude::*;
use std::rc::Rc;
/// A screen displaying a welcome message and the necessary means to set up the application. This
/// screen doesn't access the backend except for setting the initial values and is safe to be used
/// while the backend is loading.
pub struct WelcomeScreen {
handle: NavigationHandle<()>,
widget: gtk::Box,
}
impl Screen<(), ()> for WelcomeScreen {
fn new(_: (), handle: NavigationHandle<()>) -> Rc<Self> {
let widget = BoxBuilder::new()
.orientation(gtk::Orientation::Vertical)
.build();
let header = HeaderBarBuilder::new()
.title_widget(&adw::WindowTitle::new("Musicus", ""))
.build();
let button = ButtonBuilder::new()
.halign(gtk::Align::Center)
.label(&gettext("Select folder"))
.build();
let welcome = StatusPageBuilder::new()
.icon_name("folder-music-symbolic")
.title(&gettext("Welcome to Musicus!"))
.description(&gettext(
"Get startet by selecting the folder containing your music \
files! Musicus will create a new database there or open one that already exists.",
))
.child(&button)
.vexpand(true)
.build();
button.add_css_class("suggested-action");
widget.append(&header);
widget.append(&welcome);
let this = Rc::new(Self { handle, widget });
button.connect_clicked(clone!(@weak this => move |_| {
let dialog = gtk::FileChooserDialog::new(
Some(&gettext("Select music library folder")),
Some(&this.handle.window),
gtk::FileChooserAction::SelectFolder,
&[
(&gettext("Cancel"), gtk::ResponseType::Cancel),
(&gettext("Select"), gtk::ResponseType::Accept),
]);
dialog.set_modal(true);
dialog.connect_response(clone!(@weak this => move |dialog, response| {
if let gtk::ResponseType::Accept = response {
if let Some(file) = dialog.file() {
if let Some(path) = file.path() {
Rc::clone(&this.handle.backend).set_music_library_path(path).unwrap();
}
}
}
dialog.hide();
}));
dialog.show();
}));
this
}
}
impl Widget for WelcomeScreen {
fn get_widget(&self) -> gtk::Widget {
self.widget.clone().upcast()
}
}

View file

@ -0,0 +1,127 @@
use super::RecordingScreen;
use crate::editors::WorkEditor;
use crate::navigator::{NavigationHandle, NavigatorWindow, Screen};
use crate::widgets;
use crate::widgets::{List, Section, Widget};
use adw::builders::ActionRowBuilder;
use adw::prelude::*;
use gettextrs::gettext;
use glib::clone;
use musicus_backend::db::{Recording, Work};
use std::cell::RefCell;
use std::rc::Rc;
/// A screen for showing recordings of a work.
pub struct WorkScreen {
handle: NavigationHandle<()>,
work: Work,
widget: widgets::Screen,
recording_list: Rc<List>,
recordings: RefCell<Vec<Recording>>,
}
impl Screen<Work, ()> for WorkScreen {
/// Create a new work screen for the specified work and load the
/// contents asynchronously.
fn new(work: Work, handle: NavigationHandle<()>) -> Rc<Self> {
let widget = widgets::Screen::new();
widget.set_title(&work.title);
widget.set_subtitle(&work.composer.name_fl());
let recording_list = List::new();
let this = Rc::new(Self {
handle,
work,
widget,
recording_list,
recordings: RefCell::new(Vec::new()),
});
this.widget.set_back_cb(clone!(@weak this => move || {
this.handle.pop(None);
}));
this.widget.add_action(
&gettext("Edit work"),
clone!(@weak this => move || {
spawn!(@clone this, async move {
let window = NavigatorWindow::new(this.handle.backend.clone());
replace!(window.navigator, WorkEditor, Some(this.work.clone())).await;
});
}),
);
this.widget.add_action(
&gettext("Delete work"),
clone!(@weak this => move || {
spawn!(@clone this, async move {
this.handle.backend.db().delete_work(&this.work.id).unwrap();
this.handle.backend.library_changed();
});
}),
);
this.widget.set_search_cb(clone!(@weak this => move || {
this.recording_list.invalidate_filter();
}));
this.recording_list.set_make_widget_cb(
clone!(@weak this => @default-panic, move |index| {
let recording = &this.recordings.borrow()[index];
let row = ActionRowBuilder::new()
.activatable(true)
.title(&recording.work.get_title())
.subtitle(&recording.get_performers())
.build();
let recording = recording.to_owned();
row.connect_activated(clone!(@weak this => move |_| {
let recording = recording.clone();
spawn!(@clone this, async move {
push!(this.handle, RecordingScreen, recording.clone()).await;
});
}));
row.upcast()
}),
);
this.recording_list
.set_filter_cb(clone!(@weak this => @default-panic, move |index| {
let recording = &this.recordings.borrow()[index];
let search = this.widget.get_search();
let text = recording.work.get_title() + &recording.get_performers();
search.is_empty() || text.to_lowercase().contains(&search)
}));
// Load the content.
let recordings = this
.handle
.backend
.db()
.get_recordings_for_work(&this.work.id)
.unwrap();
if !recordings.is_empty() {
let length = recordings.len();
this.recordings.replace(recordings);
this.recording_list.update(length);
let section = Section::new("Recordings", &this.recording_list.widget);
this.widget.add_content(&section.widget);
}
this.widget.ready();
this
}
}
impl Widget for WorkScreen {
fn get_widget(&self) -> gtk::Widget {
self.widget.widget.clone().upcast()
}
}

View file

@ -0,0 +1,77 @@
use super::selector::Selector;
use crate::editors::EnsembleEditor;
use crate::navigator::{NavigationHandle, Screen};
use crate::widgets::Widget;
use adw::builders::ActionRowBuilder;
use adw::prelude::*;
use gettextrs::gettext;
use glib::clone;
use log::warn;
use musicus_backend::db::Ensemble;
use std::rc::Rc;
/// A screen for selecting a ensemble.
pub struct EnsembleSelector {
handle: NavigationHandle<Ensemble>,
selector: Rc<Selector<Ensemble>>,
}
impl Screen<(), Ensemble> for EnsembleSelector {
/// Create a new ensemble selector.
fn new(_: (), handle: NavigationHandle<Ensemble>) -> Rc<Self> {
// Create UI
let selector = Selector::<Ensemble>::new();
selector.set_title(&gettext("Select ensemble"));
let this = Rc::new(Self { handle, selector });
// Connect signals and callbacks
this.selector.set_back_cb(clone!(@weak this => move || {
this.handle.pop(None);
}));
this.selector.set_add_cb(clone!(@weak this => move || {
spawn!(@clone this, async move {
if let Some(ensemble) = push!(this.handle, EnsembleEditor, None).await {
this.handle.pop(Some(ensemble));
}
});
}));
this.selector
.set_make_widget(clone!(@weak this => @default-panic, move |ensemble| {
let row = ActionRowBuilder::new()
.activatable(true)
.title(&ensemble.name)
.build();
let ensemble = ensemble.to_owned();
row.connect_activated(clone!(@weak this => move |_| {
if let Err(err) = this.handle.backend.db().update_ensemble(ensemble.clone()) {
warn!("Failed to update access time. {err}");
}
this.handle.pop(Some(ensemble.clone()))
}));
row.upcast()
}));
this.selector
.set_filter(|search, ensemble| ensemble.name.to_lowercase().contains(search));
this.selector
.set_items(this.handle.backend.db().get_recent_ensembles().unwrap());
this
}
}
impl Widget for EnsembleSelector {
fn get_widget(&self) -> gtk::Widget {
self.selector.widget.clone().upcast()
}
}

View file

@ -0,0 +1,77 @@
use super::selector::Selector;
use crate::editors::InstrumentEditor;
use crate::navigator::{NavigationHandle, Screen};
use crate::widgets::Widget;
use adw::builders::ActionRowBuilder;
use adw::prelude::*;
use gettextrs::gettext;
use glib::clone;
use log::warn;
use musicus_backend::db::Instrument;
use std::rc::Rc;
/// A screen for selecting a instrument.
pub struct InstrumentSelector {
handle: NavigationHandle<Instrument>,
selector: Rc<Selector<Instrument>>,
}
impl Screen<(), Instrument> for InstrumentSelector {
/// Create a new instrument selector.
fn new(_: (), handle: NavigationHandle<Instrument>) -> Rc<Self> {
// Create UI
let selector = Selector::<Instrument>::new();
selector.set_title(&gettext("Select instrument"));
let this = Rc::new(Self { handle, selector });
// Connect signals and callbacks
this.selector.set_back_cb(clone!(@weak this => move || {
this.handle.pop(None);
}));
this.selector.set_add_cb(clone!(@weak this => move || {
spawn!(@clone this, async move {
if let Some(instrument) = push!(this.handle, InstrumentEditor, None).await {
this.handle.pop(Some(instrument));
}
});
}));
this.selector
.set_make_widget(clone!(@weak this => @default-panic, move |instrument| {
let row = ActionRowBuilder::new()
.activatable(true)
.title(&instrument.name)
.build();
let instrument = instrument.to_owned();
row.connect_activated(clone!(@weak this => move |_| {
if let Err(err) = this.handle.backend.db().update_instrument(instrument.clone()) {
warn!("Failed to update access time. {err}");
}
this.handle.pop(Some(instrument.clone()))
}));
row.upcast()
}));
this.selector
.set_filter(|search, instrument| instrument.name.to_lowercase().contains(search));
this.selector
.set_items(this.handle.backend.db().get_recent_instruments().unwrap());
this
}
}
impl Widget for InstrumentSelector {
fn get_widget(&self) -> gtk::Widget {
self.selector.widget.clone().upcast()
}
}

View file

@ -0,0 +1,157 @@
use super::selector::Selector;
use crate::navigator::{NavigationHandle, Screen};
use crate::widgets::Widget;
use adw::builders::ActionRowBuilder;
use adw::prelude::*;
use gettextrs::gettext;
use glib::clone;
use log::warn;
use musicus_backend::db::{Medium, PersonOrEnsemble};
use std::rc::Rc;
/// A screen for selecting a medium.
pub struct MediumSelector {
handle: NavigationHandle<Medium>,
selector: Rc<Selector<PersonOrEnsemble>>,
}
impl Screen<(), Medium> for MediumSelector {
fn new(_: (), handle: NavigationHandle<Medium>) -> Rc<Self> {
// Create UI
let selector = Selector::<PersonOrEnsemble>::new();
selector.set_title(&gettext("Select performer"));
let this = Rc::new(Self { handle, selector });
// Connect signals and callbacks
this.selector.set_back_cb(clone!(@weak this => move || {
this.handle.pop(None);
}));
this.selector.set_make_widget(clone!(@weak this => @default-panic, move |poe| {
let row = ActionRowBuilder::new()
.activatable(true)
.title(&poe.get_title())
.build();
let poe = poe.to_owned();
row.connect_activated(clone!(@weak this => move |_| {
let poe = poe.clone();
spawn!(@clone this, async move {
if let Some(medium) = push!(this.handle, MediumSelectorMediumScreen, poe).await {
this.handle.pop(Some(medium));
}
});
}));
row.upcast()
}));
this.selector
.set_filter(|search, poe| poe.get_title().to_lowercase().contains(search));
// Initialize items.
let mut poes = Vec::new();
let persons = this.handle.backend.db().get_recent_persons().unwrap();
let ensembles = this.handle.backend.db().get_recent_ensembles().unwrap();
for person in persons {
poes.push(PersonOrEnsemble::Person(person));
}
for ensemble in ensembles {
poes.push(PersonOrEnsemble::Ensemble(ensemble));
}
this.selector.set_items(poes);
this
}
}
impl Widget for MediumSelector {
fn get_widget(&self) -> gtk::Widget {
self.selector.widget.clone().upcast()
}
}
/// The actual medium selector that is displayed after the user has selected a person or ensemble.
struct MediumSelectorMediumScreen {
handle: NavigationHandle<Medium>,
poe: PersonOrEnsemble,
selector: Rc<Selector<Medium>>,
}
impl Screen<PersonOrEnsemble, Medium> for MediumSelectorMediumScreen {
fn new(poe: PersonOrEnsemble, handle: NavigationHandle<Medium>) -> Rc<Self> {
let selector = Selector::<Medium>::new();
selector.set_title(&gettext("Select medium"));
selector.set_subtitle(&poe.get_title());
let this = Rc::new(Self {
handle,
poe,
selector,
});
this.selector.set_back_cb(clone!(@weak this => move || {
this.handle.pop(None);
}));
this.selector
.set_make_widget(clone!(@weak this => @default-panic, move |medium| {
let row = ActionRowBuilder::new()
.activatable(true)
.title(&medium.name)
.build();
let medium = medium.to_owned();
row.connect_activated(clone!(@weak this => move |_| {
if let Err(err) = this.handle.backend.db().update_medium(medium.clone()) {
warn!("Failed to update access time. {err}");
}
this.handle.pop(Some(medium.clone()));
}));
row.upcast()
}));
this.selector
.set_filter(|search, medium| medium.name.to_lowercase().contains(search));
// Initialize items.
match this.poe.clone() {
PersonOrEnsemble::Person(person) => {
this.selector.set_items(
this.handle
.backend
.db()
.get_mediums_for_person(&person.id)
.unwrap(),
);
}
PersonOrEnsemble::Ensemble(ensemble) => {
this.selector.set_items(
this.handle
.backend
.db()
.get_mediums_for_ensemble(&ensemble.id)
.unwrap(),
);
}
}
this
}
}
impl Widget for MediumSelectorMediumScreen {
fn get_widget(&self) -> gtk::Widget {
self.selector.widget.clone().upcast()
}
}

View file

@ -0,0 +1,19 @@
pub mod ensemble;
pub use ensemble::*;
pub mod instrument;
pub use instrument::*;
pub mod medium;
pub use medium::*;
pub mod person;
pub use person::*;
pub mod recording;
pub use recording::*;
pub mod work;
pub use work::*;
mod selector;

View file

@ -0,0 +1,77 @@
use super::selector::Selector;
use crate::editors::PersonEditor;
use crate::navigator::{NavigationHandle, Screen};
use crate::widgets::Widget;
use adw::builders::ActionRowBuilder;
use adw::prelude::*;
use gettextrs::gettext;
use glib::clone;
use log::warn;
use musicus_backend::db::Person;
use std::rc::Rc;
/// A screen for selecting a person.
pub struct PersonSelector {
handle: NavigationHandle<Person>,
selector: Rc<Selector<Person>>,
}
impl Screen<(), Person> for PersonSelector {
/// Create a new person selector.
fn new(_: (), handle: NavigationHandle<Person>) -> Rc<Self> {
// Create UI
let selector = Selector::<Person>::new();
selector.set_title(&gettext("Select person"));
let this = Rc::new(Self { handle, selector });
// Connect signals and callbacks
this.selector.set_back_cb(clone!(@weak this => move || {
this.handle.pop(None);
}));
this.selector.set_add_cb(clone!(@weak this => move || {
spawn!(@clone this, async move {
if let Some(person) = push!(this.handle, PersonEditor, None).await {
this.handle.pop(Some(person));
}
});
}));
this.selector
.set_make_widget(clone!(@weak this => @default-panic, move |person| {
let row = ActionRowBuilder::new()
.activatable(true)
.title(&person.name_lf())
.build();
let person = person.to_owned();
row.connect_activated(clone!(@weak this => move |_| {
if let Err(err) = this.handle.backend.db().update_person(person.clone()) {
warn!("Failed to update access time. {err}");
}
this.handle.pop(Some(person.clone()));
}));
row.upcast()
}));
this.selector
.set_filter(|search, person| person.name_fl().to_lowercase().contains(search));
this.selector
.set_items(this.handle.backend.db().get_recent_persons().unwrap());
this
}
}
impl Widget for PersonSelector {
fn get_widget(&self) -> gtk::Widget {
self.selector.widget.clone().upcast()
}
}

Some files were not shown because too many files have changed in this diff Show more