From b097f18fbc9756753ef30646c8e31d5f2b3fbaf7 Mon Sep 17 00:00:00 2001 From: mtkennerly Date: Mon, 9 Dec 2024 20:18:49 -0500 Subject: [PATCH] Add support for audio files --- Cargo.lock | 415 +++++++++++++++++- Cargo.toml | 1 + README.md | 3 +- .../com.mtkennerly.madamiru.metainfo.xml | 3 + lang/en-US.ftl | 1 + src/gui/app.rs | 40 ++ src/gui/common.rs | 1 + src/gui/grid.rs | 6 + src/gui/icon.rs | 2 + src/gui/player.rs | 376 +++++++++++++++- src/lang.rs | 4 + src/media.rs | 5 + src/path.rs | 4 + src/resource/config.rs | 8 + 14 files changed, 856 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9132c33..bc105aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -78,6 +78,28 @@ version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45862d1c77f2228b9e10bc609d5bc203d86ebc9b87ad8d5d5167a6c9abf739d9" +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.6.0", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "android-activity" version = "0.6.0" @@ -92,7 +114,7 @@ dependencies = [ "jni-sys", "libc", "log", - "ndk", + "ndk 0.9.0", "ndk-context", "ndk-sys 0.6.0+11769913", "num_enum", @@ -439,6 +461,24 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bindgen" +version = "0.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" +dependencies = [ + "bitflags 2.6.0", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "syn 2.0.87", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -635,6 +675,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfb" version = "0.7.3" @@ -708,6 +757,17 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading 0.8.5", +] + [[package]] name = "clap" version = "4.5.21" @@ -943,6 +1003,26 @@ dependencies = [ "libc", ] +[[package]] +name = "coreaudio-rs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + +[[package]] +name = "coreaudio-sys" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ce857aa0b77d77287acc1ac3e37a05a8c95a2af3647d23b15f263bdaeb7562b" +dependencies = [ + "bindgen", +] + [[package]] name = "cosmic-text" version = "0.12.1" @@ -966,6 +1046,29 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa", + "core-foundation-sys", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk 0.8.0", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.54.0", +] + [[package]] name = "cpufeatures" version = "0.2.15" @@ -1082,6 +1185,12 @@ dependencies = [ "zbus", ] +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + [[package]] name = "data-url" version = "0.3.1" @@ -1263,6 +1372,15 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.0" @@ -1373,6 +1491,12 @@ dependencies = [ "zune-inflate", ] +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + [[package]] name = "fast-srgb8" version = "1.0.0" @@ -1860,6 +1984,12 @@ dependencies = [ "system-deps 7.0.3", ] +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + [[package]] name = "globetter" version = "0.2.0" @@ -2895,6 +3025,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "lebe" version = "0.5.2" @@ -3000,6 +3136,15 @@ version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +[[package]] +name = "mach2" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b955cdeb2a02b9117f121ce63aa52d08ade45de53e48fe6a38b39c10f6f709" +dependencies = [ + "libc", +] + [[package]] name = "madamiru" version = "0.1.0" @@ -3029,6 +3174,7 @@ dependencies = [ "regex", "reqwest", "rfd", + "rodio", "schemars", "semver 1.0.23", "serde", @@ -3099,6 +3245,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.0" @@ -3153,6 +3305,20 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.6.0", + "jni-sys", + "log", + "ndk-sys 0.5.0+25.2.9519653", + "num_enum", + "thiserror 1.0.69", +] + [[package]] name = "ndk" version = "0.9.0" @@ -3205,6 +3371,16 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "normpath" version = "1.3.0" @@ -3223,6 +3399,17 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -3513,6 +3700,29 @@ dependencies = [ "memchr", ] +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni", + "ndk 0.8.0", + "ndk-context", + "num-derive", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + [[package]] name = "once_cell" version = "1.20.2" @@ -4252,6 +4462,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rodio" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ceb6607dd738c99bc8cb28eff249b7cd5c8ec88b9db96c0608c1480d140fb1" +dependencies = [ + "cpal", + "symphonia", +] + [[package]] name = "roxmltree" version = "0.20.0" @@ -4800,6 +5020,162 @@ dependencies = [ "zeno", ] +[[package]] +name = "symphonia" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "815c942ae7ee74737bb00f965fa5b5a2ac2ce7b6c01c0cc169bbeaf7abd5f5a9" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-adpcm", + "symphonia-codec-alac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-isomp4", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72e34f34298a7308d4397a6c7fbf5b84c5d491231ce3dd379707ba673ab3bd97" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c01c2aae70f0f1fb096b6f0ff112a930b1fb3626178fba3ae68b09dce71706d4" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdbf25b545ad0d3ee3e891ea643ad115aff4ca92f6aec472086b957a58522f70" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-adpcm" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c94e1feac3327cd616e973d5be69ad36b3945f16b06f19c6773fc3ac0b426a0f" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-alac" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d8a6666649a08412906476a8b0efd9b9733e241180189e9f92b09c08d0e38f3" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f395a67057c2ebc5e84d7bb1be71cce1a7ba99f64e0f0f0e303a03f79116f89b" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a98765fb46a0a6732b007f7e2870c2129b6f78d87db7987e6533c8f164a9f30" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "798306779e3dc7d5231bd5691f5a813496dc79d3f56bf82e25789f2094e022c3" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abfdf178d697e50ce1e5d9b982ba1b94c47218e03ec35022d9f0e071a16dc844" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f7be232f962f937f4b7115cbe62c330929345434c834359425e043bfd15f50" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc622b9841a10089c5b18e99eb904f4341615d5aa55bbf4eedde1be721a4023c" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "484472580fa49991afda5f6550ece662237b00c6f562c7d9638d1b086ed010fe" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + [[package]] name = "syn" version = "1.0.109" @@ -5902,6 +6278,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.58.0" @@ -5921,6 +6307,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.58.0" @@ -5929,7 +6325,7 @@ checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" dependencies = [ "windows-implement", "windows-interface", - "windows-result", + "windows-result 0.2.0", "windows-strings", "windows-targets 0.52.6", ] @@ -5962,11 +6358,20 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" dependencies = [ - "windows-result", + "windows-result 0.2.0", "windows-strings", "windows-targets 0.52.6", ] +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.2.0" @@ -5982,7 +6387,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" dependencies = [ - "windows-result", + "windows-result 0.2.0", "windows-targets 0.52.6", ] @@ -6222,7 +6627,7 @@ dependencies = [ "js-sys", "libc", "memmap2", - "ndk", + "ndk 0.9.0", "objc2", "objc2-app-kit", "objc2-foundation", diff --git a/Cargo.toml b/Cargo.toml index 8af0a2d..b9aa37a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ realia = "0.2.0" regex = "1.10.6" reqwest = { version = "0.12.7", features = ["blocking", "gzip", "rustls-tls"], default-features = false } rfd = { version = "0.15.0", features = ["gtk3"], default-features = false } +rodio = { version = "0.20.1", features = ["symphonia-aac", "symphonia-aiff", "symphonia-alac", "symphonia-flac", "symphonia-isomp4", "symphonia-mp3", "symphonia-vorbis", "symphonia-wav"], default-features = false } schemars = { version = "0.8.21", features = ["chrono"] } semver = { version = "1.0.23", features = ["serde"] } serde = { version = "1.0.210", features = ["derive"] } diff --git a/README.md b/README.md index 41a2e8a..77e25d2 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ # Logo Madamiru Madamiru is a cross-platform media player written in [Rust](https://www.rust-lang.org) -that can automatically shuffle multiple videos and images at once in a grid layout. +that can automatically shuffle multiple videos, images, and songs at once in a grid layout. ## Features * Video formats: AVI, M4V, MKV, MOV, MP4, WebM * Image formats: BMP, GIF, ICO, JPEG, PNG, TIFF, SVG, WebP +* Audio formats: FLAC, M4A, MP3, WAV * Subtitles are supported within MKV (but not as separate files) If you'd like to help translate Madamiru into other languages, diff --git a/assets/linux/com.mtkennerly.madamiru.metainfo.xml b/assets/linux/com.mtkennerly.madamiru.metainfo.xml index 6b378d4..2c9c8c1 100644 --- a/assets/linux/com.mtkennerly.madamiru.metainfo.xml +++ b/assets/linux/com.mtkennerly.madamiru.metainfo.xml @@ -22,6 +22,9 @@ images media multimedia + music + song + songs video videos diff --git a/lang/en-US.ftl b/lang/en-US.ftl index cf49785..c0f6864 100644 --- a/lang/en-US.ftl +++ b/lang/en-US.ftl @@ -57,6 +57,7 @@ tell-playlist-has-unsaved-changes = Your playlist has unsaved changes. tell-playlist-is-invalid = The playlist file is invalid. tell-new-version-available = An application update is available: {$version}. tell-no-media-found = No more media matching your filter. +tell-unable-to-determine-media-duration = Unable to determine media duration. tell-unable-to-open-directory = Unable to open directory. tell-unable-to-open-url = Unable to open URL. tell-unable-to-save-playlist = Unable to save playlist. diff --git a/src/gui/app.rs b/src/gui/app.rs index e299172..230f86b 100644 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -52,6 +52,7 @@ pub struct App { viewing_pane_controls: Option, playlist_path: Option, playlist_dirty: bool, + default_audio_output_device: Option, } impl App { @@ -204,6 +205,7 @@ impl App { viewing_pane_controls: None, playlist_path, playlist_dirty, + default_audio_output_device: Self::get_audio_device(), }, Task::batch(commands), ) @@ -394,6 +396,34 @@ impl App { } } + fn get_audio_device() -> Option { + use rodio::cpal::traits::{DeviceTrait, HostTrait}; + let host = rodio::cpal::default_host(); + host.default_output_device().and_then(|d| d.name().ok()) + } + + /// Rodio/CPAL don't automatically follow changes to the default output device, + /// so we need to reload the streams if that happens. + /// More info: + /// * https://github.com/RustAudio/cpal/issues/740 + /// * https://github.com/RustAudio/rodio/issues/327 + /// * https://github.com/RustAudio/rodio/issues/544 + fn did_audio_device_change(&mut self) -> bool { + let device = Self::get_audio_device(); + + if self.default_audio_output_device != device { + log::info!( + "Default audio device changed: {:?} -> {:?}", + self.default_audio_output_device.as_ref(), + device.as_ref() + ); + self.default_audio_output_device = device; + true + } else { + false + } + } + pub fn update(&mut self, message: Message) -> Task { match message { Message::Ignore => Task::none(), @@ -412,11 +442,20 @@ impl App { Message::Tick(instant) => { let elapsed = instant - self.last_tick; self.last_tick = instant; + for (_id, grid) in self.grids.iter_mut() { grid.tick(elapsed, &mut self.media, &self.config.playback); } Task::none() } + Message::CheckAudio => { + if self.did_audio_device_change() { + for (_id, grid) in self.grids.iter_mut() { + grid.reload_audio(&self.config.playback); + } + } + Task::none() + } Message::Save => { self.save(); Task::none() @@ -1027,6 +1066,7 @@ impl App { _ => None, }), iced::time::every(Duration::from_millis(100)).map(Message::Tick), + iced::time::every(Duration::from_millis(1000)).map(|_| Message::CheckAudio), iced::time::every(Duration::from_secs(60 * 10)).map(|_| Message::FindMedia), ]; diff --git a/src/gui/common.rs b/src/gui/common.rs index 1bd7fd4..bc25032 100644 --- a/src/gui/common.rs +++ b/src/gui/common.rs @@ -38,6 +38,7 @@ pub enum Message { force: bool, }, Tick(Instant), + CheckAudio, Save, CloseModal, Config { diff --git a/src/gui/grid.rs b/src/gui/grid.rs index 9fb5e6b..5cecbb1 100644 --- a/src/gui/grid.rs +++ b/src/gui/grid.rs @@ -135,6 +135,12 @@ impl Grid { } } + pub fn reload_audio(&mut self, playback: &Playback) { + for player in &mut self.players { + player.reload_audio(playback); + } + } + pub fn remove(&mut self, id: player::Id) { self.players.remove(id.0); } diff --git a/src/gui/icon.rs b/src/gui/icon.rs index bd9020c..2fa9704 100644 --- a/src/gui/icon.rs +++ b/src/gui/icon.rs @@ -19,6 +19,7 @@ pub enum Icon { Loop, MoreVert, Movie, + Music, Mute, OpenInBrowser, OpenInNew, @@ -53,6 +54,7 @@ impl Icon { Self::Loop => '\u{e040}', Self::MoreVert => '\u{E5D4}', Self::Movie => '\u{e02c}', + Self::Music => '\u{e405}', Self::Mute => '\u{e04f}', Self::OpenInBrowser => '\u{e89d}', Self::OpenInNew => '\u{E89E}', diff --git a/src/gui/player.rs b/src/gui/player.rs index 30082be..971beac 100644 --- a/src/gui/player.rs +++ b/src/gui/player.rs @@ -135,6 +135,7 @@ pub struct Id(pub usize); #[derive(Debug)] pub enum Error { + Audio(String), Image(String), Io(std::io::Error), Path(crate::path::StrictPathError), @@ -145,6 +146,7 @@ pub enum Error { impl Error { pub fn message(&self) -> String { match self { + Self::Audio(error) => error.to_string(), Self::Image(error) => error.to_string(), Self::Io(error) => error.to_string(), Self::Path(error) => format!("{error:?}"), @@ -260,6 +262,18 @@ pub enum Player { hovered: bool, need_play_on_focus: bool, }, + Audio { + media: Media, + // We must hold the stream for as long as the sink. + #[allow(unused)] + stream: rodio::OutputStream, + sink: rodio::Sink, + duration: Duration, + looping: bool, + dragging: bool, + hovered: bool, + need_play_on_focus: bool, + }, Video { media: Media, video: Video, @@ -329,6 +343,23 @@ impl Player { hovered: false, }), }, + Media::Audio { path } => match Self::load_audio(path, playback, Duration::from_millis(0)) { + Ok((stream, sink, duration)) => Ok(Self::Audio { + media: media.clone(), + stream, + sink, + duration, + looping: false, + dragging: false, + hovered: false, + need_play_on_focus: false, + }), + Err(e) => Err(Self::Error { + media: media.clone(), + message: e.message(), + hovered: false, + }), + }, Media::Video { path } => match Self::load_video(path) { Ok(mut video) => { video.set_paused(playback.paused); @@ -373,6 +404,42 @@ impl Player { Ok((frames, handle_path)) } + fn load_audio( + source: &StrictPath, + playback: &Playback, + position: Duration, + ) -> Result<(rodio::OutputStream, rodio::Sink, Duration), Error> { + use rodio::Source; + + let (stream, stream_handle) = rodio::OutputStream::try_default().map_err(|e| Error::Audio(e.to_string()))?; + let sink = rodio::Sink::try_new(&stream_handle).map_err(|e| Error::Audio(e.to_string()))?; + + if playback.paused { + sink.pause(); + } else { + sink.play(); + } + + if playback.muted { + sink.set_volume(0.0); + } else { + sink.set_volume(1.0); + } + + let _ = sink.try_seek(position); + + let file = source.open_buffered()?; + let source = rodio::Decoder::new(file) + .map_err(|e| Error::Audio(e.to_string()))? + .track_position(); + let Some(duration) = source.total_duration() else { + return Err(Error::Audio(lang::tell::unable_to_determine_media_duration())); + }; + sink.append(source); + + Ok((stream, sink, duration)) + } + pub fn swap_media(&mut self, media: &Media, playback: &Playback) -> Result<(), ()> { let playback = playback.with_muted_maybe(self.is_muted()); let hovered = self.is_hovered(); @@ -410,6 +477,9 @@ impl Player { Self::Gif { position, .. } => { *position = 0.0; } + Self::Audio { sink, .. } => { + let _ = sink.try_seek(Duration::from_millis(0)); + } Self::Video { video, position, .. } => { *position = 0.0; seek_video(video, *position); @@ -425,6 +495,7 @@ impl Player { Self::Image { media, .. } => Some(media), Self::Svg { media, .. } => Some(media), Self::Gif { media, .. } => Some(media), + Self::Audio { media, .. } => Some(media), Self::Video { media, .. } => Some(media), } } @@ -436,6 +507,7 @@ impl Player { Self::Image { .. } => false, Self::Svg { .. } => false, Self::Gif { .. } => false, + Self::Audio { .. } => false, Self::Video { .. } => false, } } @@ -447,6 +519,7 @@ impl Player { Self::Image { paused, .. } => Some(*paused), Self::Svg { paused, .. } => Some(*paused), Self::Gif { paused, .. } => Some(*paused), + Self::Audio { sink, .. } => Some(sink.is_paused()), Self::Video { video, .. } => Some(video.paused()), } } @@ -458,6 +531,7 @@ impl Player { Self::Image { .. } => None, Self::Svg { .. } => None, Self::Gif { .. } => None, + Self::Audio { sink, .. } => Some(sink.volume() == 0.0), Self::Video { video, .. } => Some(video.muted()), } } @@ -469,6 +543,7 @@ impl Player { Self::Image { .. } => false, Self::Svg { .. } => false, Self::Gif { .. } => false, + Self::Audio { .. } => true, Self::Video { .. } => true, } } @@ -480,6 +555,7 @@ impl Player { Self::Image { hovered, .. } => Some(*hovered), Self::Svg { hovered, .. } => Some(*hovered), Self::Gif { hovered, .. } => Some(*hovered), + Self::Audio { hovered, .. } => Some(*hovered), Self::Video { hovered, .. } => Some(*hovered), } } @@ -499,6 +575,9 @@ impl Player { Self::Gif { hovered, .. } => { *hovered = flag; } + Self::Audio { hovered, .. } => { + *hovered = flag; + } Self::Video { hovered, .. } => { *hovered = flag; } @@ -578,10 +657,68 @@ impl Player { None } } + Self::Audio { + sink, + duration, + looping, + .. + } => { + if sink.get_pos() >= *duration { + if *looping { + let _ = sink.try_seek(Duration::from_millis(0)); + sink.play(); + } else { + return Some(Update::EndOfStream); + } + } + None + } Self::Video { .. } => None, } } + pub fn reload_audio(&mut self, playback: &Playback) { + match self { + Self::Idle => {} + Self::Error { .. } => {} + Self::Image { .. } => {} + Self::Svg { .. } => {} + Self::Gif { .. } => {} + Self::Audio { + media, + stream: _, + sink, + duration: _, + looping, + dragging, + hovered, + need_play_on_focus, + } => { + let playback = playback.with_paused(sink.is_paused()).with_muted(sink.volume() == 0.0); + let position = sink.get_pos(); + + *self = match Self::load_audio(media.path(), &playback, position) { + Ok((stream, sink, duration)) => Self::Audio { + media: media.clone(), + stream, + sink, + duration, + looping: *looping, + dragging: *dragging, + hovered: *hovered, + need_play_on_focus: *need_play_on_focus, + }, + Err(e) => Self::Error { + media: media.clone(), + message: e.message(), + hovered: false, + }, + }; + } + Self::Video { .. } => {} + } + } + fn overlay(&self, viewport: iced::Size, obscured: bool, hovered: bool) -> Overlay { let show = !obscured && hovered; @@ -594,13 +731,15 @@ impl Player { bottom_controls: false, timestamps: false, }, - Self::Image { .. } | Self::Svg { .. } | Self::Gif { .. } | Self::Video { .. } => Overlay { - show, - center_controls: show && viewport.height > 100.0 && viewport.width > 150.0, - top_controls: show && viewport.width > 100.0, - bottom_controls: show && viewport.height > 40.0, - timestamps: show && viewport.height > 60.0 && viewport.width > 150.0, - }, + Self::Image { .. } | Self::Svg { .. } | Self::Gif { .. } | Self::Audio { .. } | Self::Video { .. } => { + Overlay { + show, + center_controls: show && viewport.height > 100.0 && viewport.width > 150.0, + top_controls: show && viewport.width > 100.0, + bottom_controls: show && viewport.height > 40.0, + timestamps: show && viewport.height > 60.0 && viewport.width > 150.0, + } + } } } @@ -798,6 +937,77 @@ impl Player { None } }, + Self::Audio { + sink, + duration, + looping, + dragging, + hovered, + need_play_on_focus, + .. + } => match event { + Event::SetPause(flag) => { + if flag { + sink.pause(); + } else { + sink.play(); + } + Some(Update::PauseChanged) + } + Event::SetLoop(flag) => { + *looping = flag; + None + } + Event::SetMute(flag) => { + if flag { + sink.set_volume(0.0); + } else { + sink.set_volume(1.0); + } + Some(Update::MuteChanged) + } + Event::Seek(offset) => { + *dragging = true; + let _ = sink.try_seek(Duration::from_secs_f64(offset)); + None + } + Event::SeekStop => { + *dragging = false; + None + } + Event::SeekRandom => { + use rand::Rng; + let position = rand::thread_rng().gen_range(0.0..duration.as_secs_f64()); + let _ = sink.try_seek(Duration::from_secs_f64(position)); + None + } + Event::EndOfStream => (!*looping).then_some(Update::EndOfStream), + Event::NewFrame => None, + Event::MouseEnter => { + *hovered = true; + None + } + Event::MouseExit => { + *hovered = false; + None + } + Event::Refresh => Some(Update::Refresh), + Event::Close => Some(Update::Close), + Event::WindowFocused => { + if *need_play_on_focus { + sink.play(); + *need_play_on_focus = false; + } + None + } + Event::WindowUnfocused => { + if playback.pause_on_unfocus { + sink.pause(); + *need_play_on_focus = true; + } + None + } + }, Self::Video { video, position, @@ -1403,6 +1613,158 @@ impl Player { ) .into() } + Self::Audio { + media, + sink, + duration, + looping, + dragging, + hovered, + .. + } => { + let overlay = self.overlay(viewport, obscured, *hovered || *dragging); + + Stack::new() + .push_maybe( + (!overlay.show).then_some( + Container::new(Icon::Music.max_control()) + .align_x(Alignment::Center) + .align_y(Alignment::Center) + .width(Length::Fill) + .height(Length::Fill), + ), + ) + .push_maybe( + overlay.show.then_some( + Container::new("") + .center(Length::Fill) + .class(style::Container::ModalBackground), + ), + ) + .push_maybe( + overlay.top_controls.then_some( + Container::new( + Row::new() + .push( + button::icon(Icon::Music) + .on_press(Message::OpenFile { + path: media.path().clone(), + }) + .tooltip(media.path().render()), + ) + .push(horizontal_space()) + .push( + button::icon(Icon::Refresh) + .on_press(Message::Player { + grid_id, + player_id, + event: Event::Refresh, + }) + .tooltip(lang::action::shuffle_media()), + ) + .push( + button::icon(Icon::Close) + .on_press(Message::Player { + grid_id, + player_id, + event: Event::Close, + }) + .tooltip(lang::action::close()), + ), + ) + .align_top(Length::Fill) + .width(Length::Fill), + ), + ) + .push_maybe( + overlay.center_controls.then_some( + Container::new( + Row::new() + .spacing(5) + .align_y(alignment::Vertical::Center) + .padding(padding::all(10.0)) + .push({ + let muted = sink.volume() == 0.0; + + button::icon(if muted { Icon::Mute } else { Icon::VolumeHigh }) + .on_press(Message::Player { + grid_id, + player_id, + event: Event::SetMute(!muted), + }) + .tooltip(if muted { + lang::action::unmute() + } else { + lang::action::mute() + }) + }) + .push({ + let paused = sink.is_paused(); + + button::big_icon(if paused { Icon::Play } else { Icon::Pause }) + .on_press(Message::Player { + grid_id, + player_id, + event: Event::SetPause(!paused), + }) + .tooltip(if paused { + lang::action::play() + } else { + lang::action::pause() + }) + }) + .push( + button::icon(if *looping { Icon::Loop } else { Icon::Shuffle }) + .on_press(Message::Player { + grid_id, + player_id, + event: Event::SetLoop(!*looping), + }) + .tooltip(if *looping { + lang::tell::player_will_loop() + } else { + lang::tell::player_will_shuffle() + }), + ), + ) + .center(Length::Fill), + ), + ) + .push_maybe( + overlay.bottom_controls.then_some( + Container::new( + Column::new() + .padding(padding::left(10).right(10).bottom(5)) + .push(vertical_space()) + .push_maybe( + overlay + .timestamps + .then_some(timestamps(sink.get_pos().as_secs_f64(), *duration)), + ) + .push(Container::new( + iced::widget::slider( + 0.0..=duration.as_secs_f64(), + sink.get_pos().as_secs_f64(), + move |x| Message::Player { + grid_id, + player_id, + event: Event::Seek(x), + }, + ) + .step(0.1) + .on_release(Message::Player { + grid_id, + player_id, + event: Event::SeekStop, + }), + )), + ) + .align_bottom(Length::Fill) + .center_x(Length::Fill), + ), + ) + .into() + } Self::Video { media, video, diff --git a/src/lang.rs b/src/lang.rs index f65f506..e42c64e 100644 --- a/src/lang.rs +++ b/src/lang.rs @@ -419,6 +419,10 @@ pub mod tell { translate("tell-no-media-found") } + pub fn unable_to_determine_media_duration() -> String { + translate("tell-unable-to-determine-media-duration") + } + pub fn unable_to_open_directory() -> String { translate("tell-unable-to-open-directory") } diff --git a/src/media.rs b/src/media.rs index 96838da..c4bb522 100644 --- a/src/media.rs +++ b/src/media.rs @@ -115,6 +115,7 @@ pub enum Media { Image { path: StrictPath }, Svg { path: StrictPath }, Gif { path: StrictPath }, + Audio { path: StrictPath }, Video { path: StrictPath }, } @@ -124,6 +125,7 @@ impl Media { Self::Image { path } => path, Self::Svg { path } => path, Self::Gif { path } => path, + Self::Audio { path } => path, Self::Video { path } => path, } } @@ -146,6 +148,9 @@ impl Media { match info.mime_type() { "video/mp4" | "video/quicktime" | "video/webm" | "video/x-m4v" | "video/x-matroska" | "video/x-msvideo" => Some(Self::Video { path: path.clone() }), + "audio/mpeg" | "audio/m4a" | "audio/x-flac" | "audio/x-wav" => { + Some(Self::Audio { path: path.clone() }) + } "image/bmp" | "image/jpeg" | "image/png" diff --git a/src/path.rs b/src/path.rs index 3119535..dbca4b2 100644 --- a/src/path.rs +++ b/src/path.rs @@ -524,6 +524,10 @@ impl StrictPath { std::fs::File::open(self.as_std_path_buf()?) } + pub fn open_buffered(&self) -> Result, std::io::Error> { + Ok(std::io::BufReader::new(self.open()?)) + } + pub fn write_with_content(&self, content: &str) -> std::io::Result<()> { std::fs::write(self.as_std_path_buf()?, content.as_bytes()) } diff --git a/src/resource/config.rs b/src/resource/config.rs index a2646cd..6966bd0 100644 --- a/src/resource/config.rs +++ b/src/resource/config.rs @@ -95,6 +95,14 @@ pub struct Playback { } impl Playback { + pub fn with_paused(&self, paused: bool) -> Self { + Self { paused, ..self.clone() } + } + + pub fn with_muted(&self, muted: bool) -> Self { + Self { muted, ..self.clone() } + } + pub fn with_muted_maybe(&self, muted: Option) -> Self { Self { muted: muted.unwrap_or(self.muted),