From 14416d49d268a5b6240e891eb185eab83ab19ecd Mon Sep 17 00:00:00 2001 From: Elias Projahn Date: Mon, 3 Mar 2025 11:31:38 +0100 Subject: [PATCH] library: Add export functionality --- Cargo.lock | 293 ++++++++++++++++++++++++++++++++++++ Cargo.toml | 4 +- data/ui/library_manager.blp | 21 +++ data/ui/process_row.blp | 65 ++++++++ src/library.rs | 90 ++++++++++- src/library_manager.rs | 132 +++++++++++++++- src/main.rs | 5 +- src/process.rs | 67 +++++++++ src/process_manager.rs | 57 +++++++ src/process_row.rs | 128 ++++++++++++++++ src/window.rs | 47 +++++- 11 files changed, 893 insertions(+), 16 deletions(-) create mode 100644 data/ui/process_row.blp create mode 100644 src/process.rs create mode 100644 src/process_manager.rs create mode 100644 src/process_row.rs diff --git a/Cargo.lock b/Cargo.lock index 8ab11f8..4562ec6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,23 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -32,6 +49,15 @@ version = "1.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" +[[package]] +name = "arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -239,6 +265,25 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "cairo-rs" version = "0.20.7" @@ -268,6 +313,8 @@ version = "1.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8293772165d9345bdaaa39b45b2109591e63fe5e6fbc23c6ff930a048aa310b" dependencies = [ + "jobserver", + "libc", "shlex", ] @@ -307,6 +354,16 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -316,6 +373,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -331,6 +394,30 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -382,6 +469,12 @@ dependencies = [ "syn", ] +[[package]] +name = "deflate64" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b" + [[package]] name = "deranged" version = "0.3.11" @@ -391,6 +484,17 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "diesel" version = "2.2.6" @@ -444,6 +548,18 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -546,6 +662,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "flate2" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11faaf5a5236997af9848be0bef4db95824b1d534ebc64d0f0c6cf3e67bd38dc" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1084,6 +1210,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "iana-time-zone" version = "0.1.61" @@ -1123,6 +1258,15 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "itertools" version = "0.13.0" @@ -1138,6 +1282,15 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" +[[package]] +name = "jobserver" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" +dependencies = [ + "libc", +] + [[package]] name = "js-sys" version = "0.3.77" @@ -1220,12 +1373,28 @@ dependencies = [ "winapi", ] +[[package]] +name = "lockfree-object-pool" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9374ef4228402d4b7e403e5838cb880d9ee663314b0a900d5a6aabf0c213552e" + [[package]] name = "log" version = "0.4.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -1271,6 +1440,15 @@ dependencies = [ "quote", ] +[[package]] +name = "miniz_oxide" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" +dependencies = [ + "adler2", +] + [[package]] name = "mpris-server" version = "0.8.1" @@ -1295,6 +1473,7 @@ name = "musicus" version = "0.1.0" dependencies = [ "anyhow", + "async-channel", "chrono", "diesel", "diesel_migrations", @@ -1313,6 +1492,7 @@ dependencies = [ "serde_json", "tracing-subscriber", "uuid", + "zip", ] [[package]] @@ -1468,6 +1648,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1740,6 +1930,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + [[package]] name = "slab" version = "0.4.9" @@ -1767,6 +1963,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.96" @@ -2315,6 +2517,97 @@ dependencies = [ "syn", ] +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zip" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b280484c454e74e5fff658bbf7df8fdbe7a07c6b2de4a53def232c15ef138f3a" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "hmac", + "indexmap", + "lzma-rs", + "memchr", + "pbkdf2", + "rand", + "sha1", + "thiserror", + "time", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zopfli" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5019f391bac5cf252e93bbcc53d039ffd62c7bfb7c150414d61369afe57e946" +dependencies = [ + "bumpalo", + "crc32fast", + "lockfree-object-pool", + "log", + "once_cell", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3051792fbdc2e1e143244dc28c60f73d8470e93f3f9cbd0ead44da5ed802722" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.14+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fb060d4926e4ac3a3ad15d864e99ceb5f343c6b34f5bd6d81ae6ed417311be5" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zvariant" version = "4.2.0" diff --git a/Cargo.toml b/Cargo.toml index 99a32d4..31e4701 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] adw = { package = "libadwaita", version = "0.7", features = ["v1_6"] } anyhow = "1" +async-channel = "2.3" chrono = "0.4" diesel = { version = "2.2", features = ["chrono", "sqlite"] } diesel_migrations = "2.2" @@ -22,4 +23,5 @@ once_cell = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" tracing-subscriber = "0.3" -uuid = { version = "1", features = ["v4"] } \ No newline at end of file +uuid = { version = "1", features = ["v4"] } +zip = "2.2" \ No newline at end of file diff --git a/data/ui/library_manager.blp b/data/ui/library_manager.blp index 5ff9a54..e4fa36a 100644 --- a/data/ui/library_manager.blp +++ b/data/ui/library_manager.blp @@ -62,6 +62,27 @@ template $MusicusLibraryManager: Adw.NavigationPage { activated => $export_archive() swapped; } } + + Gtk.Label { + label: _("Progress"); + visible: bind process_list.visible; + xalign: 0; + margin-top: 24; + + styles [ + "heading", + ] + } + + Gtk.ListBox process_list { + selection-mode: none; + margin-top: 12; + visible: false; + + styles [ + "boxed-list-separate", + ] + } } } } diff --git a/data/ui/process_row.blp b/data/ui/process_row.blp new file mode 100644 index 0000000..65ba3a7 --- /dev/null +++ b/data/ui/process_row.blp @@ -0,0 +1,65 @@ +using Gtk 4.0; + +template $MusicusProcessRow: Gtk.ListBoxRow { + activatable: false; + + Gtk.Box { + orientation: vertical; + spacing: 12; + margin-top: 12; + margin-bottom: 12; + margin-start: 12; + margin-end: 12; + + Gtk.Box { + spacing: 12; + + Gtk.Box { + orientation: vertical; + hexpand: true; + + Gtk.Label description_label { + wrap: true; + xalign: 0.0; + } + + Gtk.Label success_label { + label: _("Process finished"); + wrap: true; + xalign: 0.0; + visible: false; + + styles [ + "success", + "caption" + ] + } + + Gtk.Label error_label { + wrap: true; + visible: false; + xalign: 0.0; + + styles [ + "error", + "caption" + ] + } + } + + Gtk.Button remove_button { + icon-name: "window-close-symbolic"; + tooltip-text: _("Remove from list"); + valign: start; + visible: false; + clicked => $remove() swapped; + + styles [ + "flat", + ] + } + } + + Gtk.ProgressBar progress_bar {} + } +} diff --git a/src/library.rs b/src/library.rs index 17b3a9f..50a0904 100644 --- a/src/library.rs +++ b/src/library.rs @@ -1,8 +1,10 @@ use std::{ cell::{OnceCell, RefCell}, ffi::OsString, - fs, + fs::{self, File}, + io::{BufWriter, Read, Write}, path::{Path, PathBuf}, + thread, }; use adw::{ @@ -14,6 +16,7 @@ use anyhow::{anyhow, Result}; use chrono::prelude::*; use diesel::{dsl::exists, prelude::*, QueryDsl, SqliteConnection}; use once_cell::sync::Lazy; +use zip::{write::SimpleFileOptions, ZipWriter}; use crate::{ db::{self, models::*, schema::*, tables, TranslatedString}, @@ -72,6 +75,39 @@ impl Library { .build() } + /// Import from a library archive. + pub fn import(&self, _path: impl AsRef) -> Result<()> { + Ok(()) + } + + /// Export the whole music library to an archive at `path`. If `path` already exists, it will + /// be overwritten. The work will be done in a background thread. + pub fn export( + &self, + path: impl AsRef, + ) -> Result> { + let mut binding = self.imp().connection.borrow_mut(); + let connection = &mut *binding.as_mut().unwrap(); + + let path = path.as_ref().to_owned(); + let library_folder = PathBuf::from(&self.folder()); + let tracks = tracks::table.load::(connection)?; + + let (sender, receiver) = async_channel::unbounded::(); + thread::spawn(move || { + if let Err(err) = sender.send_blocking(LibraryProcessMsg::Result(write_zip( + path, + library_folder, + tracks, + &sender, + ))) { + log::error!("Failed to send library action result: {err}"); + } + }); + + Ok(receiver) + } + pub fn search(&self, query: &LibraryQuery, search: &str) -> Result { let search = format!("%{}%", search); let mut binding = self.imp().connection.borrow_mut(); @@ -1582,3 +1618,55 @@ impl LibraryResults { && self.albums.is_empty() } } + +fn write_zip( + zip_path: impl AsRef, + library_folder: impl AsRef, + tracks: Vec, + sender: &async_channel::Sender, +) -> Result<()> { + let mut zip = zip::ZipWriter::new(BufWriter::new(fs::File::create(zip_path)?)); + + // Start with the database: + add_file_to_zip(&mut zip, &library_folder, "musicus.db")?; + + let n_tracks = tracks.len(); + + // Include all tracks that are part of the library. + for (index, track) in tracks.into_iter().enumerate() { + add_file_to_zip(&mut zip, &library_folder, &track.path)?; + + // Ignore if the reveiver has been dropped. + let _ = sender.send_blocking(LibraryProcessMsg::Progress( + (index + 1) as f64 / n_tracks as f64, + )); + } + + zip.finish()?; + + Ok(()) +} + +// TODO: Cross-platform paths? +fn add_file_to_zip( + zip: &mut ZipWriter>, + library_folder: impl AsRef, + library_path: &str, +) -> Result<()> { + let file_path = library_folder.as_ref().join(PathBuf::from(library_path)); + + let mut file = File::open(file_path)?; + let mut buffer = Vec::new(); + file.read_to_end(&mut buffer)?; + + zip.start_file(library_path, SimpleFileOptions::default())?; + zip.write_all(&buffer)?; + + Ok(()) +} + +#[derive(Debug)] +pub enum LibraryProcessMsg { + Progress(f64), + Result(Result<()>), +} diff --git a/src/library_manager.rs b/src/library_manager.rs index 6d3c1df..1b040c1 100644 --- a/src/library_manager.rs +++ b/src/library_manager.rs @@ -1,10 +1,14 @@ use std::{cell::OnceCell, ffi::OsStr, path::Path}; use adw::{prelude::*, subclass::prelude::*}; +use formatx::formatx; use gettextrs::gettext; -use gtk::glib; +use gtk::glib::{self, clone}; -use crate::{library::Library, window::Window}; +use crate::{ + library::Library, process::Process, process_manager::ProcessManager, process_row::ProcessRow, + window::Window, +}; mod imp { use super::*; @@ -14,9 +18,12 @@ mod imp { pub struct LibraryManager { pub navigation: OnceCell, pub library: OnceCell, + pub process_manager: OnceCell, #[template_child] pub library_path_row: TemplateChild, + #[template_child] + pub process_list: TemplateChild, } #[glib::object_subclass] @@ -47,16 +54,28 @@ glib::wrapper! { #[gtk::template_callbacks] impl LibraryManager { - pub fn new(navigation: &adw::NavigationView, library: &Library) -> Self { + pub fn new( + navigation: &adw::NavigationView, + library: &Library, + process_manager: &ProcessManager, + ) -> Self { let obj: Self = glib::Object::new(); - obj.imp().navigation.set(navigation.to_owned()).unwrap(); - obj.imp().library.set(library.to_owned()).unwrap(); + for process in process_manager.processes() { + obj.add_process(&process); + } if let Some(Some(filename)) = Path::new(&library.folder()).file_name().map(OsStr::to_str) { obj.imp().library_path_row.set_subtitle(filename); } + obj.imp().navigation.set(navigation.to_owned()).unwrap(); + obj.imp().library.set(library.to_owned()).unwrap(); + obj.imp() + .process_manager + .set(process_manager.to_owned()) + .unwrap(); + obj } @@ -85,8 +104,107 @@ impl LibraryManager { } #[template_callback] - fn import_archive(&self) {} + async fn import_archive(&self) { + let dialog = gtk::FileDialog::builder() + .title(gettext("Import from library archive")) + .modal(true) + .build(); + + let root = self.root(); + let window = root + .as_ref() + .and_then(|r| r.downcast_ref::()) + .and_then(|w| w.downcast_ref::()) + .unwrap(); + + match dialog.open_future(Some(window)).await { + Err(err) => { + if !err.matches(gtk::DialogError::Dismissed) { + log::error!("File selection failed: {err}"); + } + } + Ok(path) => { + if let Some(path) = path.path() { + if let Err(err) = self.imp().library.get().unwrap().import(path) { + log::error!("Failed to import library from archive: {err}"); + } + } + } + } + } #[template_callback] - fn export_archive(&self) {} + async fn export_archive(&self) { + let dialog = gtk::FileDialog::builder() + .title(gettext("Export library")) + .modal(true) + .build(); + + let root = self.root(); + let window = root + .as_ref() + .and_then(|r| r.downcast_ref::()) + .and_then(|w| w.downcast_ref::()) + .unwrap(); + + match dialog.save_future(Some(window)).await { + Err(err) => { + if !err.matches(gtk::DialogError::Dismissed) { + log::error!("File selection failed: {err}"); + } + } + Ok(path) => { + if let Some(path) = path.path() { + match self.imp().library.get().unwrap().export(&path) { + Ok(receiver) => { + let process = Process::new( + &formatx!( + gettext("Exporting music library to {}"), + path.file_name() + .map(|f| f.to_string_lossy().into_owned()) + .unwrap_or(gettext("archive")) + ) + .unwrap(), + receiver, + ); + + self.imp() + .process_manager + .get() + .unwrap() + .add_process(&process); + + self.add_process(&process); + } + Err(err) => log::error!("Failed to export 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/main.rs b/src/main.rs index 43ecac6..200d748 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,6 @@ mod application; mod config; mod db; mod editor; -mod search_page; mod library; mod library_manager; mod player; @@ -12,9 +11,13 @@ mod player_bar; mod playlist_item; mod playlist_page; mod playlist_tile; +mod process; +mod process_manager; +mod process_row; mod program; mod program_tile; mod recording_tile; +mod search_page; mod search_tag; mod selector; mod tag_tile; diff --git a/src/process.rs b/src/process.rs new file mode 100644 index 0000000..3341849 --- /dev/null +++ b/src/process.rs @@ -0,0 +1,67 @@ +use std::cell::{Cell, OnceCell, RefCell}; + +use gtk::{ + glib::{self, Properties}, + prelude::*, + subclass::prelude::*, +}; + +use crate::library::LibraryProcessMsg; + +mod imp { + use super::*; + + #[derive(Properties, Default, Debug)] + #[properties(wrapper_type = super::Process)] + pub struct Process { + #[property(get, construct_only)] + pub description: OnceCell, + #[property(get, set)] + pub progress: Cell, + #[property(get, set)] + pub finished: Cell, + #[property(get, set)] + pub error: RefCell>, + } + + #[glib::object_subclass] + impl ObjectSubclass for Process { + const NAME: &'static str = "MusicusProcess"; + type Type = super::Process; + } + + #[glib::derived_properties] + impl ObjectImpl for Process {} +} + +glib::wrapper! { + pub struct Process(ObjectSubclass); +} + +impl Process { + pub fn new(description: &str, receiver: async_channel::Receiver) -> Self { + let obj: Self = glib::Object::builder() + .property("description", description) + .build(); + + let obj_clone = obj.clone(); + glib::spawn_future_local(async move { + while let Ok(msg) = receiver.recv().await { + match msg { + LibraryProcessMsg::Progress(fraction) => { + obj_clone.set_progress(fraction); + } + LibraryProcessMsg::Result(result) => { + if let Err(err) = result { + obj_clone.set_error(err.to_string()); + } + + obj_clone.set_finished(true); + } + } + } + }); + + obj + } +} diff --git a/src/process_manager.rs b/src/process_manager.rs new file mode 100644 index 0000000..4acfcce --- /dev/null +++ b/src/process_manager.rs @@ -0,0 +1,57 @@ +use std::cell::RefCell; + +use gtk::{ + glib::{self}, + subclass::prelude::*, +}; + +use crate::process::Process; + +mod imp { + use super::*; + + #[derive(Debug, Default)] + pub struct ProcessManager { + pub processes: RefCell>, + } + + #[glib::object_subclass] + impl ObjectSubclass for ProcessManager { + const NAME: &'static str = "MusicusProcessManager"; + type Type = super::ProcessManager; + } + + impl ObjectImpl for ProcessManager {} +} + +glib::wrapper! { + pub struct ProcessManager(ObjectSubclass); +} + +impl ProcessManager { + pub fn new() -> Self { + glib::Object::new() + } + + pub fn add_process(&self, process: &Process) { + self.imp().processes.borrow_mut().push(process.to_owned()); + } + + pub fn processes(&self) -> Vec { + self.imp().processes.borrow().clone() + } + + pub fn any_ongoing(&self) -> bool { + self.imp().processes.borrow().iter().any(|p| !p.finished()) + } + + pub fn remove_process(&self, process: &Process) { + self.imp().processes.borrow_mut().retain(|p| p != process); + } +} + +impl Default for ProcessManager { + fn default() -> Self { + Self::new() + } +} diff --git a/src/process_row.rs b/src/process_row.rs new file mode 100644 index 0000000..8d68d66 --- /dev/null +++ b/src/process_row.rs @@ -0,0 +1,128 @@ +use std::cell::OnceCell; + +use formatx::formatx; +use gettextrs::gettext; +use gtk::{ + glib::{self, subclass::Signal, Properties}, + prelude::*, + subclass::prelude::*, +}; +use once_cell::sync::Lazy; + +use crate::process::Process; + +mod imp { + use super::*; + + #[derive(Properties, Debug, Default, gtk::CompositeTemplate)] + #[properties(wrapper_type = super::ProcessRow)] + #[template(file = "data/ui/process_row.blp")] + pub struct ProcessRow { + #[property(get, construct_only)] + pub process: OnceCell, + + #[template_child] + pub description_label: TemplateChild, + #[template_child] + pub success_label: TemplateChild, + #[template_child] + pub error_label: TemplateChild, + #[template_child] + pub remove_button: TemplateChild, + #[template_child] + pub progress_bar: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for ProcessRow { + const NAME: &'static str = "MusicusProcessRow"; + type Type = super::ProcessRow; + type ParentType = gtk::ListBoxRow; + + 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(); + } + } + + #[glib::derived_properties] + impl ObjectImpl for ProcessRow { + fn signals() -> &'static [Signal] { + static SIGNALS: Lazy> = + Lazy::new(|| vec![Signal::builder("remove").build()]); + + SIGNALS.as_ref() + } + + fn constructed(&self) { + self.parent_constructed(); + + self.description_label + .set_label(&self.obj().process().description()); + + self.obj() + .process() + .bind_property("progress", &*self.progress_bar, "fraction") + .build(); + + let obj = self.obj().to_owned(); + self.obj().process().connect_finished_notify(move |_| { + obj.update(); + }); + + self.obj().update(); + } + } + + impl WidgetImpl for ProcessRow {} + impl ListBoxRowImpl for ProcessRow {} +} + +glib::wrapper! { + pub struct ProcessRow(ObjectSubclass) + @extends gtk::Widget, gtk::ListBoxRow; +} + +#[gtk::template_callbacks] +impl ProcessRow { + pub fn new(process: &Process) -> Self { + glib::Object::builder().property("process", process).build() + } + + pub fn connect_remove(&self, f: F) -> glib::SignalHandlerId { + self.connect_local("remove", true, move |values| { + let obj = values[0].get::().unwrap(); + f(&obj); + None + }) + } + + #[template_callback] + fn remove(&self) { + self.emit_by_name::<()>("remove", &[]); + } + + fn update(&self) { + if !self.process().finished() { + self.imp() + .progress_bar + .set_fraction(self.process().progress()); + } else { + self.imp().progress_bar.set_visible(false); + self.imp().remove_button.set_visible(true); + + if let Some(error) = self.process().error() { + self.imp() + .error_label + .set_label(&formatx!(gettext("Process failed: {}"), error).unwrap()); + self.imp().error_label.set_visible(true); + } else { + self.imp().success_label.set_visible(true); + } + } + } +} diff --git a/src/window.rs b/src/window.rs index 8f34e28..6eeec93 100644 --- a/src/window.rs +++ b/src/window.rs @@ -11,11 +11,15 @@ use crate::{ player::Player, player_bar::PlayerBar, playlist_page::PlaylistPage, + process_manager::ProcessManager, search_page::SearchPage, welcome_page::WelcomePage, }; mod imp { + use adw::prelude::{AlertDialogExt, AlertDialogExtManual}; + use gettextrs::gettext; + use super::*; #[derive(Debug, Default, gtk::CompositeTemplate)] @@ -23,6 +27,7 @@ mod imp { pub struct Window { pub library: RefCell>, pub player: Player, + pub process_manager: ProcessManager, #[template_child] pub stack: TemplateChild, @@ -72,8 +77,11 @@ mod imp { let library_action = gio::ActionEntry::builder("library") .activate(move |_, _, _| { if let Some(library) = &*obj.imp().library.borrow() { - let library_manager = - LibraryManager::new(&obj.imp().navigation_view, library); + let library_manager = LibraryManager::new( + &obj.imp().navigation_view, + library, + &obj.imp().process_manager, + ); obj.imp().navigation_view.push(&library_manager); } }) @@ -135,11 +143,38 @@ mod imp { impl WindowImpl for Window { fn close_request(&self) -> glib::signal::Propagation { - if let Err(err) = self.obj().save_window_state() { - log::warn!("Failed to save window state: {err}"); - } + if self.process_manager.any_ongoing() { + let dialog = adw::AlertDialog::builder() + .heading(&gettext("Close window?")) + .body(&gettext( + "There are ongoing processes that will be canceled.", + )) + .build(); - glib::signal::Propagation::Proceed + dialog.add_responses(&[ + ("cancel", &gettext("Keep open")), + ("close", &gettext("Close window")), + ]); + + dialog.set_response_appearance("close", adw::ResponseAppearance::Destructive); + dialog.set_close_response("cancel"); + dialog.set_default_response(Some("cancel")); + + let obj = self.obj().to_owned(); + glib::spawn_future_local(async move { + if dialog.choose_future(&obj).await == "close" { + obj.destroy(); + } + }); + + glib::signal::Propagation::Stop + } else { + if let Err(err) = self.obj().save_window_state() { + log::warn!("Failed to save window state: {err}"); + } + + glib::signal::Propagation::Proceed + } } }