diff --git a/data/res/de.johrpan.Musicus.gresource.xml.in b/data/res/de.johrpan.Musicus.gresource.xml.in index fdb906f..839e78e 100644 --- a/data/res/de.johrpan.Musicus.gresource.xml.in +++ b/data/res/de.johrpan.Musicus.gresource.xml.in @@ -1,6 +1,7 @@ + icons/scalable/actions/library-symbolic.svg icons/scalable/actions/music-note-symbolic.svg icons/scalable/actions/playlist-symbolic.svg style.css diff --git a/data/res/icons/scalable/actions/library-symbolic.svg b/data/res/icons/scalable/actions/library-symbolic.svg new file mode 100644 index 0000000..f9f7515 --- /dev/null +++ b/data/res/icons/scalable/actions/library-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/data/ui/empty_page.blp b/data/ui/empty_page.blp new file mode 100644 index 0000000..bee614a --- /dev/null +++ b/data/ui/empty_page.blp @@ -0,0 +1,72 @@ +using Gtk 4.0; +using Adw 1; + +template $MusicusEmptyPage: Adw.NavigationPage { + title: _("New Library"); + + Adw.ToolbarView { + [top] + Adw.HeaderBar header_bar { + [end] + MenuButton { + icon-name: "open-menu-symbolic"; + menu-model: primary_menu; + } + } + + Adw.StatusPage { + icon-name: "library-symbolic"; + title: _("New Library"); + description: _("You can import your recordings by selecting \"Import music\" in the main menu. Musicus also comes with a small pre-made library of recordings. You can download it using the button below."); + + child: Gtk.Box { + orientation: vertical; + + Gtk.Button download_button { + halign: center; + label: _("Download music"); + clicked => $download_library() swapped; + + styles [ + "suggested-action", + "pill", + ] + } + + Adw.Clamp { + Gtk.ListBox process_list { + selection-mode: none; + margin-top: 12; + visible: false; + + styles [ + "boxed-list-separate", + ] + } + } + }; + } + } +} + +menu primary_menu { + item { + label: _("_Import music"); + action: "win.import"; + } + + item { + label: _("_Library manager"); + action: "win.library"; + } + + item { + label: _("_Preferences"); + action: "win.preferences"; + } + + item { + label: _("_About Musicus"); + action: "app.about"; + } +} diff --git a/data/ui/preferences_dialog.blp b/data/ui/preferences_dialog.blp index 6bfe78a..d771b59 100644 --- a/data/ui/preferences_dialog.blp +++ b/data/ui/preferences_dialog.blp @@ -4,6 +4,7 @@ using Adw 1; template $MusicusPreferencesDialog: Adw.PreferencesDialog { Adw.PreferencesPage { title: _("Playback"); + icon-name: "media-playback-start-symbolic"; Adw.PreferencesGroup { title: _("Default program"); @@ -65,6 +66,7 @@ template $MusicusPreferencesDialog: Adw.PreferencesDialog { Adw.PreferencesPage { title: _("Library"); + icon-name: "library-symbolic"; Adw.PreferencesGroup { title: _("Library download"); diff --git a/data/ui/welcome_page.blp b/data/ui/welcome_page.blp index 0e1c1a4..865ef98 100644 --- a/data/ui/welcome_page.blp +++ b/data/ui/welcome_page.blp @@ -1,7 +1,7 @@ using Gtk 4.0; using Adw 1; -template $MusicusWelcomePage : Adw.NavigationPage { +template $MusicusWelcomePage: Adw.NavigationPage { title: _("Welcome to Musicus"); tag: "welcome"; @@ -15,12 +15,17 @@ template $MusicusWelcomePage : Adw.NavigationPage { } } - Adw.StatusPage status_page { + Adw.StatusPage { icon-name: "music-note-symbolic"; title: _("Welcome to Musicus"); description: _("Get started by choosing where you want to store your music library. Are you using Musicus for the first time? If so, create a new empty folder for your library. If you wish, Musicus will automatically download some music for you."); + child: Gtk.Button { - styles ["suggested-action", "pill"] + styles [ + "suggested-action", + "pill", + ] + halign: center; label: _("Choose library folder"); clicked => $choose_library_folder() swapped; @@ -34,8 +39,9 @@ menu primary_menu { label: _("_Preferences"); action: "win.preferences"; } + item { label: _("_About Musicus"); action: "app.about"; } -} \ No newline at end of file +} diff --git a/src/empty_page.rs b/src/empty_page.rs new file mode 100644 index 0000000..a44136a --- /dev/null +++ b/src/empty_page.rs @@ -0,0 +1,173 @@ +use std::cell::OnceCell; + +use adw::{ + prelude::*, + subclass::{navigation_page::NavigationPageImpl, prelude::*}, +}; +use gettextrs::gettext; +use glib::clone; +use gtk::{gio, glib, glib::subclass::Signal}; +use once_cell::sync::Lazy; + +use crate::{ + config, library::Library, process::Process, process_manager::ProcessManager, + process_row::ProcessRow, +}; + +mod imp { + use super::*; + + #[derive(Debug, Default, gtk::CompositeTemplate)] + #[template(file = "data/ui/empty_page.blp")] + pub struct EmptyPage { + pub library: OnceCell, + pub process_manager: OnceCell, + + #[template_child] + pub download_button: TemplateChild, + #[template_child] + pub process_list: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for EmptyPage { + const NAME: &'static str = "MusicusEmptyPage"; + type Type = super::EmptyPage; + type ParentType = adw::NavigationPage; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + klass.bind_template_instance_callbacks(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for EmptyPage { + fn signals() -> &'static [Signal] { + static SIGNALS: Lazy> = + Lazy::new(|| vec![Signal::builder("ready").build()]); + + SIGNALS.as_ref() + } + } + + impl WidgetImpl for EmptyPage {} + impl NavigationPageImpl for EmptyPage {} +} + +glib::wrapper! { + pub struct EmptyPage(ObjectSubclass) + @extends gtk::Widget, adw::NavigationPage; +} + +#[gtk::template_callbacks] +impl EmptyPage { + pub fn new(library: &Library, process_manager: &ProcessManager) -> Self { + let obj: Self = glib::Object::new(); + + for process in process_manager.processes() { + obj.add_process(&process); + } + + obj.imp().library.set(library.to_owned()).unwrap(); + obj.imp() + .process_manager + .set(process_manager.to_owned()) + .unwrap(); + + obj + } + + pub fn connect_ready(&self, f: F) -> glib::SignalHandlerId { + self.connect_local("ready", true, move |values| { + let obj = values[0].get::().unwrap(); + f(&obj); + None + }) + } + + #[template_callback] + async fn download_library(&self) { + let dialog = adw::AlertDialog::builder() + .heading(&gettext("Disclaimer")) + .body(&gettext("You are about to download a library of audio files. These are from recordings that are in the public domain under EU law and are hosted on a server within the EU. Please ensure that you comply with the copyright laws of you country.")) + .build(); + + dialog.add_response("continue", &gettext("Continue")); + dialog.set_response_appearance("continue", adw::ResponseAppearance::Suggested); + dialog.add_response("cancel", &gettext("Cancel")); + dialog.set_default_response(Some("cancel")); + dialog.set_close_response("cancel"); + + let obj = self.to_owned(); + glib::spawn_future_local(async move { + if dialog.choose_future(&obj).await == "continue" { + obj.imp().download_button.set_visible(false); + + let settings = gio::Settings::new(config::APP_ID); + let url = if settings.boolean("use-custom-library-url") { + settings.string("custom-library-url").to_string() + } else { + config::LIBRARY_URL.to_string() + }; + + match obj.imp().library.get().unwrap().import_url(&url) { + Ok(receiver) => { + let process = Process::new(&gettext("Downloading music library"), receiver); + + process.connect_finished_notify(clone!( + #[weak] + obj, + move |process| { + if process.finished() { + if process.error().is_some() { + obj.imp().download_button.set_visible(true); + } else { + obj.emit_by_name::<()>("ready", &[]); + } + } + } + )); + + obj.imp() + .process_manager + .get() + .unwrap() + .add_process(&process); + + obj.add_process(&process); + } + Err(err) => log::error!("Failed to download library: {err:?}"), + } + } + }); + } + + fn add_process(&self, process: &Process) { + let row = ProcessRow::new(process); + + row.connect_remove(clone!( + #[weak(rename_to = obj)] + self, + move |row| { + obj.imp() + .process_manager + .get() + .unwrap() + .remove_process(&row.process()); + + obj.imp().process_list.remove(row); + + if obj.imp().process_list.first_child().is_none() { + obj.imp().process_list.set_visible(false); + } + } + )); + + self.imp().process_list.append(&row); + self.imp().process_list.set_visible(true); + } +} diff --git a/src/library.rs b/src/library.rs index 3bd5659..4a929c9 100644 --- a/src/library.rs +++ b/src/library.rs @@ -77,6 +77,16 @@ impl Library { Ok(obj) } + /// Whether this library is empty. The library is considered empty, if + /// there are no tracks. + pub fn is_empty(&self) -> Result { + let connection = &mut *self.imp().connection.get().unwrap().lock().unwrap(); + Ok(tracks::table + .first::(connection) + .optional()? + .is_none()) + } + /// Import from a library archive. pub fn import_archive( &self, diff --git a/src/main.rs b/src/main.rs index 95295fb..35e5a4c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod application; mod config; mod db; mod editor; +mod empty_page; mod library; mod library_manager; mod player; diff --git a/src/welcome_page.rs b/src/welcome_page.rs index db114c9..c7b1ba2 100644 --- a/src/welcome_page.rs +++ b/src/welcome_page.rs @@ -8,10 +8,7 @@ mod imp { #[derive(Debug, Default, gtk::CompositeTemplate)] #[template(file = "data/ui/welcome_page.blp")] - pub struct WelcomePage { - #[template_child] - pub status_page: TemplateChild, - } + pub struct WelcomePage {} #[glib::object_subclass] impl ObjectSubclass for WelcomePage { diff --git a/src/window.rs b/src/window.rs index 98e6ffd..b3848b8 100644 --- a/src/window.rs +++ b/src/window.rs @@ -8,6 +8,7 @@ use gtk::{gio, glib, glib::clone}; use crate::{ config, editor::tracks::TracksEditor, + empty_page::EmptyPage, library::{Library, LibraryQuery}, library_manager::LibraryManager, player::Player, @@ -259,8 +260,29 @@ impl Window { )); self.imp().player.set_library(&library); + + let is_empty = library.is_empty()?; self.imp().library.replace(Some(library)); - self.reset_view(); + + if is_empty { + let navigation = self.imp().navigation_view.get(); + let empty_page = EmptyPage::new( + self.imp().library.borrow().as_ref().unwrap(), + &self.imp().process_manager, + ); + + empty_page.connect_ready(clone!( + #[weak(rename_to = obj)] + self, + move |_| { + obj.reset_view(); + } + )); + + navigation.replace(&[empty_page.into()]); + } else { + self.reset_view(); + } Ok(()) }