mirror of
https://github.com/johrpan/musicus.git
synced 2025-10-26 11:47:25 +01:00
Move crates to subdirectory
This commit is contained in:
parent
1db96062fb
commit
ac4b29e86d
115 changed files with 10 additions and 5 deletions
19
crates/backend/Cargo.toml
Normal file
19
crates/backend/Cargo.toml
Normal 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"
|
||||
17
crates/backend/src/error.rs
Normal file
17
crates/backend/src/error.rs
Normal 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
181
crates/backend/src/lib.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
86
crates/backend/src/library.rs
Normal file
86
crates/backend/src/library.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
63
crates/backend/src/logger.rs
Normal file
63
crates/backend/src/logger.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
483
crates/backend/src/player.rs
Normal file
483
crates/backend/src/player.rs
Normal 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
1
crates/database/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
test.sqlite
|
||||
13
crates/database/Cargo.toml
Normal file
13
crates/database/Cargo.toml
Normal 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"] }
|
||||
2
crates/database/diesel.toml
Normal file
2
crates/database/diesel.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[print_schema]
|
||||
file = "src/schema.rs"
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
@ -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
|
||||
);
|
||||
|
||||
|
|
@ -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";
|
||||
|
|
@ -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;
|
||||
|
||||
76
crates/database/src/ensembles.rs
Normal file
76
crates/database/src/ensembles.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
24
crates/database/src/error.rs
Normal file
24
crates/database/src/error.rs
Normal 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>;
|
||||
79
crates/database/src/instruments.rs
Normal file
79
crates/database/src/instruments.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
66
crates/database/src/lib.rs
Normal file
66
crates/database/src/lib.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
337
crates/database/src/medium.rs
Normal file
337
crates/database/src/medium.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
89
crates/database/src/persons.rs
Normal file
89
crates/database/src/persons.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
344
crates/database/src/recordings.rs
Normal file
344
crates/database/src/recordings.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
123
crates/database/src/schema.rs
Normal file
123
crates/database/src/schema.rs
Normal 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,
|
||||
);
|
||||
249
crates/database/src/works.rs
Normal file
249
crates/database/src/works.rs
Normal 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
16
crates/import/Cargo.toml
Normal 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
174
crates/import/src/disc.rs
Normal 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)
|
||||
}
|
||||
84
crates/import/src/error.rs
Normal file
84
crates/import/src/error.rs
Normal 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>;
|
||||
80
crates/import/src/folder.rs
Normal file
80
crates/import/src/folder.rs
Normal 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
8
crates/import/src/lib.rs
Normal 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;
|
||||
127
crates/import/src/session.rs
Normal file
127
crates/import/src/session.rs
Normal 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
2
crates/musicus/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/src/config.rs
|
||||
/src/resources.rs
|
||||
20
crates/musicus/Cargo.toml
Normal file
20
crates/musicus/Cargo.toml
Normal 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"
|
||||
8
crates/musicus/data/de.johrpan.musicus.desktop.in
Normal file
8
crates/musicus/data/de.johrpan.musicus.desktop.in
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
[Desktop Entry]
|
||||
Name=Musicus
|
||||
Icon=de.johrpan.musicus
|
||||
Exec=musicus
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=GTK;
|
||||
StartupNotify=true
|
||||
17
crates/musicus/data/de.johrpan.musicus.gschema.xml
Normal file
17
crates/musicus/data/de.johrpan.musicus.gschema.xml
Normal 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>
|
||||
|
|
@ -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 |
|
|
@ -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 |
41
crates/musicus/data/meson.build
Normal file
41
crates/musicus/data/meson.build
Normal 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
|
||||
1
crates/musicus/po/LINGUAS
Normal file
1
crates/musicus/po/LINGUAS
Normal file
|
|
@ -0,0 +1 @@
|
|||
de
|
||||
71
crates/musicus/po/POTFILES.in
Normal file
71
crates/musicus/po/POTFILES.in
Normal 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
515
crates/musicus/po/de.po
Normal 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."
|
||||
1
crates/musicus/po/meson.build
Normal file
1
crates/musicus/po/meson.build
Normal file
|
|
@ -0,0 +1 @@
|
|||
i18n.gettext('musicus', preset: 'glib')
|
||||
431
crates/musicus/po/musicus.pot
Normal file
431
crates/musicus/po/musicus.pot
Normal 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 ""
|
||||
9
crates/musicus/res/meson.build
Normal file
9
crates/musicus/res/meson.build
Normal 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,
|
||||
)
|
||||
25
crates/musicus/res/musicus.gresource.xml
Normal file
25
crates/musicus/res/musicus.gresource.xml
Normal 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>
|
||||
94
crates/musicus/res/ui/editor.ui
Normal file
94
crates/musicus/res/ui/editor.ui
Normal 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>
|
||||
191
crates/musicus/res/ui/import_screen.ui
Normal file
191
crates/musicus/res/ui/import_screen.ui
Normal 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>
|
||||
159
crates/musicus/res/ui/main_screen.ui
Normal file
159
crates/musicus/res/ui/main_screen.ui
Normal 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>
|
||||
206
crates/musicus/res/ui/medium_editor.ui
Normal file
206
crates/musicus/res/ui/medium_editor.ui
Normal 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>
|
||||
175
crates/musicus/res/ui/medium_preview.ui
Normal file
175
crates/musicus/res/ui/medium_preview.ui
Normal 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>
|
||||
105
crates/musicus/res/ui/performance_editor.ui
Normal file
105
crates/musicus/res/ui/performance_editor.ui
Normal 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>
|
||||
116
crates/musicus/res/ui/player_bar.ui
Normal file
116
crates/musicus/res/ui/player_bar.ui
Normal 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>
|
||||
154
crates/musicus/res/ui/player_screen.ui
Normal file
154
crates/musicus/res/ui/player_screen.ui
Normal 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>
|
||||
66
crates/musicus/res/ui/preferences.ui
Normal file
66
crates/musicus/res/ui/preferences.ui
Normal 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>
|
||||
168
crates/musicus/res/ui/recording_editor.ui
Normal file
168
crates/musicus/res/ui/recording_editor.ui
Normal 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>
|
||||
86
crates/musicus/res/ui/screen.ui
Normal file
86
crates/musicus/res/ui/screen.ui
Normal 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>
|
||||
38
crates/musicus/res/ui/section.ui
Normal file
38
crates/musicus/res/ui/section.ui
Normal 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>
|
||||
112
crates/musicus/res/ui/selector.ui
Normal file
112
crates/musicus/res/ui/selector.ui
Normal 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>
|
||||
127
crates/musicus/res/ui/source_selector.ui
Normal file
127
crates/musicus/res/ui/source_selector.ui
Normal 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>
|
||||
48
crates/musicus/res/ui/track_editor.ui
Normal file
48
crates/musicus/res/ui/track_editor.ui
Normal 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>
|
||||
65
crates/musicus/res/ui/track_row.ui
Normal file
65
crates/musicus/res/ui/track_row.ui
Normal 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>
|
||||
49
crates/musicus/res/ui/track_selector.ui
Normal file
49
crates/musicus/res/ui/track_selector.ui
Normal 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>
|
||||
112
crates/musicus/res/ui/track_set_editor.ui
Normal file
112
crates/musicus/res/ui/track_set_editor.ui
Normal 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>
|
||||
195
crates/musicus/res/ui/work_editor.ui
Normal file
195
crates/musicus/res/ui/work_editor.ui
Normal 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>
|
||||
69
crates/musicus/res/ui/work_part_editor.ui
Normal file
69
crates/musicus/res/ui/work_part_editor.ui
Normal 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>
|
||||
2
crates/musicus/src/config.rs.in
Normal file
2
crates/musicus/src/config.rs.in
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub static VERSION: &str = @VERSION@;
|
||||
pub static LOCALEDIR: &str = @LOCALEDIR@;
|
||||
102
crates/musicus/src/editors/ensemble.rs
Normal file
102
crates/musicus/src/editors/ensemble.rs
Normal 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(§ion.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()
|
||||
}
|
||||
}
|
||||
105
crates/musicus/src/editors/instrument.rs
Normal file
105
crates/musicus/src/editors/instrument.rs
Normal 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(§ion.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()
|
||||
}
|
||||
}
|
||||
17
crates/musicus/src/editors/mod.rs
Normal file
17
crates/musicus/src/editors/mod.rs
Normal 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;
|
||||
203
crates/musicus/src/editors/performance.rs
Normal file
203
crates/musicus/src/editors/performance.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
124
crates/musicus/src/editors/person.rs
Normal file
124
crates/musicus/src/editors/person.rs
Normal 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(§ion.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()
|
||||
}
|
||||
}
|
||||
205
crates/musicus/src/editors/recording.rs
Normal file
205
crates/musicus/src/editors/recording.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
275
crates/musicus/src/editors/work.rs
Normal file
275
crates/musicus/src/editors/work.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
76
crates/musicus/src/editors/work_part.rs
Normal file
76
crates/musicus/src/editors/work_part.rs
Normal 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(§ion.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()
|
||||
}
|
||||
}
|
||||
165
crates/musicus/src/import/import_screen.rs
Normal file
165
crates/musicus/src/import/import_screen.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
220
crates/musicus/src/import/medium_editor.rs
Normal file
220
crates/musicus/src/import/medium_editor.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
271
crates/musicus/src/import/medium_preview.rs
Normal file
271
crates/musicus/src/import/medium_preview.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
9
crates/musicus/src/import/mod.rs
Normal file
9
crates/musicus/src/import/mod.rs
Normal 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;
|
||||
113
crates/musicus/src/import/source_selector.rs
Normal file
113
crates/musicus/src/import/source_selector.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
89
crates/musicus/src/import/track_editor.rs
Normal file
89
crates/musicus/src/import/track_editor.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
100
crates/musicus/src/import/track_selector.rs
Normal file
100
crates/musicus/src/import/track_selector.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
233
crates/musicus/src/import/track_set_editor.rs
Normal file
233
crates/musicus/src/import/track_set_editor.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
80
crates/musicus/src/macros.rs
Normal file
80
crates/musicus/src/macros.rs
Normal 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);
|
||||
}};
|
||||
}
|
||||
45
crates/musicus/src/main.rs
Normal file
45
crates/musicus/src/main.rs
Normal 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();
|
||||
}
|
||||
66
crates/musicus/src/meson.build
Normal file
66
crates/musicus/src/meson.build
Normal 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,
|
||||
]
|
||||
)
|
||||
225
crates/musicus/src/navigator/mod.rs
Normal file
225
crates/musicus/src/navigator/mod.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
32
crates/musicus/src/navigator/window.rs
Normal file
32
crates/musicus/src/navigator/window.rs
Normal 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
|
||||
}
|
||||
}
|
||||
90
crates/musicus/src/preferences.rs
Normal file
90
crates/musicus/src/preferences.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
9
crates/musicus/src/resources.rs.in
Normal file
9
crates/musicus/src/resources.rs.in
Normal 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(())
|
||||
}
|
||||
176
crates/musicus/src/screens/ensemble.rs
Normal file
176
crates/musicus/src/screens/ensemble.rs
Normal 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(§ion.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(§ion.widget);
|
||||
}
|
||||
|
||||
this.widget.ready();
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for EnsembleScreen {
|
||||
fn get_widget(&self) -> gtk::Widget {
|
||||
self.widget.widget.clone().upcast()
|
||||
}
|
||||
}
|
||||
214
crates/musicus/src/screens/main.rs
Normal file
214
crates/musicus/src/screens/main.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
97
crates/musicus/src/screens/medium.rs
Normal file
97
crates/musicus/src/screens/medium.rs
Normal 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(§ion.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()
|
||||
}
|
||||
}
|
||||
23
crates/musicus/src/screens/mod.rs
Normal file
23
crates/musicus/src/screens/mod.rs
Normal 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::*;
|
||||
221
crates/musicus/src/screens/person.rs
Normal file
221
crates/musicus/src/screens/person.rs
Normal 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(§ion.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(§ion.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(§ion.widget);
|
||||
}
|
||||
|
||||
this.widget.ready();
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for PersonScreen {
|
||||
fn get_widget(&self) -> gtk::Widget {
|
||||
self.widget.widget.clone().upcast()
|
||||
}
|
||||
}
|
||||
260
crates/musicus/src/screens/player.rs
Normal file
260
crates/musicus/src/screens/player.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
124
crates/musicus/src/screens/recording.rs
Normal file
124
crates/musicus/src/screens/recording.rs
Normal 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(§ion.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()
|
||||
}
|
||||
}
|
||||
86
crates/musicus/src/screens/welcome.rs
Normal file
86
crates/musicus/src/screens/welcome.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
127
crates/musicus/src/screens/work.rs
Normal file
127
crates/musicus/src/screens/work.rs
Normal 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(§ion.widget);
|
||||
}
|
||||
|
||||
this.widget.ready();
|
||||
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for WorkScreen {
|
||||
fn get_widget(&self) -> gtk::Widget {
|
||||
self.widget.widget.clone().upcast()
|
||||
}
|
||||
}
|
||||
77
crates/musicus/src/selectors/ensemble.rs
Normal file
77
crates/musicus/src/selectors/ensemble.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
77
crates/musicus/src/selectors/instrument.rs
Normal file
77
crates/musicus/src/selectors/instrument.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
157
crates/musicus/src/selectors/medium.rs
Normal file
157
crates/musicus/src/selectors/medium.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
19
crates/musicus/src/selectors/mod.rs
Normal file
19
crates/musicus/src/selectors/mod.rs
Normal 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;
|
||||
77
crates/musicus/src/selectors/person.rs
Normal file
77
crates/musicus/src/selectors/person.rs
Normal 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
Loading…
Add table
Add a link
Reference in a new issue