diff --git a/CLAUDE.md b/CLAUDE.md index a4aefdd59..36cf69b45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,15 +98,15 @@ One line each. **The reasoning, the failure mode and the exceptions are in [`doc Names in `commands/`, `audio/` and `src/components/` are predictable — read the file. For anything that isn't obvious from the name, these are the deep dives: -| Area | Doc | Covers | -| ------------ | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Playback | [`playback.md`](docs/features/playback.md) | decoder (incl. Opus via libopus) + DSD pipeline, native DSD via DoP, crossfade (static / smart / dynamic), gapless, ReplayGain (track / album / auto), EQ, speed, network pre-load, exclusive output (WASAPI / ALSA / CoreAudio), spectrum visualizer, A-B loop, queue + album shuffle, output test tone, files played without importing them | -| Library | [`library.md`](docs/features/library.md) | scanner + watcher, folder covers, local artist images, search + filters, tag editor, ratings, duplicates, import, history, multi-artist split | -| Playlists | [`playlists.md`](docs/features/playlists.md) · [`smart-playlists.md`](docs/features/smart-playlists.md) | CRUD, sorting, auto-covers, M3U, Daily Mix + On Repeat generators, rule tree | -| Integrations | [`integrations.md`](docs/features/integrations.md) | Deezer, Last.fm, TheAudioDB, lyrics providers + editor, artist overrides, Discord RPC, OS notifications, scrobbling | -| Plugins | [`plugins.md`](docs/features/plugins.md) | WASM host + sandbox, store, options, `source` / `metadata` / `ui` / `canvas` worlds, Web Radio + offline catalogue | -| UI & UX | [`ui.md`](docs/features/ui.md) | layout, 6 skins × 14 themes + custom / cover accent, suggestion of the day on Home, immersive view, Canvas, cover slideshow, artist hero, mini-player, desktop lyrics, Wrapped, profiles, onboarding, settings, updater, backups | -| LAN servers | [`dlna.md`](docs/features/dlna.md) · [`mpd.md`](docs/features/mpd.md) | opt-in MediaServer and MPD control surface | +| Area | Doc | Covers | +| ------------ | ------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Playback | [`playback.md`](docs/features/playback.md) | decoder (incl. Opus via libopus) + DSD pipeline, native DSD via DoP, crossfade (static / smart / dynamic), gapless, ReplayGain (track / album / auto), EQ, speed, network pre-load, exclusive output (WASAPI / ALSA / CoreAudio), spectrum visualizer, A-B loop, queue + album shuffle, output test tone, files played without importing them or opened from the system | +| Library | [`library.md`](docs/features/library.md) | scanner + watcher, folder covers, local artist images, search + filters, tag editor, ratings, duplicates, import, history, multi-artist split | +| Playlists | [`playlists.md`](docs/features/playlists.md) · [`smart-playlists.md`](docs/features/smart-playlists.md) | CRUD, sorting, auto-covers, M3U, Daily Mix + On Repeat generators, rule tree | +| Integrations | [`integrations.md`](docs/features/integrations.md) | Deezer, Last.fm, TheAudioDB, lyrics providers + editor, artist overrides, Discord RPC, OS notifications, scrobbling | +| Plugins | [`plugins.md`](docs/features/plugins.md) | WASM host + sandbox, store, options, `source` / `metadata` / `ui` / `canvas` worlds, Web Radio + offline catalogue | +| UI & UX | [`ui.md`](docs/features/ui.md) | layout, 6 skins × 14 themes + custom / cover accent, suggestion of the day on Home, immersive view, Canvas, cover slideshow, artist hero, mini-player, desktop lyrics, Wrapped, profiles, onboarding, settings, updater, backups | +| LAN servers | [`dlna.md`](docs/features/dlna.md) · [`mpd.md`](docs/features/mpd.md) | opt-in MediaServer and MPD control surface | ## Conventions diff --git a/docs/features/playback.md b/docs/features/playback.md index 58b01cbd7..489cd7962 100644 --- a/docs/features/playback.md +++ b/docs/features/playback.md @@ -469,8 +469,12 @@ A file can play without becoming a library track: opened from the system (a doub - **Otherwise nothing is written.** The files' queue lives in memory on the same `DetachedSession` the remote queue uses ([`remote_playback`](../../src-tauri/crates/app/src/remote_playback.rs)), so it takes over next / previous, media keys and the end of a track while active, under the same ordering rules (#622): nothing is installed for a superseded intent, and an install the user overtook while it started is undone only if still ours. Starting one clears the other; a library track, a radio stream or a profile switch clears both. The sleep timer's "end of this track" stops either queue at the end of the track, as it stops the library one; until now a remote queue played on past it. Each file plays under a fresh negative id, through `AudioCmd::LoadRemoteFileAndPlay` with `DetachedOrigin::File`, so it records no `play_event`, no statistics and no scrobble — the decision for a file played in passing, like radio. - **Its tags are read when it plays**, not when it is queued (a folder of a thousand files reads one): title (else the file name), artist, length, ReplayGain, and the cover — embedded, else the folder's — written under its hash into the metadata artwork cache, where the asset protocol can serve it. DSD reads its own container; a file whose tags cannot be read still plays, its length from the decoder's probe. - **On screen** it is told apart by `isFileTrack`: a negative id with a `file_path` (`player:radio-metadata` carries `is_file` and the path; radio and remote tracks leave the path empty). It has no library affordance (`isStreamTrack` covers it: no ♥, no start radio, no slideshow, no file menu), keeps Previous / Next / Repeat like a remote track, no Shuffle, and its queue shows in the remote queue's view (`RemoteQueueView source="file"`, in the panel and the mini-player). Its lyrics are looked up by artist and title and render synced. -- **From the app.** A drop on the window offers two targets, **Play now** and **Add to the library** (see [Drag-and-drop import](library.md#drag-and-drop-import)); the "Play without importing" button in the sidebar's *My music* header opens a menu with **Play files…** and **Play a folder…**, also bound to `Ctrl+O` and `Ctrl+Shift+O`. All of them end in [`playWithoutImporting`](../../src/lib/openToPlay.ts), which hands the paths to `player_play_paths`; the file picker offers the extensions the scanner takes. A failure, from the picker or from the command (a folder with no audio file in it), goes through the playback alert as `files-failed` / `picker-failed`, emitted as a `player:error` from the frontend, since none of these paths has anything else on screen to show it. -- **The command line.** [`launch_args`](../../src-tauri/crates/app/src/launch_args.rs) reads the files a launch was opened with — the first launch from its own arguments once the profile is open, a later one from what the single-instance plugin hands the running instance, relative paths taken from its working directory. Options are skipped; the Flatpak's `%U` arrives as `file://` URIs and is converted. +- **From the app.** A drop on the window offers two targets, **Play now** and **Add to the library** (see [Drag-and-drop import](library.md#drag-and-drop-import)); the "Play without importing" button in the sidebar's _My music_ header opens a menu with **Play files…** and **Play a folder…**, also bound to `Ctrl+O` and `Ctrl+Shift+O`. All of them end in [`playWithoutImporting`](../../src/lib/openToPlay.ts), which hands the paths to `player_play_paths`; the file picker offers the extensions the scanner takes. A failure, from the picker or from the command (a folder with no audio file in it), goes through the playback alert as `files-failed` / `picker-failed`, emitted as a `player:error` from the frontend, since none of these paths has anything else on screen to show it. +- **The command line.** [`launch_args`](../../src-tauri/crates/app/src/launch_args.rs) reads the files a launch was opened with — the first launch from its own arguments once the profile is open, a later one from what the single-instance plugin hands the running instance, relative paths taken from its working directory. Options are skipped; the Flatpak's `%U` arrives as `file://` URIs and is converted. macOS passes no arguments: it sends `RunEvent::Opened` with file URLs, at launch as well as later, read by `paths_from_urls`. **Files that arrive together play together**: Windows starts the program once per file when several are opened at once, so each reaches the running window on its own, a moment after the last, and played as they came each would replace the queue the one before had started. `play_when_ready` gathers them until none has arrived for 400 ms (at most 3 s once the player is up; the wait for the player at a cold start gathers too), each file once, then plays them as one queue: in the order given when one launch brought them all, in path order when several did, since those reach the window in no reliable order (installed and measured: three files opened together arrived third, second, first). The open's load intent (#622) is claimed as soon as the engine exists, not when the files finally play: a track the user picks during the wait is the newer choice, and the files give way to it. On macOS the window is brought forward when files are opened, as the single-instance callback does elsewhere, except while the splash still stands at a launch, whose handoff reveals it once painted. +- **Opened from the system.** The installers offer WaveFlow for the formats it plays (mp3, flac, wav, aiff/aif, ogg/oga, opus, m4a, aac, dsf, dff) and make it the default for none: the default player stays the user's choice. `.mp4` is left out, since it is a video type first. + - **macOS**: `bundle.fileAssociations` in [`tauri.macos.conf.json`](../../src-tauri/crates/app/tauri.macos.conf.json), one per format with its MIME type, role `Viewer` and rank `Alternate` (`LSHandlerRank`): listed under _Open With_, never picked over the owner. No `exportedType`, so WaveFlow declares none of these types as its own. **They are declared for macOS only, on purpose**: on Windows Tauri's associations write each extension's default class (NSIS `APP_ASSOCIATE`, advertised `ProgId`s in the MSI), making the installer the default player for every format. A `tauri.windows.conf.json` setting them to `null` cannot remove them: the CLI merges the base config, then the platform file, then the `--config` overlay — and [`scripts/tauri.mjs`](../../scripts/tauri.mjs) passes the whole `tauri.conf.json` as that overlay, which puts them back. + - **Linux** (.deb, .rpm and the AppImage, so the AUR and COPR packages too): a desktop template of our own ([`linux/waveflow.desktop`](../../src-tauri/crates/app/linux/waveflow.desktop)) lists the same formats as `MimeType`, with the legacy aliases (`audio/x-flac`, ...). Tauri's default template has `Exec=waveflow` with no field code, so "Open with WaveFlow" started an empty window; ours passes `%F`. The Flatpak's own entry lists the same types and launches with `%U`. + - **Windows**: NSIS hooks ([`windows/open-with.nsh`](../../src-tauri/crates/app/windows/open-with.nsh)) and a WiX fragment for the MSI ([`windows/open-with.wxs`](../../src-tauri/crates/app/windows/open-with.wxs)) write instead the `WaveFlow.Audio` ProgID, `.ext\OpenWithProgids` (the _Open with_ entry), `Applications\\SupportedTypes` (no entry for other types), and `Capabilities` + `RegisteredApplications` (WaveFlow listed in _Settings > Default apps_, where the user can choose it). NSIS writes under the hive its install mode picked and tells Explorer (`SHChangeNotify`); the MSI installs per machine. Uninstalling removes only WaveFlow's own values: the extension keys are shared with the other players, and an update leaves them in place, as it leaves the shortcuts. ## A-B loop diff --git a/packaging/copr/README.md b/packaging/copr/README.md index 00fbc653d..22c1576a2 100644 --- a/packaging/copr/README.md +++ b/packaging/copr/README.md @@ -51,7 +51,7 @@ adds for a Linux-side respin. So instead the spec: - in `%install`, runs `rpm2cpio | cpio -idmv` to extract the binary blob directly into the buildroot. -This is the same pattern used by Spotify, Discord, and other binary +This is the usual pattern for binary-only Linux apps that ship to COPR. The Fedora project itself wouldn't accept this in `Fedora/`, but COPR is for user-hosted repos and explicitly allows it. diff --git a/packaging/flatpak/app.waveflow.WaveFlow.desktop b/packaging/flatpak/app.waveflow.WaveFlow.desktop index 8e887509a..49a52c5af 100644 --- a/packaging/flatpak/app.waveflow.WaveFlow.desktop +++ b/packaging/flatpak/app.waveflow.WaveFlow.desktop @@ -4,8 +4,8 @@ Version=1.0 Name=WaveFlow GenericName=Music Player GenericName[fr]=Lecteur de musique -Comment=Local-first music player with a Spotify-inspired UI -Comment[fr]=Lecteur de musique local avec une interface inspirée de Spotify +Comment=Local-first music player with Hi-Res audio and synced lyrics +Comment[fr]=Lecteur de musique local, audio haute résolution et paroles synchronisées Exec=waveflow %U Icon=app.waveflow.WaveFlow Terminal=false @@ -14,5 +14,5 @@ StartupWMClass=waveflow Categories=AudioVideo;Audio;Player;Music; Keywords=music;player;audio;flac;mp3;dsd;library;playlist;lyrics;scrobble;equalizer; Keywords[fr]=musique;lecteur;audio;flac;mp3;dsd;bibliothèque;playlist;paroles;scrobble;égaliseur; -MimeType=audio/flac;audio/mpeg;audio/mp4;audio/ogg;audio/opus;audio/wav;audio/x-wav;audio/x-wavpack;audio/x-aiff;audio/x-dsd;audio/x-dsf;audio/x-dff;audio/x-musepack;audio/aac;audio/aiff;application/vnd.apple.mpegurl;application/x-mpegurl;audio/x-mpegurl;audio/x-scpls; +MimeType=audio/mpeg;audio/flac;audio/x-flac;audio/wav;audio/x-wav;audio/aiff;audio/x-aiff;audio/ogg;audio/x-vorbis+ogg;audio/opus;audio/x-opus+ogg;audio/mp4;audio/x-m4a;audio/aac;audio/x-dsf;audio/x-dff; SingleMainWindow=true diff --git a/packaging/flatpak/app.waveflow.WaveFlow.metainfo.xml b/packaging/flatpak/app.waveflow.WaveFlow.metainfo.xml index 6d0cc45d8..34192f7c2 100644 --- a/packaging/flatpak/app.waveflow.WaveFlow.metainfo.xml +++ b/packaging/flatpak/app.waveflow.WaveFlow.metainfo.xml @@ -96,16 +96,17 @@ waveflow - audio/flac audio/mpeg - audio/mp4 - audio/ogg - audio/opus + audio/flac audio/wav - audio/x-wavpack + audio/aiff audio/x-aiff - audio/x-dsd - audio/x-musepack + audio/ogg + audio/opus + audio/mp4 + audio/aac + audio/x-dsf + audio/x-dff diff --git a/packaging/winget/1.0.0/InstaZDLL.WaveFlow.locale.en-US.yaml b/packaging/winget/1.0.0/InstaZDLL.WaveFlow.locale.en-US.yaml index a294c8199..6bf519dd9 100644 --- a/packaging/winget/1.0.0/InstaZDLL.WaveFlow.locale.en-US.yaml +++ b/packaging/winget/1.0.0/InstaZDLL.WaveFlow.locale.en-US.yaml @@ -13,7 +13,7 @@ PackageUrl: https://waveflow.app License: GPL-3.0-only LicenseUrl: https://github.com/InstaZDLL/WaveFlow/blob/main/LICENSE Copyright: Copyright © 2026 InstaZDLL. Licensed under GPL-3.0-only. -ShortDescription: Local-first music player with a Spotify-inspired UI. +ShortDescription: Local-first music player with Hi-Res audio and synced lyrics. Description: |- WaveFlow is a local music player desktop app built with Tauri 2 and React 19. It scans your local audio folders, organizes tracks by album/artist/genre, and plays them with a real-time audio engine — no streaming, no cloud, your music stays on your machine. diff --git a/src-tauri/crates/app/linux/waveflow.desktop b/src-tauri/crates/app/linux/waveflow.desktop new file mode 100644 index 000000000..47dc30dc3 --- /dev/null +++ b/src-tauri/crates/app/linux/waveflow.desktop @@ -0,0 +1,33 @@ +{{!-- + The desktop entry the .deb, the .rpm and the AppImage install (the AUR and + COPR packages repackage the first two). It is Tauri's own template with two + changes: + + - `Exec` takes `%F`. Without a field code the file manager starts WaveFlow + with no file at all, so "Open with WaveFlow" opened an empty window. + `%F` hands over local paths, mounting a remote file through GIO first; + `launch_args` reads them, and a second launch passes them on to the + running window. + - `MimeType` lists the formats WaveFlow plays, with the legacy aliases + (`audio/x-flac`, ...) older shared-mime-info databases still report. + It is written here rather than taken from `bundle.fileAssociations`: + those are declared for macOS only (tauri.macos.conf.json), since in the + base config Windows would make them every format's default, and the + `--config` overlay scripts/tauri.mjs passes undoes a platform file's + attempt to remove them. + + Keep the formats in step with tauri.macos.conf.json, the Windows + `open-with` hooks and the Flatpak desktop entry. +--~}} +[Desktop Entry] +Categories={{categories}} +{{#if comment}} +Comment={{comment}} +{{/if}} +Exec={{exec}} %F +StartupWMClass={{exec}} +Icon={{icon}} +Name={{name}} +Terminal=false +Type=Application +MimeType=audio/mpeg;audio/flac;audio/x-flac;audio/wav;audio/x-wav;audio/aiff;audio/x-aiff;audio/ogg;audio/x-vorbis+ogg;audio/opus;audio/x-opus+ogg;audio/mp4;audio/x-m4a;audio/aac;audio/x-dsf;audio/x-dff; diff --git a/src-tauri/crates/app/src/external_playback.rs b/src-tauri/crates/app/src/external_playback.rs index 755803f73..69cec96e6 100644 --- a/src-tauri/crates/app/src/external_playback.rs +++ b/src-tauri/crates/app/src/external_playback.rs @@ -212,6 +212,18 @@ pub async fn play_paths(app: &AppHandle, paths: Vec) -> AppResult<()> { // Claimed before the walk and the lookups below (#622): a folder can // take a while, and a track the user picks meanwhile must win. let intent = app.state::>().next_load_intent(); + play_paths_with_intent(app, paths, intent).await +} + +/// [`play_paths`] under an intent the caller claimed earlier: the files a +/// launch hands over are waited for before they play +/// ([`crate::launch_args::play_when_ready`]), and a track the user picks +/// during that wait is the newer choice. +pub async fn play_paths_with_intent( + app: &AppHandle, + paths: Vec, + intent: LoadIntent, +) -> AppResult<()> { let files = tokio::task::spawn_blocking(move || expand(&paths)) .await .map_err(|e| AppError::Other(format!("listing the files to play: {e}")))?; diff --git a/src-tauri/crates/app/src/launch_args.rs b/src-tauri/crates/app/src/launch_args.rs index 574fd7caf..a971a72da 100644 --- a/src-tauri/crates/app/src/launch_args.rs +++ b/src-tauri/crates/app/src/launch_args.rs @@ -8,12 +8,22 @@ //! dropped. Both end in [`crate::external_playback::play_paths`]. //! //! The Flatpak's desktop entry launches with `%U`, so files arrive there -//! as `file://` URIs; everything else passes plain paths. +//! as `file://` URIs; everything else passes plain paths. macOS passes no +//! arguments at all: it sends `RunEvent::Opened` with file URLs, read by +//! [`paths_from_urls`]. +//! +//! Files that arrive together play together. Windows starts the program +//! once per file when several are opened at once, so each comes through the +//! single-instance plugin on its own, a moment after the last: played as +//! they came, every one would replace the queue the one before had just +//! started. They are gathered until none has arrived for a moment, then +//! played as one queue: in path order when several launches brought them, +//! since those arrive in no reliable order ([`play_order`]). use std::ffi::OsString; use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::Duration; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; use tauri::{AppHandle, Manager}; @@ -70,25 +80,108 @@ fn normalize(path: &Path) -> PathBuf { out } +/// The local files among the URLs macOS opens the app with. Anything else +/// (a web link, a file gone in the meantime) is dropped. +#[cfg(any(target_os = "macos", test))] +pub fn paths_from_urls<'a>(urls: impl IntoIterator) -> Vec { + urls.into_iter() + .filter_map(|url| url.to_file_path().ok()) + .filter(|path| path.exists()) + .collect() +} + +/// How long the files of one open are waited for after the last one came: +/// Windows starts the program again for each, and each hands its file over +/// through the single-instance plugin a moment after the one before. +const GATHER_QUIET: Duration = Duration::from_millis(400); + +/// The longest the gathering waits once the player is up, however steadily +/// files keep coming. +const GATHER_LIMIT: Duration = Duration::from_secs(3); + +/// Files waiting to play, when the last of them arrived, and how many +/// launches brought them. +struct Gathering { + paths: Vec, + last: Instant, + arrivals: usize, +} + +/// The open being gathered, if one is. `None` when nothing is waiting. +static GATHERING: Mutex> = Mutex::new(None); + +/// Add `more` to `paths`, each file once, in the order they came. +fn gather(paths: &mut Vec, more: Vec) { + for path in more { + if !paths.contains(&path) { + paths.push(path); + } + } +} + +/// The order a gathered open plays in. One launch keeps the order it named +/// its files in (`waveflow b.flac a.flac` plays b first). Files from several +/// launches are put in path order: the launches reach the running window in +/// no reliable order (measured: Explorer's three files arrived third, +/// second, first, the first launch's own arguments read last, after its +/// setup), so their arrival order means nothing. +fn play_order(mut paths: Vec, arrivals: usize) -> Vec { + if arrivals > 1 { + paths.sort_by_cached_key(|path| path.to_string_lossy().to_lowercase()); + } + paths +} + /// Play `paths` once the active profile is open, which is when a file can /// be told apart from a library track. At launch that is a moment after -/// `setup` returns; from a second launch it already is. +/// `setup` returns; from a second launch it already is. Files that arrive +/// while some are already waiting join them (see the module comment). pub fn play_when_ready(app: &AppHandle, paths: Vec) { if paths.is_empty() { return; } + { + let mut gathering = GATHERING.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(waiting) = gathering.as_mut() { + // A task is already waiting to play; it takes these too. + gather(&mut waiting.paths, paths); + waiting.last = Instant::now(); + waiting.arrivals += 1; + return; + } + let mut first = Vec::new(); + gather(&mut first, paths); + *gathering = Some(Gathering { + paths: first, + last: Instant::now(), + arrivals: 1, + }); + } + // Claimed here when the engine already exists, before the task is even + // scheduled; at a launch, as soon as it does (below). + let early = app + .try_state::>() + .map(|engine| engine.next_load_intent()); let app = app.clone(); tauri::async_runtime::spawn(async move { // A second launch can arrive while this instance is still in // `setup`, before its state and engine exist: wait for them, then // for the profile. Bounded: with no profile at all (the first run, // before onboarding) the files still play, as files. + // + // The open's intent is claimed as soon as the engine exists (#622), + // not when the files finally play: a track the user picks while + // they are gathered, or while the profile opens, is the newer + // choice, and the files give way to it. Nothing can be picked + // before the engine exists, so nothing is lost by waiting for it. + let mut intent = early; let mut ready = false; for _ in 0..150 { - if let (Some(state), Some(_)) = ( + if let (Some(state), Some(engine)) = ( app.try_state::(), app.try_state::>(), ) { + intent.get_or_insert_with(|| engine.next_load_intent()); ready = true; if state.require_profile_pool().await.is_ok() { break; @@ -96,13 +189,39 @@ pub fn play_when_ready(app: &AppHandle, paths: Vec) { } tokio::time::sleep(Duration::from_millis(100)).await; } - if !ready { + // Then until the files stop coming. The wait for the player above + // gathers too, so a cold start opened with many files has them all. + let started = Instant::now(); + loop { + let since_last = GATHERING + .lock() + .unwrap_or_else(|e| e.into_inner()) + .as_ref() + .map_or(GATHER_QUIET, |waiting| waiting.last.elapsed()); + if since_last >= GATHER_QUIET || started.elapsed() >= GATHER_LIMIT { + break; + } + tokio::time::sleep(GATHER_QUIET - since_last).await; + } + // Taken on every way out, so the next open starts a gathering of + // its own. + let paths = GATHERING + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take() + .map(|waiting| play_order(waiting.paths, waiting.arrivals)) + .unwrap_or_default(); + // Both the state and the engine, not the engine alone: playing + // reads the state. + let (true, Some(intent)) = (ready, intent) else { tracing::warn!( "the player never came up; not playing the files WaveFlow was opened with" ); return; - } - if let Err(err) = crate::external_playback::play_paths(&app, paths).await { + }; + if let Err(err) = + crate::external_playback::play_paths_with_intent(&app, paths, intent).await + { tracing::warn!(%err, "could not play the files WaveFlow was opened with"); } }); @@ -151,6 +270,47 @@ mod tests { ); } + #[test] + fn files_that_arrive_together_are_gathered_once_each_in_order() { + let a = PathBuf::from("/m/a.flac"); + let b = PathBuf::from("/m/b.flac"); + let c = PathBuf::from("/m/c.flac"); + let mut paths = Vec::new(); + gather(&mut paths, vec![a.clone(), b.clone()]); + gather(&mut paths, vec![b.clone(), c.clone(), a.clone()]); + assert_eq!(paths, [a, b, c]); + } + + #[test] + fn one_launch_keeps_its_order_and_several_play_in_path_order() { + let a = PathBuf::from("/m/Album/01 First.flac"); + let b = PathBuf::from("/m/Album/02 second.flac"); + let c = PathBuf::from("/m/Album/03 Third.flac"); + // `waveflow 03 01`: the order it was given. + assert_eq!( + play_order(vec![c.clone(), a.clone()], 1), + [c.clone(), a.clone()] + ); + // Three launches that reached the window third, second, first. + assert_eq!( + play_order(vec![c.clone(), b.clone(), a.clone()], 3), + [a, b, c] + ); + } + + #[test] + fn only_local_files_are_taken_from_opened_urls() { + let dir = tempfile::tempdir().unwrap(); + let song = dir.path().join("my song.flac"); + std::fs::write(&song, b"x").unwrap(); + let urls = [ + url::Url::from_file_path(&song).unwrap(), + url::Url::from_file_path(dir.path().join("gone.flac")).unwrap(), + url::Url::parse("https://waveflow.app/").unwrap(), + ]; + assert_eq!(paths_from_urls(&urls), [song]); + } + #[test] fn a_file_uri_is_a_path_and_a_missing_file_is_dropped() { let dir = tempfile::tempdir().unwrap(); diff --git a/src-tauri/crates/app/src/lib.rs b/src-tauri/crates/app/src/lib.rs index db3e20680..426ee4433 100644 --- a/src-tauri/crates/app/src/lib.rs +++ b/src-tauri/crates/app/src/lib.rs @@ -1386,6 +1386,24 @@ pub fn run() { .build(context) .expect("error while building tauri application") .run(|app, event| { + // macOS opens files by sending them to the running app rather + // than on its command line, at launch as well as later. + #[cfg(target_os = "macos")] + if let tauri::RunEvent::Opened { urls } = &event { + let paths = launch_args::paths_from_urls(urls); + if !paths.is_empty() { + // Brought forward, as the single-instance callback does + // on the other systems: a window hidden to the tray + // would play the files out of sight. Not while the + // splash still stands, at a launch: the handoff shows + // the window once it has painted, and showing it before + // would put an unfinished interface on screen. + if app.get_webview_window("splashscreen").is_none() { + show_main_window(app); + } + launch_args::play_when_ready(app, paths); + } + } // Every way out lands here: the tray's "Quit" (which calls // `AppHandle::exit`), a window close that isn't close-to-tray, a // session logout. `WindowEvent::Destroyed` does not — `exit` diff --git a/src-tauri/crates/app/tauri.conf.json b/src-tauri/crates/app/tauri.conf.json index 614ac061b..f95fcfde0 100644 --- a/src-tauri/crates/app/tauri.conf.json +++ b/src-tauri/crates/app/tauri.conf.json @@ -81,9 +81,24 @@ "macOS": { "entitlements": "waveflow.entitlements" }, + "windows": { + "nsis": { + "installerHooks": "windows/open-with.nsh" + }, + "wix": { + "fragmentPaths": ["windows/open-with.wxs"], + "componentRefs": ["WaveFlowOpenWith"] + } + }, "linux": { "appimage": { "bundleMediaFramework": true + }, + "deb": { + "desktopTemplate": "linux/waveflow.desktop" + }, + "rpm": { + "desktopTemplate": "linux/waveflow.desktop" } } }, diff --git a/src-tauri/crates/app/tauri.macos.conf.json b/src-tauri/crates/app/tauri.macos.conf.json new file mode 100644 index 000000000..0db2c566b --- /dev/null +++ b/src-tauri/crates/app/tauri.macos.conf.json @@ -0,0 +1,76 @@ +{ + "bundle": { + "fileAssociations": [ + { + "ext": ["mp3"], + "name": "MP3 Audio", + "mimeType": "audio/mpeg", + "role": "Viewer", + "rank": "Alternate" + }, + { + "ext": ["flac"], + "name": "FLAC Audio", + "mimeType": "audio/flac", + "role": "Viewer", + "rank": "Alternate" + }, + { + "ext": ["wav"], + "name": "WAV Audio", + "mimeType": "audio/wav", + "role": "Viewer", + "rank": "Alternate" + }, + { + "ext": ["aiff", "aif"], + "name": "AIFF Audio", + "mimeType": "audio/aiff", + "role": "Viewer", + "rank": "Alternate" + }, + { + "ext": ["ogg", "oga"], + "name": "Ogg Vorbis Audio", + "mimeType": "audio/ogg", + "role": "Viewer", + "rank": "Alternate" + }, + { + "ext": ["opus"], + "name": "Opus Audio", + "mimeType": "audio/opus", + "role": "Viewer", + "rank": "Alternate" + }, + { + "ext": ["m4a"], + "name": "MPEG-4 Audio", + "mimeType": "audio/mp4", + "role": "Viewer", + "rank": "Alternate" + }, + { + "ext": ["aac"], + "name": "AAC Audio", + "mimeType": "audio/aac", + "role": "Viewer", + "rank": "Alternate" + }, + { + "ext": ["dsf"], + "name": "DSD Audio (DSF)", + "mimeType": "audio/x-dsf", + "role": "Viewer", + "rank": "Alternate" + }, + { + "ext": ["dff"], + "name": "DSD Audio (DFF)", + "mimeType": "audio/x-dff", + "role": "Viewer", + "rank": "Alternate" + } + ] + } +} diff --git a/src-tauri/crates/app/windows/open-with.nsh b/src-tauri/crates/app/windows/open-with.nsh new file mode 100644 index 000000000..6ccb1f82c --- /dev/null +++ b/src-tauri/crates/app/windows/open-with.nsh @@ -0,0 +1,92 @@ +; "Open with WaveFlow" for the audio formats WaveFlow plays, without taking +; any of them over. +; +; Tauri's own file associations (`bundle.fileAssociations`) write each +; extension's default class, which makes the installer the default player +; for every format it lists. They are declared for macOS only +; (`tauri.macos.conf.json`), and Windows gets these hooks instead, which only +; offer WaveFlow: +; +; - the `WaveFlow.Audio` ProgID, with its icon and its open command; +; - `.ext\OpenWithProgids`, which lists it under "Open with" for each format; +; - `Applications\\SupportedTypes`, which keeps WaveFlow out of the +; "Open with" list of every other type; +; - `Capabilities` + `RegisteredApplications`, which list WaveFlow in +; Settings > Default apps, where the user can make it the default for a +; format if they want to. +; +; Everything goes under SHCTX, the hive the template's `SetContext` picked +; for this install (per user or per machine). The MSI does the same through +; `open-with.wxs`. Keep the formats in step with tauri.macos.conf.json, the +; Linux desktop template and the Flatpak desktop entry. +; +; The template includes this file before it defines MAINBINARYNAME and +; MANUPRODUCTKEY, and a `!define` expands what it names when it is read: the +; paths built from them live in the macro bodies, which expand where they +; are inserted, once both exist. + +!define WAVEFLOW_PROGID "WaveFlow.Audio" + +!macro WAVEFLOW_FOR_EACH_FORMAT MACRO + !insertmacro ${MACRO} "mp3" + !insertmacro ${MACRO} "flac" + !insertmacro ${MACRO} "wav" + !insertmacro ${MACRO} "aiff" + !insertmacro ${MACRO} "aif" + !insertmacro ${MACRO} "ogg" + !insertmacro ${MACRO} "oga" + !insertmacro ${MACRO} "opus" + !insertmacro ${MACRO} "m4a" + !insertmacro ${MACRO} "aac" + !insertmacro ${MACRO} "dsf" + !insertmacro ${MACRO} "dff" +!macroend + +!macro WAVEFLOW_OFFER_FORMAT EXT + WriteRegStr SHCTX "Software\Classes\.${EXT}\OpenWithProgids" "${WAVEFLOW_PROGID}" "" + WriteRegStr SHCTX "Software\Classes\Applications\${MAINBINARYNAME}.exe\SupportedTypes" ".${EXT}" "" + WriteRegStr SHCTX "${MANUPRODUCTKEY}\Capabilities\FileAssociations" ".${EXT}" "${WAVEFLOW_PROGID}" +!macroend + +; Only our own value. The extension key, its `OpenWithProgids` and the +; default they may name belong to the other players too, and `/ifempty` +; would not protect them: it looks at subkeys, not at values. +!macro WAVEFLOW_WITHDRAW_FORMAT EXT + DeleteRegValue SHCTX "Software\Classes\.${EXT}\OpenWithProgids" "${WAVEFLOW_PROGID}" +!macroend + +!macro NSIS_HOOK_POSTINSTALL + WriteRegStr SHCTX "Software\Classes\${WAVEFLOW_PROGID}" "" "${PRODUCTNAME} audio file" + WriteRegStr SHCTX "Software\Classes\${WAVEFLOW_PROGID}\DefaultIcon" "" "$\"$INSTDIR\${MAINBINARYNAME}.exe$\",0" + WriteRegStr SHCTX "Software\Classes\${WAVEFLOW_PROGID}\shell\open\command" "" "$\"$INSTDIR\${MAINBINARYNAME}.exe$\" $\"%1$\"" + + WriteRegStr SHCTX "Software\Classes\Applications\${MAINBINARYNAME}.exe" "FriendlyAppName" "${PRODUCTNAME}" + WriteRegStr SHCTX "Software\Classes\Applications\${MAINBINARYNAME}.exe\DefaultIcon" "" "$\"$INSTDIR\${MAINBINARYNAME}.exe$\",0" + WriteRegStr SHCTX "Software\Classes\Applications\${MAINBINARYNAME}.exe\shell\open\command" "" "$\"$INSTDIR\${MAINBINARYNAME}.exe$\" $\"%1$\"" + + WriteRegStr SHCTX "${MANUPRODUCTKEY}\Capabilities" "ApplicationName" "${PRODUCTNAME}" + WriteRegStr SHCTX "${MANUPRODUCTKEY}\Capabilities" "ApplicationDescription" "Local-first music player" + WriteRegStr SHCTX "Software\RegisteredApplications" "${PRODUCTNAME}" "${MANUPRODUCTKEY}\Capabilities" + + !insertmacro WAVEFLOW_FOR_EACH_FORMAT WAVEFLOW_OFFER_FORMAT + + ; SHCNE_ASSOCCHANGED: Explorer rereads the associations now rather than + ; at its next restart. + System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, p 0, p 0)' +!macroend + +!macro NSIS_HOOK_POSTUNINSTALL + ; An update runs the old uninstaller with /UPDATE before installing the + ; new version, whose post-install writes all of this again: leave it be, + ; as the template leaves the shortcuts. + ${If} $UpdateMode <> 1 + !insertmacro WAVEFLOW_FOR_EACH_FORMAT WAVEFLOW_WITHDRAW_FORMAT + + DeleteRegValue SHCTX "Software\RegisteredApplications" "${PRODUCTNAME}" + DeleteRegKey SHCTX "${MANUPRODUCTKEY}\Capabilities" + DeleteRegKey SHCTX "Software\Classes\Applications\${MAINBINARYNAME}.exe" + DeleteRegKey SHCTX "Software\Classes\${WAVEFLOW_PROGID}" + + System::Call 'shell32::SHChangeNotify(i 0x08000000, i 0, p 0, p 0)' + ${EndIf} +!macroend diff --git a/src-tauri/crates/app/windows/open-with.wxs b/src-tauri/crates/app/windows/open-with.wxs new file mode 100644 index 000000000..a195a9b90 --- /dev/null +++ b/src-tauri/crates/app/windows/open-with.wxs @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +