From 223b1ca06179db29f4bc440e476cbf6a997c3e27 Mon Sep 17 00:00:00 2001 From: ad-archer Date: Tue, 14 Apr 2026 19:09:54 -0400 Subject: [PATCH 1/4] restore queue button --- README.md | 260 ++++++++++----------- assets/tailwind.css | 3 + src/components/app.rs | 68 +++++- src/components/song_details/queue_panel.rs | 17 +- src/components/views/home.rs | 15 +- src/components/views/queue.rs | 190 ++++++++++++--- src/db/mod.rs | 155 +++++++++++- 7 files changed, 521 insertions(+), 187 deletions(-) diff --git a/README.md b/README.md index ad1f1b5..caf0b28 100644 --- a/README.md +++ b/README.md @@ -1,72 +1,75 @@ # RustySound -A lightweight cross-platform music streaming client for Navidrome and Subsonic-compatible servers, built with Rust and Dioxus, < 10mb +A lightweight cross-platform music streaming client for Navidrome and Subsonic-compatible servers, built with Rust and Dioxus, < 15mb -![RustySound desktop screenshot](https://www.antonioarcher.com/images/projects/rustysound/desktop/sound_menu.webp) -![RustySound lyrics demo](https://www.antonioarcher.com/images/projects/rustysound/desktop/shot.gif) -album -![RustySound desktop theme 1](https://www.antonioarcher.com/images/projects/rustysound/desktop/desktoptheme1.webp) -![RustySound desktop theme 2](https://www.antonioarcher.com/images/projects/rustysound/desktop/desktoptheme2.webp) -![RustySound mobile theme](https://www.antonioarcher.com/images/projects/rustysound/mobile/mobiletheme1.webp) +
+ RustySound desktop screenshot + RustySound lyrics demo + RustySound desktop theme + RustySound desktop theme variant + RustySound mobile theme + RustySound album view -# Features +
-- 🎵 **Multi-platform Support**: Available on Desktop (macOS, Windows, Linux), Mobile (iOS, Android), and Web -- 🎧 **Audio Playback**: High-quality audio streaming with queue management -- 📱 **Server Integration**: Connect to Navidrome and Subsonic-compatible music servers -- 💾 **Local Storage**: Persistent settings and playback state across sessions -- 🎼 **Playlist Management**: Create and manage playlists -- 🔍 **Search & Browse**: Browse your music library by artists, albums, and tracks -- 🎚️ **Audio Controls**: Play, pause, skip, shuffle, and repeat functionality -- 🌙 **Modern UI**: Clean, responsive interface built with Tailwind CSS -- 🎨 **Themes**: Multiple built-in themes with custom theme/CSS support +## Features -# Themes +- **Multi-platform**: Desktop (macOS, Windows, Linux), Mobile (iOS, Android), and Web +- **Audio streaming**: High-quality playback with queue management +- **Server integration**: Connect to Navidrome and Subsonic-compatible servers +- **Offline support**: Local storage, downloads, persistent settings and playback state +- **Playlist management**: Create and organize playlists +- **Browse and search**: Find music by artist, album, or track +- **Full controls**: Play, pause, skip, shuffle, repeat, and more +- **Modern UI**: Clean, responsive Tailwind CSS interface +- **Customizable themes**: Multiple built-in themes with CSS overrides -RustySound now supports multiple built-in themes and custom themes. +## Supported Platforms -- Switch between bundled themes in **Settings** -- Apply your own custom CSS overrides +| Platform | Installation | +| ----------- | ---------------------------------------------------------- | +| **iOS** | AltStore (recommended), manual sideloading, or source feed | +| **Android** | APK from Releases | +| **macOS** | Homebrew, DMG installer | +| **Windows** | Scoop, standalone EXE | +| **Linux** | Flatpak | +| **Web** | Browser + PWA, Docker | -# Supported Platforms +> **Note**: Android is feature-aligned but not under active day-to-day development and may have platform-specific issues. -## Web +## Installation -- **Browser**: WebAssembly-based web application -- **Progressive Web App**: Installable PWA support +### iOS -## Desktop +#### AltStore Installation (Recommended) -- **macOS**: DMG installer and Homebrew -- **Windows**: NSIS installer and scoop. -- **Linux**: Flatpak +This is the easiest way to install and keep RustySound updated on iOS. -## Mobile + +Add to AltStore + -- **iOS**: unsigned .ipa -- **Android**: APK release artifact +Or manually add `https://ad-archer.github.io/packages/source.json` as a source in: -> Android status: Android builds are published and kept feature-aligned, but Android is not under active day-to-day development and may contain platform-specific bugs. +- [AltStore](https://altstore.io/) or [AltServer](https://altstore.io/) for Mac/Windows +- [LiveContainer](https://github.com/LiveContainer/LiveContainer) on iOS -# Installation +#### Manual Sideloading -## Desktop - -### IOS - -1. Add `https://ad-archer.github.io/packages/source.json` as a source in [AltStore/AltServer](https://altstore.io/) or [LiveContainer](https://github.com/LiveContainer/LiveContainer) -2. Install RustySound from that source on your device -3. Use the latest `.ipa` from [Releases](https://github.com/AD-Archer/RustySound/releases) only if you want to sideload manually instead of using the source feed +1. Download the latest `.ipa` file from [Releases](https://github.com/AD-Archer/RustySound/releases) +2. Use [AltStore](https://altstore.io/), [Sideloadly](https://sideloadly.io/), or [Xcode](https://developer.apple.com/xcode/) to install ### Android 1. Download the latest `.apk` file from [Releases](https://github.com/AD-Archer/RustySound/releases) -2. Enable installation from unknown sources on your device +2. Enable "Installation from unknown sources" in Settings 3. Install the APK and launch RustySound -### macOS +### Desktop -#### Homebrew +#### macOS + +##### Homebrew (Recommended) ```bash brew tap ad-archer/homebrew-tap @@ -79,42 +82,39 @@ To update: brew upgrade rustysound ``` -#### DMG +##### DMG Installer 1. Download the latest `.dmg` file from [Releases](https://github.com/AD-Archer/RustySound/releases) 2. Open the DMG and drag RustySound to your Applications folder -If macOS says the app is damaged or won't open: - -1. In Finder, right-click `RustySound.app` and choose **Open**, then confirm. -2. Or run: +**Troubleshooting**: If macOS says the app is damaged or won't open: ```bash +# Option 1: Right-click in Finder and choose Open, then confirm +# Option 2: Run this command xattr -dr com.apple.quarantine /Applications/RustySound.app open /Applications/RustySound.app ``` -### Windows +#### Windows -#### Scoop +##### Scoop (Recommended) ```powershell scoop bucket add ad-archer https://github.com/ad-archer/scoop scoop install ad-archer/rustysound ``` -#### Executable +##### Portable Executable 1. Download the latest `.exe` file from [Releases](https://github.com/AD-Archer/RustySound/releases) 2. Open the exe and install RustySound -Note: Antivirus may flag this installer since this exe is not verified by windows. +> **Note**: Antivirus may flag the installer since it is not verified by Windows. This is safe to ignore for builds from the official repository. -Note: release artifacts may be unsigned/ad-hoc signed when Apple notarization secrets are not configured in CI. For public distribution without warnings, a paid Apple Developer signing + notarization flow is required. +#### Linux -### Linux - -#### Flatpak (Recommended) +##### Flatpak (Recommended) ```bash flatpak remote-add --if-not-exists --user adarcher-rustysound https://ad-archer.github.io/packages/rustysound.flatpakrepo @@ -122,7 +122,7 @@ flatpak install --user adarcher-rustysound app.adarcher.rustysound//stable flatpak run app.adarcher.rustysound ``` -If you installed an older build that tracked `master`, migrate once with: +To update: ```bash flatpak install --user adarcher-rustysound app.adarcher.rustysound//stable @@ -135,7 +135,7 @@ flatpak uninstall --user app.adarcher.rustysound flatpak remote-delete --user adarcher-rustysound ``` -#### Troubleshooting — missing GNOME runtime +**Troubleshooting — missing GNOME runtime** If you see an error like: @@ -143,34 +143,32 @@ If you see an error like: error: The application app.adarcher.rustysound/x86_64/master requires the runtime org.gnome.Platform/x86_64/49 which was not found ``` -this usually means the remote you added doesn't provide the GNOME runtime. Install the runtime from Flathub and try again: +Install the runtime from Flathub: ```bash # Add Flathub (if not already present) flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo -# Install the GNOME 49 runtime (user or system-wide) +# Install the GNOME 49 runtime flatpak install --user flathub org.gnome.Platform//49 -# Optional: locale data -flatpak install --user flathub org.gnome.Platform.Locale//49 -# Then reinstall the app from the adarcher remote +# Reinstall the app flatpak install --user adarcher-rustysound app.adarcher.rustysound//stable ``` -If you prefer a system-wide install (no `--user`), omit `--user` from the commands. Also ensure the runtime architecture matches your system (x86_64 vs aarch64). +For system-wide install, omit `--user` from the commands. ### Web -Visit [rustysound](https://rustysound-demo.adarcher.app) to use the web version. +Visit [rustysound](https://rustysound-demo.adarcher.app) to try the web version. #### Docker Deployment -You can also run RustySound as a Docker container: +##### Docker Compose (Recommended) 1. Ensure you have Docker and Docker Compose installed 2. Clone this repository or copy [`docker-compose.yml`](https://raw.githubusercontent.com/AD-Archer/RustySound/refs/heads/main/docker-compose.yml) -3. Run the application: +3. Run: ```bash docker-compose up -d @@ -178,20 +176,20 @@ docker-compose up -d The web interface will be available at `http://localhost:8080`. -To stop the container: +To stop: ```bash docker-compose down ``` -#### Manual Docker Run - -If you prefer to run the container directly: +##### Manual Docker Run ```bash docker run -d -p 8080:80 --name rustysound ghcr.io/ad-archer/rustysound:latest ``` +## Development + ### Prerequisites - Rust 1.70+ ([install here](https://rustup.rs/)) @@ -212,119 +210,113 @@ cd RustySound cargo build ``` -### Running the Application +### Running Locally -#### Development Server +#### Web ```bash -dx serve +dx serve --platform web ``` -#### Just Shortcuts +#### Desktop ```bash -just # list recipes -just serve # dx serve -just serve-ios # iOS simulator dev (safe linker env) -just serve-android # Android dev/debug (auto-create/start emulator) -just bundle # macOS + iOS + unsigned IPA -just bundle-android-release # Android release APK into dist/android -just check # cargo check +dx serve --platform desktop ``` -#### iOS Simulator Development +#### Mobile -Use the helper below instead of raw `dx serve --ios` if your shell exports Homebrew/Nix compiler flags: +##### iOS Simulator ```bash ./scripts/serve-ios.sh ``` -You can pass normal `dx serve` options through: +You can pass `dx serve` options: ```bash ./scripts/serve-ios.sh --device "iPhone 16 Pro" ``` -#### Specific Platforms +##### Android Emulator ```bash -# Web (default) -dx serve --platform web - -# Desktop -dx serve --platform desktop - -# Mobile (iOS Simulator) -dx serve --platform ios - -# Mobile (Android Emulator) dx serve --platform android +# or with auto-setup (NixOS): +just serve-android ``` -For NixOS convenience (auto create/start emulator + boot wait): +### Quick Commands + +Use `just` for common tasks: ```bash -just serve-android -# alias: -just serve-andoird +just # list all recipes +just serve # dx serve (web) +just serve-ios # iOS simulator dev (safe linker env) +just serve-android # Android dev/debug (auto-create/start emulator) +just check # cargo check +just bundle # macOS + iOS + unsigned IPA +just bundle-android-release # Android release APK ``` -### Building for Production +### Building for Release -#### Desktop Bundles +#### Desktop ```bash dx bundle --platform desktop --release ``` -#### Mobile Builds +#### iOS ```bash -# iOS dx bundle --platform ios --release - -# Android -./scripts/bundle-android.sh -# or -just bundle-android-release ``` -`scripts/bundle-android.sh` only exports Android release `.apk` artifacts into `dist/android`. - -Optional signing env vars for `scripts/bundle-android.sh`: - -- `ANDROID_KEYSTORE_BASE64` (or `ANDROID_KEYSTORE_PATH`) -- `ANDROID_KEYSTORE_PASSWORD` -- `ANDROID_KEY_ALIAS` -- `ANDROID_KEY_PASSWORD` (optional) - -CI/CD builds Android release APKs and publishes only `.apk` artifacts from `dist/android`. - -#### Apple Bundles (.app + unsigned .ipa) +Or use the Apple bundling script: ```bash ./scripts/bundle-apple.sh ``` -- macOS `.app` output: `dist/apple/macos` -- iOS `.app` output: `dist/apple/ios` +Outputs: + +- macOS `.app`: `dist/apple/macos` +- iOS `.app`: `dist/apple/ios` - Unsigned iOS `.ipa`: `dist/apple/ios/*-unsigned.ipa` -By default, the script builds for physical iOS devices (`aarch64-apple-ios`). To build for the simulator instead: +To build for simulator: ```bash IOS_TARGET=aarch64-apple-ios-sim ./scripts/bundle-apple.sh ``` -If your shell exports Homebrew C/C++ flags (for example `LDFLAGS`/`LIBRARY_PATH` for `libiconv`), prefer this script over raw `dx bundle --ios` so those vars are unset for iOS linking. +If your shell exports compiler flags (e.g., `LDFLAGS`), use the script to avoid linking issues. -You can also override icon source/name if needed: +Customize the icon and app name: ```bash APP_NAME="RustySound" IOS_ICON_SOURCE="/absolute/path/to/icon-1024.png" ./scripts/bundle-apple.sh ``` +#### Android + +```bash +./scripts/bundle-android.sh +# or +just bundle-android-release +``` + +This exports release `.apk` to `dist/android`. + +Optional signing environment variables: + +- `ANDROID_KEYSTORE_BASE64` or `ANDROID_KEYSTORE_PATH` +- `ANDROID_KEYSTORE_PASSWORD` +- `ANDROID_KEY_ALIAS` +- `ANDROID_KEY_PASSWORD` (optional) + ## Project Structure ``` @@ -350,20 +342,6 @@ rustysound/ └── tailwind.css # Tailwind CSS styles ``` -## Configuration - -### Server Connection - -1. Launch RustySound -2. Go to Settings - -### Supported Servers - -- **[Navidrome](https://www.navidrome.org/)**: Full feature support -- **Subsonic**: Compatible with Subsonic API v1.16.1+ -- **Airsonic**: Compatible servers -- **Gonic**: Compatible servers - ## Contributing 1. Fork the repository diff --git a/assets/tailwind.css b/assets/tailwind.css index c1b9a46..43907e0 100644 --- a/assets/tailwind.css +++ b/assets/tailwind.css @@ -872,6 +872,9 @@ .max-w-sm { max-width: var(--container-sm); } + .max-w-xl { + max-width: var(--container-xl); + } .max-w-xs { max-width: var(--container-xs); } diff --git a/src/components/app.rs b/src/components/app.rs index 311c4e0..68eb3b8 100644 --- a/src/components/app.rs +++ b/src/components/app.rs @@ -13,11 +13,12 @@ use crate::components::{ }; use crate::db::{ initialize_database, load_playback_state, load_servers, load_settings, save_playback_state, - save_servers, save_settings, AppSettings, PlaybackState, QueueItem, + save_servers, save_settings, save_temporary_queue_snapshot, AppSettings, PlaybackState, + QueueItem, TemporaryQueueSnapshot, }; use crate::diagnostics::{log_perf, PerfTimer}; use crate::offline_audio::{prune_temporary_queue_prefetch_downloads, run_auto_download_pass}; -use chrono::{DateTime, NaiveDateTime}; +use chrono::{DateTime, NaiveDateTime, Utc}; #[cfg(target_arch = "wasm32")] use dioxus::core::{Runtime, RuntimeGuard}; #[cfg(all(feature = "desktop", target_os = "macos"))] @@ -112,6 +113,27 @@ fn home_init_profile_cache_key(profile: HomeFeedLoadProfile) -> &'static str { profile.as_storage() } +fn queue_snapshot_signature(queue: &[Song], queue_index: usize, now_playing: Option<&Song>) -> String { + let mut signature = String::new(); + signature.push_str(&queue.len().to_string()); + signature.push('|'); + signature.push_str(&queue_index.to_string()); + signature.push('|'); + if let Some(song) = now_playing { + signature.push_str(song.server_id.trim()); + signature.push(':'); + signature.push_str(song.id.trim()); + } + signature.push('|'); + for song in queue { + signature.push_str(song.server_id.trim()); + signature.push(':'); + signature.push_str(song.id.trim()); + signature.push(';'); + } + signature +} + #[cfg(all(feature = "desktop", target_os = "macos"))] fn focus_global_search_input() { let _ = document::eval( @@ -889,6 +911,7 @@ pub fn AppShell() -> Element { let mut app_settings = use_signal(AppSettings::default); let mut playback_position = use_signal(|| 0.0f64); let mut last_playback_save = use_signal(|| None::<(String, String, u64, usize, usize)>); + let mut last_queue_snapshot_signature = use_signal(String::new); let mut db_initialized = use_signal(|| false); let mut servers_loaded = use_signal(|| false); let mut settings_loaded = use_signal(|| false); @@ -1893,6 +1916,47 @@ pub fn AppShell() -> Element { } }); + // Save temporary queue snapshots so queue can be restored after tab/app close. + use_effect(move || { + if !db_initialized() || !settings_loaded() { + return; + } + if preview_playback() { + return; + } + + let queue_snapshot = queue(); + if queue_snapshot.is_empty() { + return; + } + + let clamped_queue_index = queue_index().min(queue_snapshot.len().saturating_sub(1)); + let now_playing_snapshot = now_playing(); + let signature = queue_snapshot_signature( + &queue_snapshot, + clamped_queue_index, + now_playing_snapshot.as_ref(), + ); + if signature == last_queue_snapshot_signature() { + return; + } + last_queue_snapshot_signature.set(signature); + + let saved_at_epoch_ms = Utc::now().timestamp_millis(); + let snapshot = TemporaryQueueSnapshot { + id: format!("queue-{saved_at_epoch_ms}"), + saved_at_epoch_ms, + queue: queue_snapshot, + queue_index: clamped_queue_index, + now_playing: now_playing_snapshot, + playback_position: playback_position().max(0.0), + }; + + spawn(async move { + let _ = save_temporary_queue_snapshot(snapshot).await; + }); + }); + let view = use_route::(); let sidebar_signal = sidebar_open.clone(); let can_go_back = navigation.can_go_back(); diff --git a/src/components/song_details/queue_panel.rs b/src/components/song_details/queue_panel.rs index 74202d3..26f7822 100644 --- a/src/components/song_details/queue_panel.rs +++ b/src/components/song_details/queue_panel.rs @@ -94,6 +94,22 @@ fn QueuePanel(props: QueuePanelProps) -> Element { rsx! { div { class: "h-full overflow-y-visible md:overflow-y-auto pr-1 space-y-2", + div { class: "pb-1", + button { + class: if (props.create_queue_busy)() { + "w-full px-3 py-2 rounded-xl bg-zinc-700 text-zinc-300 text-sm cursor-not-allowed" + } else { + "w-full px-3 py-2 rounded-xl bg-emerald-500 hover:bg-emerald-400 text-white text-sm transition-colors" + }, + disabled: (props.create_queue_busy)(), + onclick: on_create_queue, + if (props.create_queue_busy)() { + "Creating queue..." + } else { + "Quick Create Queue" + } + } + } for (index, entry) in props.up_next.iter() { div { key: "{entry.server_id}:{entry.id}:{index}", @@ -235,4 +251,3 @@ fn QueuePanel(props: QueuePanelProps) -> Element { } } } - diff --git a/src/components/views/home.rs b/src/components/views/home.rs index 7b3295e..5912562 100644 --- a/src/components/views/home.rs +++ b/src/components/views/home.rs @@ -1450,16 +1450,15 @@ return (function () { } else { div { class: "overflow-x-auto", div { class: "flex gap-4 pb-2 min-w-min", - for (index , song) in items.iter().take(visible).enumerate() { + for song in items.iter().take(visible) { div { class: "w-32 flex-shrink-0", SongCard { song: song.clone(), onclick: { let song = song.clone(); - let songs_for_queue = items.clone(); move |_| { - queue.set(songs_for_queue.clone()); - queue_index.set(index); + queue.set(vec![song.clone()]); + queue_index.set(0); now_playing.set(Some(song.clone())); is_playing.set(true); } @@ -1523,16 +1522,15 @@ return (function () { "grid-template-columns: repeat(auto-fit, minmax({}px, 1fr));", quick_picks_grid_min_px, ), - for (index , song) in quick_picks_display.iter().enumerate() { + for song in quick_picks_display.iter() { div { class: "w-full min-w-0", SongCard { song: song.clone(), onclick: { let song = song.clone(); - let queue_items = quick_picks_display.clone(); move |_| { - queue.set(queue_items.clone()); - queue_index.set(index); + queue.set(vec![song.clone()]); + queue_index.set(0); now_playing.set(Some(song.clone())); is_playing.set(true); } @@ -3938,4 +3936,3 @@ pub fn SongRow( } } } - diff --git a/src/components/views/queue.rs b/src/components/views/queue.rs index b19a0e4..2e5856a 100644 --- a/src/components/views/queue.rs +++ b/src/components/views/queue.rs @@ -6,7 +6,7 @@ use crate::components::{ generate_queue_extension_from_seed, AddIntent, AddMenuController, AppView, Icon, Navigation, PlaybackPositionSignal, PreviewPlaybackSignal, SeekRequestSignal, }; -use crate::db::AppSettings; +use crate::db::{load_temporary_queue_snapshots, AppSettings, TemporaryQueueSnapshot}; use crate::diagnostics::{log_perf, PerfTimer}; use crate::offline_audio::{is_song_downloaded, prefetch_song_audio}; use dioxus::prelude::*; @@ -39,6 +39,39 @@ async fn queue_search_delay_ms(ms: u64) { gloo_timers::future::TimeoutFuture::new(ms as u32).await; } +fn restore_queue_snapshot( + mut queue: Signal>, + mut queue_index: Signal, + mut now_playing: Signal>, + mut is_playing: Signal, + mut playback_position: Signal, + mut seek_request: Signal>, + mut preview_playback: Signal, + snapshot: TemporaryQueueSnapshot, +) { + if snapshot.queue.is_empty() { + return; + } + let restored_queue = snapshot.queue; + let restored_index = snapshot + .queue_index + .min(restored_queue.len().saturating_sub(1)); + let restored_song = snapshot + .now_playing + .or_else(|| restored_queue.get(restored_index).cloned()) + .or_else(|| restored_queue.first().cloned()); + + queue.set(restored_queue); + queue_index.set(restored_index); + now_playing.set(restored_song.clone()); + playback_position.set(snapshot.playback_position.max(0.0)); + if let Some(song) = restored_song { + seek_request.set(Some((song.id.clone(), snapshot.playback_position.max(0.0)))); + } + preview_playback.set(false); + is_playing.set(false); +} + async fn prefetch_lrclib_lyrics_for_queue(songs: Vec, max_songs: usize) { if songs.is_empty() || max_songs == 0 { return; @@ -89,11 +122,17 @@ pub fn QueueView() -> Element { let lyrics_prefetch_signature = use_signal(String::new); let quick_create_queue_busy = use_signal(|| false); let mut queue_song_menu = use_signal(|| None::<(Song, usize, f64, f64)>); + let saved_queue_snapshots = use_signal(Vec::::new); + let saved_queue_snapshots_loaded = use_signal(|| false); let current_index = queue_index(); let songs: Vec = queue().into_iter().collect(); let queue_len = songs.len(); let current_song = now_playing(); + let saved_queue_snapshot_items = saved_queue_snapshots(); + let can_restore_saved_queue = + saved_queue_snapshots_loaded() && !saved_queue_snapshot_items.is_empty(); + let latest_saved_queue_snapshot = saved_queue_snapshot_items.first().cloned(); let can_quick_create_more = current_song .clone() .or_else(|| songs.get(current_index).cloned()) @@ -149,6 +188,20 @@ pub fn QueueView() -> Element { }); } + { + let saved_queue_snapshots = saved_queue_snapshots.clone(); + let saved_queue_snapshots_loaded = saved_queue_snapshots_loaded.clone(); + use_effect(move || { + let mut saved_queue_snapshots = saved_queue_snapshots.clone(); + let mut saved_queue_snapshots_loaded = saved_queue_snapshots_loaded.clone(); + spawn(async move { + let snapshots = load_temporary_queue_snapshots().await.unwrap_or_default(); + saved_queue_snapshots.set(snapshots); + saved_queue_snapshots_loaded.set(true); + }); + }); + } + let on_preview_song = Rc::new({ let queue_index = queue_index.clone(); let now_playing = now_playing.clone(); @@ -774,22 +827,58 @@ pub fn QueueView() -> Element { p { class: "text-zinc-500 text-sm mt-2", "Use Add Songs above to build a queue." } - button { - class: if quick_create_queue_busy() || !can_quick_create_more { - "mt-4 inline-flex items-center gap-2 rounded-xl border border-zinc-700 bg-zinc-800/60 px-4 py-2 text-sm text-zinc-500 cursor-not-allowed" - } else { - "mt-4 inline-flex items-center gap-2 rounded-xl border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-sm text-zinc-300 hover:text-white hover:border-emerald-500/70 hover:bg-emerald-500/10 transition-colors" - }, - disabled: quick_create_queue_busy() || !can_quick_create_more, - onclick: on_quick_create_more, - Icon { - name: if quick_create_queue_busy() { "loader".to_string() } else { "plus".to_string() }, - class: "w-4 h-4".to_string(), + div { class: "mt-4 grid w-full max-w-xl grid-cols-1 sm:grid-cols-2 gap-2", + button { + class: if quick_create_queue_busy() || !can_quick_create_more { + "inline-flex items-center justify-center gap-2 rounded-xl border border-zinc-700 bg-zinc-800/60 px-4 py-2 text-sm text-zinc-500 cursor-not-allowed" + } else { + "inline-flex items-center justify-center gap-2 rounded-xl border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-sm text-zinc-300 hover:text-white hover:border-emerald-500/70 hover:bg-emerald-500/10 transition-colors" + }, + disabled: quick_create_queue_busy() || !can_quick_create_more, + onclick: on_quick_create_more, + Icon { + name: if quick_create_queue_busy() { "loader".to_string() } else { "plus".to_string() }, + class: "w-4 h-4".to_string(), + } + if quick_create_queue_busy() { + "Adding songs..." + } else { + "Quick Create Queue" + } } - if quick_create_queue_busy() { - "Adding songs..." - } else { - "Quick Create Queue" + button { + class: if can_restore_saved_queue { + "inline-flex items-center justify-center gap-2 rounded-xl border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-sm text-zinc-300 hover:text-white hover:border-emerald-500/70 hover:bg-emerald-500/10 transition-colors" + } else { + "inline-flex items-center justify-center gap-2 rounded-xl border border-zinc-700 bg-zinc-800/60 px-4 py-2 text-sm text-zinc-500 cursor-not-allowed" + }, + disabled: !can_restore_saved_queue, + onclick: { + let latest_saved_queue_snapshot = latest_saved_queue_snapshot.clone(); + let queue = queue.clone(); + let queue_index = queue_index.clone(); + let now_playing = now_playing.clone(); + let is_playing = is_playing.clone(); + let playback_position = playback_position.clone(); + let seek_request = seek_request.clone(); + let preview_playback = preview_playback.clone(); + move |_| { + if let Some(snapshot) = latest_saved_queue_snapshot.clone() { + restore_queue_snapshot( + queue.clone(), + queue_index.clone(), + now_playing.clone(), + is_playing.clone(), + playback_position.clone(), + seek_request.clone(), + preview_playback.clone(), + snapshot, + ); + } + } + }, + Icon { name: "clock".to_string(), class: "w-4 h-4".to_string() } + "Restore Previous Queue" } } } @@ -1195,23 +1284,59 @@ pub fn QueueView() -> Element { } } } - div { class: "flex justify-center pt-4", - button { - class: if quick_create_queue_busy() || !can_quick_create_more { - "inline-flex items-center gap-2 rounded-xl border border-zinc-700 bg-zinc-800/60 px-4 py-2 text-sm text-zinc-500 cursor-not-allowed" - } else { - "inline-flex items-center gap-2 rounded-xl border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-sm text-zinc-300 hover:text-white hover:border-emerald-500/70 hover:bg-emerald-500/10 transition-colors" - }, - disabled: quick_create_queue_busy() || !can_quick_create_more, - onclick: on_quick_create_more, - Icon { - name: if quick_create_queue_busy() { "loader".to_string() } else { "plus".to_string() }, - class: "w-4 h-4".to_string(), + div { class: "pt-4 flex justify-center", + div { class: "grid w-full max-w-xl grid-cols-1 sm:grid-cols-2 gap-2", + button { + class: if quick_create_queue_busy() || !can_quick_create_more { + "inline-flex items-center justify-center gap-2 rounded-xl border border-zinc-700 bg-zinc-800/60 px-4 py-2 text-sm text-zinc-500 cursor-not-allowed" + } else { + "inline-flex items-center justify-center gap-2 rounded-xl border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-sm text-zinc-300 hover:text-white hover:border-emerald-500/70 hover:bg-emerald-500/10 transition-colors" + }, + disabled: quick_create_queue_busy() || !can_quick_create_more, + onclick: on_quick_create_more, + Icon { + name: if quick_create_queue_busy() { "loader".to_string() } else { "plus".to_string() }, + class: "w-4 h-4".to_string(), + } + if quick_create_queue_busy() { + "Adding songs..." + } else { + "Quick Create Queue" + } } - if quick_create_queue_busy() { - "Adding songs..." - } else { - "Quick Create Queue" + button { + class: if can_restore_saved_queue { + "inline-flex items-center justify-center gap-2 rounded-xl border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-sm text-zinc-300 hover:text-white hover:border-emerald-500/70 hover:bg-emerald-500/10 transition-colors" + } else { + "inline-flex items-center justify-center gap-2 rounded-xl border border-zinc-700 bg-zinc-800/60 px-4 py-2 text-sm text-zinc-500 cursor-not-allowed" + }, + disabled: !can_restore_saved_queue, + onclick: { + let latest_saved_queue_snapshot = latest_saved_queue_snapshot.clone(); + let queue = queue.clone(); + let queue_index = queue_index.clone(); + let now_playing = now_playing.clone(); + let is_playing = is_playing.clone(); + let playback_position = playback_position.clone(); + let seek_request = seek_request.clone(); + let preview_playback = preview_playback.clone(); + move |_| { + if let Some(snapshot) = latest_saved_queue_snapshot.clone() { + restore_queue_snapshot( + queue.clone(), + queue_index.clone(), + now_playing.clone(), + is_playing.clone(), + playback_position.clone(), + seek_request.clone(), + preview_playback.clone(), + snapshot, + ); + } + } + }, + Icon { name: "clock".to_string(), class: "w-4 h-4".to_string() } + "Restore Previous Queue" } } } @@ -1840,4 +1965,3 @@ async fn build_queue_add_recommendations( suggestions } - diff --git a/src/db/mod.rs b/src/db/mod.rs index 59d0527..b47b612 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,5 +1,7 @@ use crate::api::{ - default_lyrics_provider_order, models::ServerConfig, normalize_lyrics_provider_order, + default_lyrics_provider_order, + models::{ServerConfig, Song}, + normalize_lyrics_provider_order, }; #[cfg(not(target_arch = "wasm32"))] use crate::storage::app_data_dir; @@ -37,6 +39,9 @@ const SETTINGS_KEY: &str = "rustysound.app_settings"; const PLAYBACK_KEY: &str = "rustysound.playback_state"; #[cfg(target_arch = "wasm32")] const SERVERS_KEY: &str = "rustysound.servers"; +#[cfg(target_arch = "wasm32")] +const TEMP_QUEUE_SNAPSHOTS_KEY: &str = "rustysound.temporary_queue_snapshots"; +const TEMP_QUEUE_SNAPSHOT_LIMIT: usize = 1; /// Repeat mode for playback #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)] @@ -303,6 +308,98 @@ pub struct QueueItem { pub server_id: String, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct TemporaryQueueSnapshot { + pub id: String, + pub saved_at_epoch_ms: i64, + pub queue: Vec, + pub queue_index: usize, + pub now_playing: Option, + pub playback_position: f64, +} + +fn snapshot_signature(snapshot: &TemporaryQueueSnapshot) -> String { + let mut signature = String::new(); + signature.push_str(&snapshot.queue_index.to_string()); + signature.push('|'); + if let Some(song) = snapshot.now_playing.as_ref() { + signature.push_str(song.server_id.trim()); + signature.push(':'); + signature.push_str(song.id.trim()); + } + signature.push('|'); + for song in &snapshot.queue { + signature.push_str(song.server_id.trim()); + signature.push(':'); + signature.push_str(song.id.trim()); + signature.push(';'); + } + signature +} + +fn normalize_queue_snapshots( + snapshots: Vec, +) -> Vec { + let mut snapshots: Vec = snapshots + .into_iter() + .filter_map(|mut snapshot| { + if snapshot.queue.is_empty() { + return None; + } + + snapshot.queue_index = snapshot + .queue_index + .min(snapshot.queue.len().saturating_sub(1)); + snapshot.playback_position = snapshot.playback_position.max(0.0); + if snapshot.id.trim().is_empty() { + snapshot.id = format!("queue-{}", snapshot.saved_at_epoch_ms.max(0)); + } + Some(snapshot) + }) + .collect(); + + snapshots.sort_by(|left, right| { + right + .saved_at_epoch_ms + .cmp(&left.saved_at_epoch_ms) + .then_with(|| left.id.cmp(&right.id)) + }); + snapshots.truncate(TEMP_QUEUE_SNAPSHOT_LIMIT); + snapshots +} + +fn upsert_queue_snapshot( + existing: Vec, + mut incoming: TemporaryQueueSnapshot, +) -> Vec { + if incoming.id.trim().is_empty() { + incoming.id = format!("queue-{}", incoming.saved_at_epoch_ms.max(0)); + } + incoming.queue_index = incoming + .queue_index + .min(incoming.queue.len().saturating_sub(1)); + incoming.playback_position = incoming.playback_position.max(0.0); + + let incoming_signature = snapshot_signature(&incoming); + let mut snapshots = normalize_queue_snapshots(existing); + if let Some(position) = snapshots + .iter() + .position(|snapshot| snapshot_signature(snapshot) == incoming_signature) + { + let mut existing_snapshot = snapshots.remove(position); + existing_snapshot.saved_at_epoch_ms = incoming.saved_at_epoch_ms; + existing_snapshot.queue = incoming.queue; + existing_snapshot.queue_index = incoming.queue_index; + existing_snapshot.now_playing = incoming.now_playing; + existing_snapshot.playback_position = incoming.playback_position; + snapshots.insert(0, existing_snapshot); + } else { + snapshots.insert(0, incoming); + } + + normalize_queue_snapshots(snapshots) +} + // Database operations for native platforms // These run directly on desktop/mobile without needing #[server] @@ -482,6 +579,62 @@ pub async fn load_playback_state() -> Result { } } +#[cfg(not(target_arch = "wasm32"))] +pub async fn save_temporary_queue_snapshot(snapshot: TemporaryQueueSnapshot) -> Result<(), DbError> { + if snapshot.queue.is_empty() { + return Ok(()); + } + + let mut snapshots = load_temporary_queue_snapshots().await?; + snapshots = upsert_queue_snapshot(snapshots, snapshot); + let payload = serde_json::to_string(&snapshots).map_err(|e| DbError::new(e.to_string()))?; + let conn = get_db_connection()?; + conn.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES ('temporary_queue_snapshots', ?1)", + [&payload], + ) + .map_err(|e| DbError::new(e.to_string()))?; + Ok(()) +} + +#[cfg(target_arch = "wasm32")] +pub async fn save_temporary_queue_snapshot(snapshot: TemporaryQueueSnapshot) -> Result<(), StorageError> { + if snapshot.queue.is_empty() { + return Ok(()); + } + + let existing = load_temporary_queue_snapshots().await.unwrap_or_default(); + let snapshots = upsert_queue_snapshot(existing, snapshot); + LocalStorage::set(TEMP_QUEUE_SNAPSHOTS_KEY, snapshots).map_err(|e| e) +} + +#[cfg(not(target_arch = "wasm32"))] +pub async fn load_temporary_queue_snapshots() -> Result, DbError> { + let conn = get_db_connection()?; + let result: Result = conn.query_row( + "SELECT value FROM settings WHERE key = 'temporary_queue_snapshots'", + [], + |row: &rusqlite::Row| row.get(0), + ); + + match result { + Ok(json) => { + let parsed: Vec = + serde_json::from_str(&json).map_err(|e| DbError::new(e.to_string()))?; + Ok(normalize_queue_snapshots(parsed)) + } + Err(_) => Ok(Vec::new()), + } +} + +#[cfg(target_arch = "wasm32")] +pub async fn load_temporary_queue_snapshots() -> Result, StorageError> { + match LocalStorage::get(TEMP_QUEUE_SNAPSHOTS_KEY) { + Ok(snapshots) => Ok(normalize_queue_snapshots(snapshots)), + Err(_) => Ok(Vec::new()), + } +} + #[cfg(not(target_arch = "wasm32"))] pub async fn initialize_database() -> Result<(), DbError> { let conn = get_db_connection()?; From 380f74f5cba1fae5795a693ccae2db5c6202e7e7 Mon Sep 17 00:00:00 2001 From: ad-archer Date: Tue, 14 Apr 2026 19:15:59 -0400 Subject: [PATCH 2/4] feat: enhance back navigation buttons with improved styling and functionality --- assets/tailwind.css | 3 +++ src/components/app.rs | 17 ----------------- src/components/views/album_detail.rs | 11 +++++++---- src/components/views/artist_detail.rs | 11 +++++++---- src/components/views/playlist_detail.rs | 5 +++-- src/components/views/queue.rs | 20 +++++++++++++++++++- 6 files changed, 39 insertions(+), 28 deletions(-) diff --git a/assets/tailwind.css b/assets/tailwind.css index 43907e0..f15c8f8 100644 --- a/assets/tailwind.css +++ b/assets/tailwind.css @@ -563,6 +563,9 @@ .mb-12 { margin-bottom: calc(var(--spacing) * 12); } + .-ml-1 { + margin-left: calc(var(--spacing) * -1); + } .ml-0\.5 { margin-left: calc(var(--spacing) * 0.5); } diff --git a/src/components/app.rs b/src/components/app.rs index 68eb3b8..148c74a 100644 --- a/src/components/app.rs +++ b/src/components/app.rs @@ -1959,7 +1959,6 @@ pub fn AppShell() -> Element { let view = use_route::(); let sidebar_signal = sidebar_open.clone(); - let can_go_back = navigation.can_go_back(); let song_details_open = song_details_state().is_open; let is_startup_bootstrapping = !db_initialized() || !settings_loaded(); let is_home_initializing = home_init_in_progress() && matches!(&view, AppView::HomeView {}); @@ -2027,22 +2026,6 @@ pub fn AppShell() -> Element { class: "w-5 h-5".to_string(), } } - if can_go_back { - button { - class: "p-2 rounded-lg text-zinc-300 hover:text-white hover:bg-zinc-800/60 transition-colors", - aria_label: "Go back", - onclick: { - let navigation = navigation.clone(); - move |_| { - let _ = navigation.go_back(); - } - }, - Icon { - name: "arrow-left".to_string(), - class: "w-5 h-5".to_string(), - } - } - } } div { class: "flex flex-col items-center text-center", span { class: "text-xs uppercase tracking-widest text-zinc-500", diff --git a/src/components/views/album_detail.rs b/src/components/views/album_detail.rs index 27f1c37..069e610 100644 --- a/src/components/views/album_detail.rs +++ b/src/components/views/album_detail.rs @@ -251,14 +251,17 @@ pub fn AlbumDetailView(album_id: String, server_id: String) -> Element { div { class: "space-y-8 overflow-x-hidden", // Back button button { - class: "flex items-center gap-2 text-zinc-400 hover:text-white transition-colors mb-4", + class: "inline-flex items-center justify-center text-zinc-400 hover:text-white transition-colors mb-4 rounded-md p-1 -ml-1", + aria_label: "Go back", + title: "Go back", onclick: move |_| { - if navigation.go_back().is_none() { + if navigation.can_go_back() { + navigation.go_back(); + } else { navigation.navigate_to(AppView::Albums {}); } }, - Icon { name: "prev".to_string(), class: "w-4 h-4".to_string() } - "Back to Albums" + Icon { name: "arrow-left".to_string(), class: "w-5 h-5".to_string() } } { diff --git a/src/components/views/artist_detail.rs b/src/components/views/artist_detail.rs index 8adefa3..a32743a 100644 --- a/src/components/views/artist_detail.rs +++ b/src/components/views/artist_detail.rs @@ -210,14 +210,17 @@ pub fn ArtistDetailView(artist_id: String, server_id: String) -> Element { rsx! { button { - class: "flex items-center gap-2 text-zinc-400 hover:text-white transition-colors mb-4", + class: "inline-flex items-center justify-center text-zinc-400 hover:text-white transition-colors mb-4 rounded-md p-1 -ml-1", + aria_label: "Go back", + title: "Go back", onclick: move |_| { - if navigation.go_back().is_none() { + if navigation.can_go_back() { + navigation.go_back(); + } else { navigation.navigate_to(AppView::ArtistsView {}); } }, - Icon { name: "prev".to_string(), class: "w-4 h-4".to_string() } - "Back to Artists" + Icon { name: "arrow-left".to_string(), class: "w-5 h-5".to_string() } } { diff --git a/src/components/views/playlist_detail.rs b/src/components/views/playlist_detail.rs index c6e090d..acb55e8 100644 --- a/src/components/views/playlist_detail.rs +++ b/src/components/views/playlist_detail.rs @@ -1228,7 +1228,9 @@ pub fn PlaylistDetailView(playlist_id: String, server_id: String) -> Element { button { class: "flex items-center gap-2 text-zinc-400 hover:text-white transition-colors mb-4", onclick: move |_| { - if navigation.go_back().is_none() { + if navigation.can_go_back() { + navigation.go_back(); + } else { navigation.navigate_to(AppView::PlaylistsView {}); } }, @@ -2108,4 +2110,3 @@ async fn build_playlist_add_recommendations( suggestions } - diff --git a/src/components/views/queue.rs b/src/components/views/queue.rs index 2e5856a..a44ff58 100644 --- a/src/components/views/queue.rs +++ b/src/components/views/queue.rs @@ -410,11 +410,29 @@ pub fn QueueView() -> Element { rsx! { div { class: "space-y-8", header { class: "page-header page-header--split", - div { + div { class: "flex items-start gap-3", + button { + class: "inline-flex items-center justify-center rounded-md p-1 -ml-1 text-zinc-400 hover:text-white transition-colors", + aria_label: "Go back", + title: "Go back", + onclick: { + let navigation = navigation.clone(); + move |_| { + if navigation.can_go_back() { + navigation.go_back(); + } else { + navigation.navigate_to(AppView::HomeView {}); + } + } + }, + Icon { name: "arrow-left".to_string(), class: "w-5 h-5".to_string() } + } + div { h1 { class: "page-title", "Play Queue" } p { class: "page-subtitle", "{songs.len()} songs • {format_duration(songs.iter().map(|s| s.duration).sum())}" } + } } div { class: "flex items-center gap-2", From d96443fac7c5488bd91615d3ea9b1156ceb73af2 Mon Sep 17 00:00:00 2001 From: ad-archer Date: Mon, 27 Apr 2026 12:37:30 -0400 Subject: [PATCH 3/4] fix issues with reloading pages --- src/components/app.rs | 71 +++- src/components/app_view.rs | 34 +- src/components/mod.rs | 2 +- src/components/navigation.rs | 119 +++++- src/components/player/controls.rs | 2 +- src/components/player/mod.rs | 4 +- src/components/song_details/mod.rs | 14 +- src/components/views/album_detail.rs | 526 +++++++++++++----------- src/components/views/album_song_row.rs | 3 +- src/components/views/downloads.rs | 14 +- src/components/views/favorites.rs | 2 +- src/components/views/home.rs | 16 +- src/components/views/playlist_detail.rs | 41 +- src/components/views/queue.rs | 8 +- src/components/views/random.rs | 2 +- src/components/views/settings.rs | 6 +- src/components/views/songs.rs | 5 +- 17 files changed, 538 insertions(+), 331 deletions(-) diff --git a/src/components/app.rs b/src/components/app.rs index 148c74a..755ec35 100644 --- a/src/components/app.rs +++ b/src/components/app.rs @@ -5,18 +5,18 @@ use crate::cache_service::{ }; use crate::components::views::home_layout::HomeFeedLoadProfile; use crate::components::{ - ios_audio_log_snapshot, ios_diag_log, view_label, AddIntent, AddMenuController, - AddToMenuOverlay, AppView, AudioController, AudioState, HomeRefreshSignal, Icon, - IsPlayingSignal, Navigation, PlaybackPositionSignal, Player, PreviewPlaybackSignal, - SeekRequestSignal, ShuffleEnabledSignal, Sidebar, SidebarOpenSignal, SongDetailsController, - SongDetailsOverlay, SongDetailsState, VolumeSignal, + AddIntent, AddMenuController, AddToMenuOverlay, AppView, AudioController, AudioState, + HomeRefreshSignal, Icon, IsPlayingSignal, Navigation, PlaybackPositionSignal, Player, + PreviewPlaybackSignal, SeekRequestSignal, ShuffleEnabledSignal, Sidebar, SidebarOpenSignal, + SongDetailsController, SongDetailsOverlay, SongDetailsState, VolumeSignal, + ios_audio_log_snapshot, ios_diag_log, view_instance_key, view_label, }; use crate::db::{ - initialize_database, load_playback_state, load_servers, load_settings, save_playback_state, - save_servers, save_settings, save_temporary_queue_snapshot, AppSettings, PlaybackState, - QueueItem, TemporaryQueueSnapshot, + AppSettings, PlaybackState, QueueItem, TemporaryQueueSnapshot, initialize_database, + load_playback_state, load_servers, load_settings, save_playback_state, save_servers, + save_settings, save_temporary_queue_snapshot, }; -use crate::diagnostics::{log_perf, PerfTimer}; +use crate::diagnostics::{PerfTimer, log_perf}; use crate::offline_audio::{prune_temporary_queue_prefetch_downloads, run_auto_download_pass}; use chrono::{DateTime, NaiveDateTime, Utc}; #[cfg(target_arch = "wasm32")] @@ -25,14 +25,15 @@ use dioxus::core::{Runtime, RuntimeGuard}; use dioxus::desktop::use_muda_event_handler; use dioxus_router::components::Outlet; #[cfg(target_arch = "wasm32")] -use wasm_bindgen::closure::Closure; -#[cfg(target_arch = "wasm32")] use wasm_bindgen::JsCast; #[cfg(target_arch = "wasm32")] +use wasm_bindgen::closure::Closure; +#[cfg(target_arch = "wasm32")] use web_sys::window; // Re-export RepeatMode for other components pub use crate::db::RepeatMode; use dioxus::prelude::*; +use dioxus_router::use_navigator; #[cfg(target_arch = "wasm32")] const HISTORY_SWIPE_THRESHOLD: f64 = 100.0; @@ -113,7 +114,11 @@ fn home_init_profile_cache_key(profile: HomeFeedLoadProfile) -> &'static str { profile.as_storage() } -fn queue_snapshot_signature(queue: &[Song], queue_index: usize, now_playing: Option<&Song>) -> String { +fn queue_snapshot_signature( + queue: &[Song], + queue_index: usize, + now_playing: Option<&Song>, +) -> String { let mut signature = String::new(); signature.push_str(&queue.len().to_string()); signature.push('|'); @@ -903,6 +908,10 @@ async fn initialize_home_cache( pub fn AppShell() -> Element { let mut servers = use_signal(Vec::::new); let current_view = use_route::(); + let router_navigator = use_navigator(); + let mut current_view_signal = use_signal(|| current_view.clone()); + let pending_navigation_target = use_signal(|| None::); + let outlet_key = view_instance_key(¤t_view); let now_playing = use_signal(|| None::); let queue = use_signal(Vec::::new); let mut queue_index = use_signal(|| 0usize); @@ -933,7 +942,38 @@ pub fn AppShell() -> Element { let audio_state = use_signal(AudioState::default); let preview_playback = use_signal(|| false); let sidebar_open = use_signal(|| false); - let navigation = Navigation::new(); + use_effect({ + let current_view = current_view.clone(); + move || { + if current_view_signal() != current_view { + current_view_signal.set(current_view.clone()); + } + } + }); + + let navigation = Navigation::new( + router_navigator, + current_view_signal, + pending_navigation_target, + ); + use_effect({ + let navigation = navigation; + move || { + let current_view = current_view_signal(); + let pending_target = pending_navigation_target(); + if pending_target.is_some() && matches!(current_view, AppView::HomeView {}) { + eprintln!( + "[nav.refresh.effect] current={} pending={}", + current_view, + pending_target + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| "".to_string()) + ); + navigation.resume_pending_navigation(); + } + } + }); let seek_request = use_signal(|| None::<(String, f64)>); let mut resume_bookmark_loaded = use_signal(|| false); #[cfg(target_arch = "wasm32")] @@ -2081,7 +2121,10 @@ pub fn AppShell() -> Element { } } } - Outlet:: {} + div { + key: "{outlet_key}", + Outlet:: {} + } } } Player {} diff --git a/src/components/app_view.rs b/src/components/app_view.rs index 9c5718c..d2193d0 100644 --- a/src/components/app_view.rs +++ b/src/components/app_view.rs @@ -1,7 +1,7 @@ //! Defines the shared application view state. -use crate::components::views::*; use crate::components::AppShell; +use crate::components::views::*; use dioxus::prelude::*; #[derive(Routable, Clone, PartialEq)] @@ -74,3 +74,35 @@ pub fn view_label(view: &AppView) -> &'static str { AppView::PlaylistDetailView { .. } => "Playlist", } } + +pub fn view_instance_key(view: &AppView) -> String { + match view { + AppView::HomeView {} => "home".to_string(), + AppView::SearchView {} => "search".to_string(), + AppView::SongsView {} => "songs".to_string(), + AppView::Albums {} => "albums".to_string(), + AppView::AlbumsWithGenre { genre } => format!("albums:{genre}"), + AppView::ArtistsView {} => "artists".to_string(), + AppView::PlaylistsView {} => "playlists".to_string(), + AppView::RadioView {} => "radio".to_string(), + AppView::BookmarksView {} => "bookmarks".to_string(), + AppView::FavoritesView {} => "favorites".to_string(), + AppView::DownloadsView {} => "downloads".to_string(), + AppView::RandomView {} => "random".to_string(), + AppView::SettingsView {} => "settings".to_string(), + AppView::StatsView {} => "stats".to_string(), + AppView::QueueView {} => "queue".to_string(), + AppView::AlbumDetailView { + album_id, + server_id, + } => format!("album:{server_id}:{album_id}"), + AppView::ArtistDetailView { + artist_id, + server_id, + } => format!("artist:{server_id}:{artist_id}"), + AppView::PlaylistDetailView { + playlist_id, + server_id, + } => format!("playlist:{server_id}:{playlist_id}"), + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs index ae47566..ac8439d 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -41,7 +41,7 @@ pub struct ShuffleEnabledSignal(pub Signal); pub use add_to_menu::*; pub use app::*; -pub use app_view::{view_label, AppView}; +pub use app_view::{AppView, view_instance_key, view_label}; pub use audio_manager::*; pub use icons::*; pub use navigation::Navigation; diff --git a/src/components/navigation.rs b/src/components/navigation.rs index 2009411..e5a075f 100644 --- a/src/components/navigation.rs +++ b/src/components/navigation.rs @@ -1,14 +1,65 @@ use dioxus::prelude::*; +use dioxus_router::Navigator; use crate::components::app_view::AppView; #[cfg(target_arch = "wasm32")] use wasm_bindgen::JsCast; #[cfg(target_arch = "wasm32")] -use web_sys::{window, HtmlElement}; +use web_sys::{HtmlElement, window}; -#[derive(Clone, Copy, Default)] -pub struct Navigation; +#[derive(Clone, Copy)] +pub struct Navigation( + pub Navigator, + pub Signal, + pub Signal>, +); + +fn should_refresh_detail_route(current: &AppView, target: &AppView) -> bool { + match (current, target) { + ( + AppView::AlbumDetailView { + album_id: current_album_id, + server_id: current_server_id, + }, + AppView::AlbumDetailView { + album_id: target_album_id, + server_id: target_server_id, + }, + ) => current_album_id != target_album_id || current_server_id != target_server_id, + ( + AppView::ArtistDetailView { + artist_id: current_artist_id, + server_id: current_server_id, + }, + AppView::ArtistDetailView { + artist_id: target_artist_id, + server_id: target_server_id, + }, + ) => current_artist_id != target_artist_id || current_server_id != target_server_id, + ( + AppView::PlaylistDetailView { + playlist_id: current_playlist_id, + server_id: current_server_id, + }, + AppView::PlaylistDetailView { + playlist_id: target_playlist_id, + server_id: target_server_id, + }, + ) => current_playlist_id != target_playlist_id || current_server_id != target_server_id, + _ => false, + } +} + +#[cfg(not(target_arch = "wasm32"))] +async fn route_refresh_pause() { + tokio::time::sleep(std::time::Duration::from_millis(16)).await; +} + +#[cfg(target_arch = "wasm32")] +async fn route_refresh_pause() { + gloo_timers::future::TimeoutFuture::new(16).await; +} fn reset_main_scroll_position() { #[cfg(target_arch = "wasm32")] @@ -47,38 +98,76 @@ fn reset_main_scroll_position() { } impl Navigation { - pub fn new() -> Self { - Self + pub fn new( + navigator: Navigator, + current_view: Signal, + pending_target: Signal>, + ) -> Self { + Self(navigator, current_view, pending_target) } pub fn navigate_to(&self, target: AppView) { - let navigator = navigator(); - navigator.push(target); + let current_view = self.1(); + if should_refresh_detail_route(¤t_view, &target) { + let mut current_view_signal = self.1; + let mut pending_target = self.2; + eprintln!( + "[nav.refresh.start] current={} target={}", + current_view, target + ); + pending_target.set(Some(target)); + self.0.replace(AppView::HomeView {}); + current_view_signal.set(AppView::HomeView {}); + reset_main_scroll_position(); + return; + } + + self.0.push(target.clone()); + let mut current_view_signal = self.1; + current_view_signal.set(target); reset_main_scroll_position(); } + pub fn resume_pending_navigation(&self) { + if !matches!(self.1(), AppView::HomeView {}) { + return; + } + + let Some(target) = self.2() else { + return; + }; + + let navigator = self.0; + let mut current_view_signal = self.1; + let mut pending_target = self.2; + pending_target.set(None); + eprintln!("[nav.refresh.resume] target={}", target); + spawn(async move { + route_refresh_pause().await; + navigator.replace(target.clone()); + current_view_signal.set(target); + reset_main_scroll_position(); + }); + } + pub fn can_go_back(&self) -> bool { - let navigator = navigator(); - navigator.can_go_back() + self.0.can_go_back() } pub fn go_back(&self) -> Option { - let navigator = navigator(); - navigator.go_back(); + self.0.go_back(); reset_main_scroll_position(); None // Router handles the navigation, we don't need to return the view } #[cfg(target_arch = "wasm32")] pub fn can_go_forward(&self) -> bool { - let navigator = navigator(); - navigator.can_go_forward() + self.0.can_go_forward() } #[cfg(target_arch = "wasm32")] pub fn go_forward(&self) -> Option { - let navigator = navigator(); - navigator.go_forward(); + self.0.go_forward(); reset_main_scroll_position(); None // Router handles the navigation, we don't need to return the view } diff --git a/src/components/player/controls.rs b/src/components/player/controls.rs index 0fcce3e..3054747 100644 --- a/src/components/player/controls.rs +++ b/src/components/player/controls.rs @@ -3,7 +3,7 @@ use crate::components::audio_manager::{ apply_collection_shuffle_mode, queue_should_generate_similar_on_end, spawn_shuffle_queue, }; use crate::components::{ - ios_diag_log, seek_to, AddIntent, AddMenuController, AudioState, Icon, PlaybackPositionSignal, + AddIntent, AddMenuController, AudioState, Icon, PlaybackPositionSignal, ios_diag_log, seek_to, }; use crate::db::{AppSettings, RepeatMode}; use dioxus::prelude::*; diff --git a/src/components/player/mod.rs b/src/components/player/mod.rs index 584cd6a..8b02923 100644 --- a/src/components/player/mod.rs +++ b/src/components/player/mod.rs @@ -2,8 +2,8 @@ use crate::api::models::format_duration; use crate::api::*; use crate::components::views::artist_links::ArtistNameLinks; use crate::components::{ - seek_to, AppView, AudioState, Icon, Navigation, PlaybackPositionSignal, SongDetailsController, - VolumeSignal, + AppView, AudioState, Icon, Navigation, PlaybackPositionSignal, SongDetailsController, + VolumeSignal, seek_to, }; use dioxus::prelude::*; diff --git a/src/components/song_details/mod.rs b/src/components/song_details/mod.rs index d1c916e..8da689d 100644 --- a/src/components/song_details/mod.rs +++ b/src/components/song_details/mod.rs @@ -1,16 +1,16 @@ //! Song-details overlay, panels, and shared helpers. use crate::api::{ - fetch_lyrics_with_fallback, format_duration, normalize_lyrics_provider_order, - search_lyrics_candidates, LyricLine, LyricsQuery, LyricsResult, LyricsSearchCandidate, - NavidromeClient, ServerConfig, Song, + LyricLine, LyricsQuery, LyricsResult, LyricsSearchCandidate, NavidromeClient, ServerConfig, + Song, fetch_lyrics_with_fallback, format_duration, normalize_lyrics_provider_order, + search_lyrics_candidates, }; use crate::components::views::artist_links::{parse_artist_names, resolve_artist_id_for_name}; use crate::components::{ - apply_collection_shuffle_mode, generate_queue_extension_from_seed, - queue_should_generate_similar_on_end, seek_to, spawn_shuffle_queue, AddIntent, - AddMenuController, AppView, AudioState, Icon, Navigation, PlaybackPositionSignal, - SidebarOpenSignal, VolumeSignal, + AddIntent, AddMenuController, AppView, AudioState, Icon, Navigation, PlaybackPositionSignal, + SidebarOpenSignal, VolumeSignal, apply_collection_shuffle_mode, + generate_queue_extension_from_seed, queue_should_generate_similar_on_end, seek_to, + spawn_shuffle_queue, }; use crate::db::{AppSettings, RepeatMode}; use dioxus::prelude::*; diff --git a/src/components/views/album_detail.rs b/src/components/views/album_detail.rs index 069e610..7896e2a 100644 --- a/src/components/views/album_detail.rs +++ b/src/components/views/album_detail.rs @@ -43,13 +43,28 @@ pub fn AlbumDetailView(album_id: String, server_id: String) -> Element { let mut show_album_menu = use_signal(|| false); let mut album_menu_x = use_signal(|| 0f64); let mut album_menu_y = use_signal(|| 0f64); + let mut current_album_id = use_signal(|| album_id.clone()); + let mut current_server_id = use_signal(|| server_id.clone()); - let server = servers().into_iter().find(|s| s.id == server_id); + use_effect({ + let album_id = album_id.clone(); + let server_id = server_id.clone(); + move || { + if current_album_id() != album_id { + current_album_id.set(album_id.clone()); + show_album_menu.set(false); + } + if current_server_id() != server_id { + current_server_id.set(server_id.clone()); + show_album_menu.set(false); + } + } + }); - let album_id_for_resource = album_id.clone(); let album_data = use_resource(move || { - let server = server.clone(); - let album_id = album_id_for_resource.clone(); + let server_id = current_server_id(); + let album_id = current_album_id(); + let server = servers().into_iter().find(|s| s.id == server_id); async move { if let Some(server) = server { let client = NavidromeClient::new(server); @@ -61,8 +76,8 @@ pub fn AlbumDetailView(album_id: String, server_id: String) -> Element { }); let on_play_all = { - let source_server_id = server_id.clone(); - let source_album_id = album_id.clone(); + let source_server_id = current_server_id.clone(); + let source_album_id = current_album_id.clone(); let album_data_ref = album_data.clone(); let app_settings = app_settings.clone(); let mut download_status = download_status.clone(); @@ -89,7 +104,7 @@ pub fn AlbumDetailView(album_id: String, server_id: String) -> Element { let playable = assign_collection_queue_meta( playable, QueueSourceKind::Album, - format!("{}::{}", source_server_id, source_album_id), + format!("{}::{}", source_server_id(), source_album_id()), ); queue.set(playable.clone()); queue_index.set(0); @@ -274,280 +289,295 @@ pub fn AlbumDetailView(album_id: String, server_id: String) -> Element { // Song list match album_data() { Some(Some((album, songs))) => { - let cover_art_id = album - .cover_art - .as_ref() - .filter(|value| !value.trim().is_empty()) - .cloned() - .or_else(|| { - songs.iter().find_map(|song| { - song.cover_art - .as_ref() - .filter(|value| !value.trim().is_empty()) - .cloned() - }) - }) - .or_else(|| { - if album.id.trim().is_empty() { - None - } else { - Some(album.id.clone()) + let requested_album_id = current_album_id(); + let requested_server_id = current_server_id(); + let server_matches = album.server_id.is_empty() + || album.server_id == requested_server_id; + if album.id != requested_album_id || !server_matches { + rsx! { + div { class: "flex items-center justify-center py-20", + Icon { + name: "loader".to_string(), + class: "w-8 h-8 text-zinc-500".to_string(), + } } - }); + } + } else { + let cover_art_id = album + .cover_art + .as_ref() + .filter(|value| !value.trim().is_empty()) + .cloned() + .or_else(|| { + songs.iter().find_map(|song| { + song.cover_art + .as_ref() + .filter(|value| !value.trim().is_empty()) + .cloned() + }) + }) + .or_else(|| { + if album.id.trim().is_empty() { + None + } else { + Some(album.id.clone()) + } + }); - let cover_url = servers() - .iter() - .find(|s| s.id == album.server_id) - .and_then(|server| cover_art_id.as_ref().map(|cover_art_id| { - let client = NavidromeClient::new(server.clone()); - client.get_cover_art_url(cover_art_id, 500) - })); - let downloaded_song_count = - songs.iter().filter(|song| is_song_downloaded(song)).count(); - let album_downloaded = is_album_downloaded(&album.server_id, &album.id); - let album_fully_downloaded = - !songs.is_empty() && downloaded_song_count >= songs.len(); - rsx! { - div { class: "flex flex-col md:flex-row gap-8 mb-8 overflow-x-hidden items-center md:items-end", - div { class: "w-64 h-64 rounded-2xl bg-zinc-800 overflow-hidden shadow-2xl flex-shrink-0 mx-auto md:mx-0", - { - match cover_url { - Some(url) => rsx! { - img { - src: "{url}", - alt: "{album.name} cover", - class: "w-full h-full object-cover", - loading: "lazy", + let cover_url = servers() + .iter() + .find(|s| s.id == album.server_id) + .and_then(|server| cover_art_id.as_ref().map(|cover_art_id| { + let client = NavidromeClient::new(server.clone()); + client.get_cover_art_url(cover_art_id, 500) + })); + let downloaded_song_count = + songs.iter().filter(|song| is_song_downloaded(song)).count(); + let album_downloaded = is_album_downloaded(&album.server_id, &album.id); + let album_fully_downloaded = + !songs.is_empty() && downloaded_song_count >= songs.len(); + rsx! { + div { class: "flex flex-col md:flex-row gap-8 mb-8 overflow-x-hidden items-center md:items-end", + div { class: "w-64 h-64 rounded-2xl bg-zinc-800 overflow-hidden shadow-2xl flex-shrink-0 mx-auto md:mx-0", + { + match cover_url { + Some(url) => rsx! { + img { + src: "{url}", + alt: "{album.name} cover", + class: "w-full h-full object-cover", + loading: "lazy", + } + }, + None => rsx! { + div { class: "w-full h-full flex items-center justify-center bg-gradient-to-br from-zinc-700 to-zinc-800", + Icon { + name: "album".to_string(), + class: "w-20 h-20 text-zinc-500".to_string(), + } + } + }, + } + } + } + div { class: "flex flex-col justify-end max-w-full text-center md:text-left", + p { class: "text-sm text-zinc-400 uppercase tracking-wide mb-2", "Album" } + div { class: "flex flex-wrap items-baseline gap-x-2 gap-y-1 justify-center md:justify-start mb-2 max-w-full", + h1 { + class: "text-3xl md:text-4xl font-bold text-white max-w-full", + style: "word-break: break-word; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;", + "{album.name}" + } + span { class: "text-zinc-500", "•" } + div { class: "flex items-center gap-2 max-w-full min-w-0", + ArtistNameLinks { + artist_text: album.artist.clone(), + server_id: album.server_id.clone(), + fallback_artist_id: album.artist_id.clone(), + container_class: "inline-flex max-w-full min-w-0 items-center gap-1 text-lg text-zinc-300".to_string(), + button_class: "inline-flex max-w-fit truncate text-left hover:text-emerald-400 transition-colors".to_string(), + separator_class: "text-zinc-500".to_string(), } - }, - None => rsx! { - div { class: "w-full h-full flex items-center justify-center bg-gradient-to-br from-zinc-700 to-zinc-800", + if album_downloaded { Icon { - name: "album".to_string(), - class: "w-20 h-20 text-zinc-500".to_string(), + name: "download".to_string(), + class: "w-4 h-4 text-emerald-400 flex-shrink-0".to_string(), } } - }, + } } - } - } - div { class: "flex flex-col justify-end max-w-full text-center md:text-left", - p { class: "text-sm text-zinc-400 uppercase tracking-wide mb-2", "Album" } - div { class: "flex flex-wrap items-baseline gap-x-2 gap-y-1 justify-center md:justify-start mb-2 max-w-full", - h1 { - class: "text-3xl md:text-4xl font-bold text-white max-w-full", - style: "word-break: break-word; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;", - "{album.name}" + div { class: "flex items-center gap-4 text-sm text-zinc-400 justify-center md:justify-start", + if let Some(year) = album.year { + span { "{year}" } + } + span { "{album.song_count} songs" } + span { "{format_duration(album.duration / 1000)}" } + span { "{downloaded_song_count} downloaded" } } - span { class: "text-zinc-500", "•" } - div { class: "flex items-center gap-2 max-w-full min-w-0", - ArtistNameLinks { - artist_text: album.artist.clone(), - server_id: album.server_id.clone(), - fallback_artist_id: album.artist_id.clone(), - container_class: "inline-flex max-w-full min-w-0 items-center gap-1 text-lg text-zinc-300".to_string(), - button_class: "inline-flex max-w-fit truncate text-left hover:text-emerald-400 transition-colors".to_string(), - separator_class: "text-zinc-500".to_string(), + div { class: "mt-6 w-full max-w-sm grid grid-cols-4 gap-2 md:max-w-none md:flex md:flex-wrap md:gap-3 justify-center md:justify-start", + button { + class: "col-span-1 p-3 rounded-full bg-emerald-500 hover:bg-emerald-400 text-white font-medium transition-colors flex items-center justify-center gap-2 md:px-8", + onclick: on_play_all, + title: "Play album", + Icon { name: "play".to_string(), class: "w-5 h-5".to_string() } + span { class: "hidden md:inline", "Play" } } - if album_downloaded { + button { + class: if download_busy() { + "col-span-1 p-3 rounded-full border border-zinc-700 text-zinc-500 cursor-not-allowed flex items-center justify-center" + } else if album_fully_downloaded { + "col-span-1 p-3 rounded-full bg-emerald-500 text-white hover:bg-emerald-400 transition-colors flex items-center justify-center" + } else { + "col-span-1 p-3 rounded-full border border-emerald-500/60 text-emerald-300 hover:text-white hover:border-emerald-400 transition-colors flex items-center justify-center" + }, + disabled: download_busy(), + onclick: on_download_album, + title: if download_busy() { + "Downloading album" + } else if album_fully_downloaded { + "Album fully downloaded" + } else { + "Download album" + }, Icon { - name: "download".to_string(), - class: "w-4 h-4 text-emerald-400 flex-shrink-0".to_string(), + name: if download_busy() { + "loader".to_string() + } else if album_fully_downloaded { + "check".to_string() + } else { + "download".to_string() + }, + class: "w-5 h-5".to_string(), } } - } - } - div { class: "flex items-center gap-4 text-sm text-zinc-400 justify-center md:justify-start", - if let Some(year) = album.year { - span { "{year}" } - } - span { "{album.song_count} songs" } - span { "{format_duration(album.duration / 1000)}" } - span { "{downloaded_song_count} downloaded" } - } - div { class: "mt-6 w-full max-w-sm grid grid-cols-4 gap-2 md:max-w-none md:flex md:flex-wrap md:gap-3 justify-center md:justify-start", - button { - class: "col-span-1 p-3 rounded-full bg-emerald-500 hover:bg-emerald-400 text-white font-medium transition-colors flex items-center justify-center gap-2 md:px-8", - onclick: on_play_all, - title: "Play album", - Icon { name: "play".to_string(), class: "w-5 h-5".to_string() } - span { class: "hidden md:inline", "Play" } - } - button { - class: if download_busy() { - "col-span-1 p-3 rounded-full border border-zinc-700 text-zinc-500 cursor-not-allowed flex items-center justify-center" - } else if album_fully_downloaded { - "col-span-1 p-3 rounded-full bg-emerald-500 text-white hover:bg-emerald-400 transition-colors flex items-center justify-center" - } else { - "col-span-1 p-3 rounded-full border border-emerald-500/60 text-emerald-300 hover:text-white hover:border-emerald-400 transition-colors flex items-center justify-center" - }, - disabled: download_busy(), - onclick: on_download_album, - title: if download_busy() { - "Downloading album" - } else if album_fully_downloaded { - "Album fully downloaded" - } else { - "Download album" - }, - Icon { - name: if download_busy() { - "loader".to_string() - } else if album_fully_downloaded { - "check".to_string() + button { + class: if shuffle_enabled() { + "col-span-1 p-3 rounded-full bg-emerald-500 text-white hover:bg-emerald-400 transition-colors flex items-center justify-center" } else { - "download".to_string() + "col-span-1 p-3 rounded-full border border-zinc-700 text-zinc-300 hover:text-white hover:border-emerald-500/60 transition-colors flex items-center justify-center" }, - class: "w-5 h-5".to_string(), + onclick: on_toggle_shuffle, + title: if shuffle_enabled() { + "Shuffle is on" + } else { + "Shuffle is off" + }, + Icon { + name: "shuffle".to_string(), + class: "w-5 h-5".to_string(), + } } - } - button { - class: if shuffle_enabled() { - "col-span-1 p-3 rounded-full bg-emerald-500 text-white hover:bg-emerald-400 transition-colors flex items-center justify-center" - } else { - "col-span-1 p-3 rounded-full border border-zinc-700 text-zinc-300 hover:text-white hover:border-emerald-500/60 transition-colors flex items-center justify-center" - }, - onclick: on_toggle_shuffle, - title: if shuffle_enabled() { - "Shuffle is on" - } else { - "Shuffle is off" - }, - Icon { - name: "shuffle".to_string(), - class: "w-5 h-5".to_string(), + button { + class: "col-span-1 p-3 rounded-full border border-zinc-700 text-zinc-300 hover:text-white hover:border-emerald-500/60 transition-colors flex items-center justify-center", + onclick: move |evt: MouseEvent| { + evt.stop_propagation(); + let coords = evt.client_coordinates(); + album_menu_x.set(coords.x); + album_menu_y.set(coords.y); + show_album_menu.set(!show_album_menu()); + }, + title: "More album actions", + Icon { name: "more-horizontal".to_string(), class: "w-5 h-5".to_string() } } } - button { - class: "col-span-1 p-3 rounded-full border border-zinc-700 text-zinc-300 hover:text-white hover:border-emerald-500/60 transition-colors flex items-center justify-center", - onclick: move |evt: MouseEvent| { - evt.stop_propagation(); - let coords = evt.client_coordinates(); - album_menu_x.set(coords.x); - album_menu_y.set(coords.y); - show_album_menu.set(!show_album_menu()); - }, - title: "More album actions", - Icon { name: "more-horizontal".to_string(), class: "w-5 h-5".to_string() } - } - } - if show_album_menu() { - div { - class: "fixed inset-0 z-[9998]", - onclick: move |evt: MouseEvent| { - evt.stop_propagation(); - show_album_menu.set(false); - }, - } - div { - class: "fixed z-[9999] w-52 rounded-xl border border-zinc-700 bg-zinc-900/95 shadow-2xl p-1.5 space-y-1", - style: anchored_menu_style( - album_menu_x(), - album_menu_y(), - 208.0, - 320.0, - ), - onclick: move |evt: MouseEvent| evt.stop_propagation(), - if album.artist_id.is_some() { + if show_album_menu() { + div { + class: "fixed inset-0 z-[9998]", + onclick: move |evt: MouseEvent| { + evt.stop_propagation(); + show_album_menu.set(false); + }, + } + div { + class: "fixed z-[9999] w-52 rounded-xl border border-zinc-700 bg-zinc-900/95 shadow-2xl p-1.5 space-y-1", + style: anchored_menu_style( + album_menu_x(), + album_menu_y(), + 208.0, + 320.0, + ), + onclick: move |evt: MouseEvent| evt.stop_propagation(), + if album.artist_id.is_some() { + button { + class: "w-full flex items-center gap-2 px-2.5 py-2 rounded-lg text-sm text-zinc-200 hover:bg-zinc-800/80 transition-colors", + onclick: on_view_artist_from_menu, + Icon { + name: "artist".to_string(), + class: "w-4 h-4".to_string(), + } + "View artist" + } + } button { class: "w-full flex items-center gap-2 px-2.5 py-2 rounded-lg text-sm text-zinc-200 hover:bg-zinc-800/80 transition-colors", - onclick: on_view_artist_from_menu, + onclick: on_open_album_menu, Icon { - name: "artist".to_string(), + name: "plus".to_string(), class: "w-4 h-4".to_string(), } - "View artist" + "Add to..." } - } - button { - class: "w-full flex items-center gap-2 px-2.5 py-2 rounded-lg text-sm text-zinc-200 hover:bg-zinc-800/80 transition-colors", - onclick: on_open_album_menu, - Icon { - name: "plus".to_string(), - class: "w-4 h-4".to_string(), + div { class: "px-2.5 pt-1 text-[11px] uppercase tracking-wide text-zinc-500", + "Rating" } - "Add to..." - } - div { class: "px-2.5 pt-1 text-[11px] uppercase tracking-wide text-zinc-500", - "Rating" - } - div { class: "flex items-center gap-1 px-2 pb-1", - for i in 1u32..=5u32 { - button { - class: "p-1 rounded text-amber-400 hover:text-amber-300 transition-colors", - onclick: make_on_set_album_rating(i), - Icon { - name: if i <= album_rating() { "star-filled".to_string() } else { "star".to_string() }, - class: "w-3.5 h-3.5".to_string(), + div { class: "flex items-center gap-1 px-2 pb-1", + for i in 1u32..=5u32 { + button { + class: "p-1 rounded text-amber-400 hover:text-amber-300 transition-colors", + onclick: make_on_set_album_rating(i), + Icon { + name: if i <= album_rating() { "star-filled".to_string() } else { "star".to_string() }, + class: "w-3.5 h-3.5".to_string(), + } } } } } } - } - if let Some(status) = download_status() { - p { class: "text-xs text-zinc-500 mt-2", "{status}" } + if let Some(status) = download_status() { + p { class: "text-xs text-zinc-500 mt-2", "{status}" } + } } } - } - div { class: "space-y-1", - for (index , song) in songs.iter().enumerate() { - { - let song_clone = song.clone(); - let album_source_id = format!( - "{}::{}", - album.server_id.clone(), - album.id.clone() - ); - let songs_for_queue = songs.clone(); - let app_settings = app_settings.clone(); - let mut download_status = download_status.clone(); - rsx! { - AlbumSongRow { - song: song.clone(), - index: index + 1, - onclick: move |_| { - let settings = app_settings(); - let playable = if settings.offline_mode { - songs_for_queue - .iter() - .filter(|song| is_song_downloaded(song)) - .cloned() - .collect::>() - } else { - songs_for_queue.clone() - }; - if playable.is_empty() { - download_status.set(Some( - "No downloaded songs in this album are available for offline playback." - .to_string(), - )); - return; - } - let playable = assign_collection_queue_meta( - playable, - QueueSourceKind::Album, - album_source_id.clone(), - ); - let target_index = playable - .iter() - .position(|entry| entry.id == song_clone.id) - .unwrap_or(0); - queue.set(playable.clone()); - queue_index.set(target_index); - now_playing.set(Some(playable[target_index].clone())); - is_playing.set(true); - let shuffle = shuffle_enabled(); - if shuffle { - let _ = apply_collection_shuffle_mode( - queue.clone(), - queue_index.clone(), - now_playing.clone(), - true, + div { class: "space-y-1", + for (index , song) in songs.iter().enumerate() { + { + let song_clone = song.clone(); + let album_source_id = format!( + "{}::{}", + album.server_id.clone(), + album.id.clone() + ); + let songs_for_queue = songs.clone(); + let app_settings = app_settings.clone(); + let mut download_status = download_status.clone(); + rsx! { + AlbumSongRow { + song: song.clone(), + index: index + 1, + onclick: move |_| { + let settings = app_settings(); + let playable = if settings.offline_mode { + songs_for_queue + .iter() + .filter(|song| is_song_downloaded(song)) + .cloned() + .collect::>() + } else { + songs_for_queue.clone() + }; + if playable.is_empty() { + download_status.set(Some( + "No downloaded songs in this album are available for offline playback." + .to_string(), + )); + return; + } + let playable = assign_collection_queue_meta( + playable, + QueueSourceKind::Album, + album_source_id.clone(), ); - } - }, + let target_index = playable + .iter() + .position(|entry| entry.id == song_clone.id) + .unwrap_or(0); + queue.set(playable.clone()); + queue_index.set(target_index); + now_playing.set(Some(playable[target_index].clone())); + is_playing.set(true); + let shuffle = shuffle_enabled(); + if shuffle { + let _ = apply_collection_shuffle_mode( + queue.clone(), + queue_index.clone(), + now_playing.clone(), + true, + ); + } + }, + } } } } diff --git a/src/components/views/album_song_row.rs b/src/components/views/album_song_row.rs index e6a561c..09a5fa8 100644 --- a/src/components/views/album_song_row.rs +++ b/src/components/views/album_song_row.rs @@ -1,6 +1,6 @@ use crate::api::*; use crate::components::views::artist_links::{ - parse_artist_names, resolve_artist_id_for_name, ArtistNameLinks, + ArtistNameLinks, parse_artist_names, resolve_artist_id_for_name, }; use crate::components::{AddIntent, AddMenuController, AppView, Icon, Navigation}; use crate::db::AppSettings; @@ -477,4 +477,3 @@ pub fn AlbumSongRow(song: Song, index: usize, onclick: EventHandler) } } } - diff --git a/src/components/views/downloads.rs b/src/components/views/downloads.rs index 4af224d..fb05a21 100644 --- a/src/components/views/downloads.rs +++ b/src/components/views/downloads.rs @@ -1,13 +1,13 @@ use crate::api::{NavidromeClient, ServerConfig, Song}; use crate::components::{AddIntent, AddMenuController, AppView, Icon, Navigation}; -use crate::db::{save_settings, AppSettings}; +use crate::db::{AppSettings, save_settings}; use crate::offline_audio::{ - clear_downloads, download_stats, list_active_downloads, list_downloaded_collection_memberships, - list_downloaded_collections, list_downloaded_entries, refresh_downloaded_cache, - remove_downloaded_album, remove_downloaded_collection, remove_downloaded_song, - run_auto_download_pass, sync_downloaded_collection_members, - sync_downloaded_collection_metadata, ActiveDownloadEntry, DownloadCollectionEntry, - DownloadCollectionMembershipEntry, DownloadIndexEntry, + ActiveDownloadEntry, DownloadCollectionEntry, DownloadCollectionMembershipEntry, + DownloadIndexEntry, clear_downloads, download_stats, list_active_downloads, + list_downloaded_collection_memberships, list_downloaded_collections, list_downloaded_entries, + refresh_downloaded_cache, remove_downloaded_album, remove_downloaded_collection, + remove_downloaded_song, run_auto_download_pass, sync_downloaded_collection_members, + sync_downloaded_collection_metadata, }; use dioxus::prelude::*; use rand::seq::SliceRandom; diff --git a/src/components/views/favorites.rs b/src/components/views/favorites.rs index e3b8ff5..549d639 100644 --- a/src/components/views/favorites.rs +++ b/src/components/views/favorites.rs @@ -6,7 +6,7 @@ use crate::components::audio_manager::{ use crate::components::views::home::{AlbumCard, SongRow}; use crate::components::views::search::ArtistCard; use crate::components::{AppView, Icon, Navigation}; -use crate::diagnostics::{log_perf, PerfTimer}; +use crate::diagnostics::{PerfTimer, log_perf}; use dioxus::prelude::*; use std::collections::HashSet; diff --git a/src/components/views/home.rs b/src/components/views/home.rs index 5912562..2e952a9 100644 --- a/src/components/views/home.rs +++ b/src/components/views/home.rs @@ -1,19 +1,19 @@ -use super::artist_links::{parse_artist_names, resolve_artist_id_for_name, ArtistNameLinks}; +use super::artist_links::{ArtistNameLinks, parse_artist_names, resolve_artist_id_for_name}; use super::home_layout::{ - parse_home_layout_settings, serialize_home_layout_settings, HomeAlbumSectionConfig, - HomeAlbumSource, HomeFeedLoadProfile, HomeLayoutSettings, HomeQuickPicksLayout, - HomeQuickPicksSize, HomeQuickPlayAction, HomeSongSectionConfig, HomeSongSource, - HomeSortDirection, HomeTopStripMode, + HomeAlbumSectionConfig, HomeAlbumSource, HomeFeedLoadProfile, HomeLayoutSettings, + HomeQuickPicksLayout, HomeQuickPicksSize, HomeQuickPlayAction, HomeSongSectionConfig, + HomeSongSource, HomeSortDirection, HomeTopStripMode, parse_home_layout_settings, + serialize_home_layout_settings, }; use crate::api::*; use crate::components::audio_manager::{ apply_collection_shuffle_mode, assign_collection_queue_meta, }; use crate::components::{ - ios_audio_log_snapshot, ios_diag_log, AddIntent, AddMenuController, AppView, HomeFeedState, - HomeRefreshSignal, Icon, Navigation, + AddIntent, AddMenuController, AppView, HomeFeedState, HomeRefreshSignal, Icon, Navigation, + ios_audio_log_snapshot, ios_diag_log, }; -use crate::db::{save_settings, AppSettings}; +use crate::db::{AppSettings, save_settings}; use crate::offline_audio::{ download_songs_batch, is_album_downloaded, is_song_downloaded, mark_collection_downloaded, prefetch_song_audio, sync_downloaded_collection_members, diff --git a/src/components/views/playlist_detail.rs b/src/components/views/playlist_detail.rs index acb55e8..85ceedd 100644 --- a/src/components/views/playlist_detail.rs +++ b/src/components/views/playlist_detail.rs @@ -4,18 +4,18 @@ use crate::components::audio_manager::{ apply_collection_shuffle_mode, assign_collection_queue_meta, }; use crate::components::views::artist_links::{ - parse_artist_names, resolve_artist_id_for_name, ArtistNameLinks, + ArtistNameLinks, parse_artist_names, resolve_artist_id_for_name, }; use crate::components::{ AddIntent, AddMenuController, AppView, Icon, Navigation, PlaybackPositionSignal, PreviewPlaybackSignal, SeekRequestSignal, }; use crate::db::AppSettings; -use crate::diagnostics::{log_perf, PerfTimer}; +use crate::diagnostics::{PerfTimer, log_perf}; use crate::offline_audio::{ - download_songs_batch, is_playlist_auto_download_tracked, is_song_downloaded, + DownloadOrigin, download_songs_batch, is_playlist_auto_download_tracked, is_song_downloaded, mark_collection_downloaded, mark_playlist_auto_download_tracked, prefetch_song_audio, - prefetch_song_audio_with_origin, sync_downloaded_collection_members, DownloadOrigin, + prefetch_song_audio_with_origin, sync_downloaded_collection_members, }; use dioxus::prelude::*; use std::cell::RefCell; @@ -622,10 +622,24 @@ pub fn PlaylistDetailView(playlist_id: String, server_id: String) -> Element { let mut show_playlist_menu = use_signal(|| false); let mut playlist_menu_x = use_signal(|| 0f64); let mut playlist_menu_y = use_signal(|| 0f64); - - let server = servers().into_iter().find(|s| s.id == server_id); + let mut current_playlist_id = use_signal(|| playlist_id.clone()); + let mut current_server_id = use_signal(|| server_id.clone()); let playlist_queue_source = format!("{}::{}", server_id.clone(), playlist_id.clone()); - let server_for_playlist = server.clone(); + + use_effect({ + let playlist_id = playlist_id.clone(); + let server_id = server_id.clone(); + move || { + if current_playlist_id() != playlist_id { + current_playlist_id.set(playlist_id.clone()); + show_playlist_menu.set(false); + } + if current_server_id() != server_id { + current_server_id.set(server_id.clone()); + show_playlist_menu.set(false); + } + } + }); { let mut song_search_debounced = song_search_debounced.clone(); @@ -653,8 +667,9 @@ pub fn PlaylistDetailView(playlist_id: String, server_id: String) -> Element { } let playlist_data = use_resource(move || { - let server = server_for_playlist.clone(); - let playlist_id = playlist_id.clone(); + let server_id = current_server_id(); + let playlist_id = current_playlist_id(); + let server = servers().into_iter().find(|s| s.id == server_id); let _reload = reload(); async move { if let Some(server) = server { @@ -667,23 +682,23 @@ pub fn PlaylistDetailView(playlist_id: String, server_id: String) -> Element { }); let search_results = { - let server = server.clone(); use_resource(move || { - let server = server.clone(); + let server_id = current_server_id(); + let server = servers().into_iter().find(|s| s.id == server_id); let query = song_search_debounced(); async move { search_playlist_add_candidates(server, query).await } }) }; let auto_recommendations = { - let server = server.clone(); let edit_mode = edit_mode.clone(); let song_list = song_list.clone(); let recently_added_seed = recently_added_seed.clone(); let dismissed_recommendations = dismissed_recommendations.clone(); let recommendation_refresh_nonce = recommendation_refresh_nonce.clone(); use_resource(move || { - let server = server.clone(); + let server_id = current_server_id(); + let server = servers().into_iter().find(|s| s.id == server_id); let editing = edit_mode(); let playlist_songs = song_list(); let recent_seed = recently_added_seed(); diff --git a/src/components/views/queue.rs b/src/components/views/queue.rs index a44ff58..9d20a56 100644 --- a/src/components/views/queue.rs +++ b/src/components/views/queue.rs @@ -3,11 +3,11 @@ use crate::api::*; use crate::cache_service::{get_json as cache_get_json, put_json as cache_put_json}; use crate::components::views::artist_links::ArtistNameLinks; use crate::components::{ - generate_queue_extension_from_seed, AddIntent, AddMenuController, AppView, Icon, Navigation, - PlaybackPositionSignal, PreviewPlaybackSignal, SeekRequestSignal, + AddIntent, AddMenuController, AppView, Icon, Navigation, PlaybackPositionSignal, + PreviewPlaybackSignal, SeekRequestSignal, generate_queue_extension_from_seed, }; -use crate::db::{load_temporary_queue_snapshots, AppSettings, TemporaryQueueSnapshot}; -use crate::diagnostics::{log_perf, PerfTimer}; +use crate::db::{AppSettings, TemporaryQueueSnapshot, load_temporary_queue_snapshots}; +use crate::diagnostics::{PerfTimer, log_perf}; use crate::offline_audio::{is_song_downloaded, prefetch_song_audio}; use dioxus::prelude::*; use std::collections::HashSet; diff --git a/src/components/views/random.rs b/src/components/views/random.rs index 4c365bf..e8b5da4 100644 --- a/src/components/views/random.rs +++ b/src/components/views/random.rs @@ -1,10 +1,10 @@ use crate::api::*; use crate::cache_service::{get_json as cache_get_json, put_json as cache_put_json}; +use crate::components::Icon; use crate::components::audio_manager::{ apply_collection_shuffle_mode, assign_collection_queue_meta, normalize_manual_queue_songs, }; use crate::components::views::home::SongRow; -use crate::components::Icon; use dioxus::prelude::*; use std::collections::HashSet; diff --git a/src/components/views/settings.rs b/src/components/views/settings.rs index f5c5062..16ab302 100644 --- a/src/components/views/settings.rs +++ b/src/components/views/settings.rs @@ -4,10 +4,10 @@ use crate::cache_service::{ stats as current_cache_stats, }; use crate::components::{ - ios_audio_log_clear, ios_audio_log_export_txt, ios_audio_log_snapshot, AppView, Icon, - Navigation, VolumeSignal, + AppView, Icon, Navigation, VolumeSignal, ios_audio_log_clear, ios_audio_log_export_txt, + ios_audio_log_snapshot, }; -use crate::db::{save_servers_now, save_settings, AppSettings, ArtworkDownloadPreference}; +use crate::db::{AppSettings, ArtworkDownloadPreference, save_servers_now, save_settings}; use crate::offline_audio::{ clear_downloads, download_stats, prune_temporary_queue_prefetch_downloads, refresh_downloaded_cache, run_auto_download_pass, diff --git a/src/components/views/songs.rs b/src/components/views/songs.rs index 0d44c63..8b9e9dc 100644 --- a/src/components/views/songs.rs +++ b/src/components/views/songs.rs @@ -1,8 +1,8 @@ use crate::api::*; +use crate::components::Icon; use crate::components::views::artist_links::{ - parse_artist_names, resolve_artist_id_for_name, ArtistNameLinks, + ArtistNameLinks, parse_artist_names, resolve_artist_id_for_name, }; -use crate::components::Icon; use crate::components::{AddIntent, AddMenuController, AppView, Navigation}; use crate::db::AppSettings; use crate::offline_audio::{is_song_downloaded, prefetch_song_audio}; @@ -1023,4 +1023,3 @@ fn SongRowWithRating( } } } - From 015e565d10637d5bb635b7e15f44ee5b8635e21b Mon Sep 17 00:00:00 2001 From: ad-archer Date: Mon, 27 Apr 2026 12:47:03 -0400 Subject: [PATCH 4/4] feat: add playlist renaming functionality and improve UI for playlist management --- assets/tailwind.css | 11 ++ src/api/navidrome/playlist_mutations.rs | 36 ++++ src/components/app.rs | 22 +-- src/components/app_view.rs | 2 +- src/components/mod.rs | 2 +- src/components/navigation.rs | 2 +- src/components/player/controls.rs | 2 +- src/components/player/mod.rs | 4 +- src/components/song_details/mod.rs | 14 +- src/components/views/album_song_row.rs | 2 +- src/components/views/downloads.rs | 14 +- src/components/views/favorites.rs | 2 +- src/components/views/home.rs | 16 +- src/components/views/playlist_detail.rs | 223 +++++++++++++++++++----- src/components/views/playlists.rs | 116 ++++++++++++ src/components/views/queue.rs | 8 +- src/components/views/random.rs | 2 +- src/components/views/settings.rs | 6 +- src/components/views/songs.rs | 4 +- src/db/mod.rs | 8 +- 20 files changed, 399 insertions(+), 97 deletions(-) diff --git a/assets/tailwind.css b/assets/tailwind.css index f15c8f8..f9476cf 100644 --- a/assets/tailwind.css +++ b/assets/tailwind.css @@ -979,6 +979,9 @@ .grid-cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); } + .grid-cols-6 { + grid-template-columns: repeat(6, minmax(0, 1fr)); + } .grid-cols-12 { grid-template-columns: repeat(12, minmax(0, 1fr)); } @@ -1622,6 +1625,9 @@ background-color: color-mix(in oklab, var(--color-emerald-500) 95%, transparent); } } + .bg-emerald-600 { + background-color: var(--color-emerald-600); + } .bg-emerald-900\/20 { background-color: color-mix(in srgb, oklch(37.8% 0.077 168.94) 20%, transparent); @supports (color: color-mix(in lab, red, red)) { @@ -4065,6 +4071,11 @@ padding: calc(var(--spacing) * 8); } } + .md\:px-4 { + @media (width >= 48rem) { + padding-inline: calc(var(--spacing) * 4); + } + } .md\:px-6 { @media (width >= 48rem) { padding-inline: calc(var(--spacing) * 6); diff --git a/src/api/navidrome/playlist_mutations.rs b/src/api/navidrome/playlist_mutations.rs index 445377c..7ff0001 100644 --- a/src/api/navidrome/playlist_mutations.rs +++ b/src/api/navidrome/playlist_mutations.rs @@ -1,5 +1,41 @@ // Playlist write/update operations. impl NavidromeClient { + pub async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), String> { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err("Playlist name cannot be empty.".to_string()); + } + + let url = self.build_url_owned( + "updatePlaylist", + vec![ + ("playlistId".to_string(), playlist_id.to_string()), + ("name".to_string(), trimmed.to_string()), + ], + ); + let response = HTTP_CLIENT + .get(&url) + .send() + .await + .map_err(|e| e.to_string())?; + let json: SubsonicResponse = response.json().await.map_err(|e| e.to_string())?; + + if json.subsonic_response.status != "ok" { + return Err(json + .subsonic_response + .error + .map(|e| e.message) + .unwrap_or("Unknown error".to_string())); + } + + let _ = cache_remove_prefix(&format!( + "api:getPlaylist:v1:{}:{}", + self.server.id, playlist_id + )); + self.invalidate_playlist_cache(); + Ok(()) + } + pub async fn create_playlist( &self, name: &str, diff --git a/src/components/app.rs b/src/components/app.rs index 755ec35..f207b83 100644 --- a/src/components/app.rs +++ b/src/components/app.rs @@ -5,18 +5,18 @@ use crate::cache_service::{ }; use crate::components::views::home_layout::HomeFeedLoadProfile; use crate::components::{ - AddIntent, AddMenuController, AddToMenuOverlay, AppView, AudioController, AudioState, - HomeRefreshSignal, Icon, IsPlayingSignal, Navigation, PlaybackPositionSignal, Player, - PreviewPlaybackSignal, SeekRequestSignal, ShuffleEnabledSignal, Sidebar, SidebarOpenSignal, - SongDetailsController, SongDetailsOverlay, SongDetailsState, VolumeSignal, - ios_audio_log_snapshot, ios_diag_log, view_instance_key, view_label, + ios_audio_log_snapshot, ios_diag_log, view_instance_key, view_label, AddIntent, + AddMenuController, AddToMenuOverlay, AppView, AudioController, AudioState, HomeRefreshSignal, + Icon, IsPlayingSignal, Navigation, PlaybackPositionSignal, Player, PreviewPlaybackSignal, + SeekRequestSignal, ShuffleEnabledSignal, Sidebar, SidebarOpenSignal, SongDetailsController, + SongDetailsOverlay, SongDetailsState, VolumeSignal, }; use crate::db::{ - AppSettings, PlaybackState, QueueItem, TemporaryQueueSnapshot, initialize_database, - load_playback_state, load_servers, load_settings, save_playback_state, save_servers, - save_settings, save_temporary_queue_snapshot, + initialize_database, load_playback_state, load_servers, load_settings, save_playback_state, + save_servers, save_settings, save_temporary_queue_snapshot, AppSettings, PlaybackState, + QueueItem, TemporaryQueueSnapshot, }; -use crate::diagnostics::{PerfTimer, log_perf}; +use crate::diagnostics::{log_perf, PerfTimer}; use crate::offline_audio::{prune_temporary_queue_prefetch_downloads, run_auto_download_pass}; use chrono::{DateTime, NaiveDateTime, Utc}; #[cfg(target_arch = "wasm32")] @@ -25,10 +25,10 @@ use dioxus::core::{Runtime, RuntimeGuard}; use dioxus::desktop::use_muda_event_handler; use dioxus_router::components::Outlet; #[cfg(target_arch = "wasm32")] -use wasm_bindgen::JsCast; -#[cfg(target_arch = "wasm32")] use wasm_bindgen::closure::Closure; #[cfg(target_arch = "wasm32")] +use wasm_bindgen::JsCast; +#[cfg(target_arch = "wasm32")] use web_sys::window; // Re-export RepeatMode for other components pub use crate::db::RepeatMode; diff --git a/src/components/app_view.rs b/src/components/app_view.rs index d2193d0..4e27699 100644 --- a/src/components/app_view.rs +++ b/src/components/app_view.rs @@ -1,7 +1,7 @@ //! Defines the shared application view state. -use crate::components::AppShell; use crate::components::views::*; +use crate::components::AppShell; use dioxus::prelude::*; #[derive(Routable, Clone, PartialEq)] diff --git a/src/components/mod.rs b/src/components/mod.rs index ac8439d..f5ec64b 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -41,7 +41,7 @@ pub struct ShuffleEnabledSignal(pub Signal); pub use add_to_menu::*; pub use app::*; -pub use app_view::{AppView, view_instance_key, view_label}; +pub use app_view::{view_instance_key, view_label, AppView}; pub use audio_manager::*; pub use icons::*; pub use navigation::Navigation; diff --git a/src/components/navigation.rs b/src/components/navigation.rs index e5a075f..e726686 100644 --- a/src/components/navigation.rs +++ b/src/components/navigation.rs @@ -6,7 +6,7 @@ use crate::components::app_view::AppView; #[cfg(target_arch = "wasm32")] use wasm_bindgen::JsCast; #[cfg(target_arch = "wasm32")] -use web_sys::{HtmlElement, window}; +use web_sys::{window, HtmlElement}; #[derive(Clone, Copy)] pub struct Navigation( diff --git a/src/components/player/controls.rs b/src/components/player/controls.rs index 3054747..0fcce3e 100644 --- a/src/components/player/controls.rs +++ b/src/components/player/controls.rs @@ -3,7 +3,7 @@ use crate::components::audio_manager::{ apply_collection_shuffle_mode, queue_should_generate_similar_on_end, spawn_shuffle_queue, }; use crate::components::{ - AddIntent, AddMenuController, AudioState, Icon, PlaybackPositionSignal, ios_diag_log, seek_to, + ios_diag_log, seek_to, AddIntent, AddMenuController, AudioState, Icon, PlaybackPositionSignal, }; use crate::db::{AppSettings, RepeatMode}; use dioxus::prelude::*; diff --git a/src/components/player/mod.rs b/src/components/player/mod.rs index 8b02923..584cd6a 100644 --- a/src/components/player/mod.rs +++ b/src/components/player/mod.rs @@ -2,8 +2,8 @@ use crate::api::models::format_duration; use crate::api::*; use crate::components::views::artist_links::ArtistNameLinks; use crate::components::{ - AppView, AudioState, Icon, Navigation, PlaybackPositionSignal, SongDetailsController, - VolumeSignal, seek_to, + seek_to, AppView, AudioState, Icon, Navigation, PlaybackPositionSignal, SongDetailsController, + VolumeSignal, }; use dioxus::prelude::*; diff --git a/src/components/song_details/mod.rs b/src/components/song_details/mod.rs index 8da689d..d1c916e 100644 --- a/src/components/song_details/mod.rs +++ b/src/components/song_details/mod.rs @@ -1,16 +1,16 @@ //! Song-details overlay, panels, and shared helpers. use crate::api::{ - LyricLine, LyricsQuery, LyricsResult, LyricsSearchCandidate, NavidromeClient, ServerConfig, - Song, fetch_lyrics_with_fallback, format_duration, normalize_lyrics_provider_order, - search_lyrics_candidates, + fetch_lyrics_with_fallback, format_duration, normalize_lyrics_provider_order, + search_lyrics_candidates, LyricLine, LyricsQuery, LyricsResult, LyricsSearchCandidate, + NavidromeClient, ServerConfig, Song, }; use crate::components::views::artist_links::{parse_artist_names, resolve_artist_id_for_name}; use crate::components::{ - AddIntent, AddMenuController, AppView, AudioState, Icon, Navigation, PlaybackPositionSignal, - SidebarOpenSignal, VolumeSignal, apply_collection_shuffle_mode, - generate_queue_extension_from_seed, queue_should_generate_similar_on_end, seek_to, - spawn_shuffle_queue, + apply_collection_shuffle_mode, generate_queue_extension_from_seed, + queue_should_generate_similar_on_end, seek_to, spawn_shuffle_queue, AddIntent, + AddMenuController, AppView, AudioState, Icon, Navigation, PlaybackPositionSignal, + SidebarOpenSignal, VolumeSignal, }; use crate::db::{AppSettings, RepeatMode}; use dioxus::prelude::*; diff --git a/src/components/views/album_song_row.rs b/src/components/views/album_song_row.rs index 09a5fa8..2ccfef3 100644 --- a/src/components/views/album_song_row.rs +++ b/src/components/views/album_song_row.rs @@ -1,6 +1,6 @@ use crate::api::*; use crate::components::views::artist_links::{ - ArtistNameLinks, parse_artist_names, resolve_artist_id_for_name, + parse_artist_names, resolve_artist_id_for_name, ArtistNameLinks, }; use crate::components::{AddIntent, AddMenuController, AppView, Icon, Navigation}; use crate::db::AppSettings; diff --git a/src/components/views/downloads.rs b/src/components/views/downloads.rs index fb05a21..4af224d 100644 --- a/src/components/views/downloads.rs +++ b/src/components/views/downloads.rs @@ -1,13 +1,13 @@ use crate::api::{NavidromeClient, ServerConfig, Song}; use crate::components::{AddIntent, AddMenuController, AppView, Icon, Navigation}; -use crate::db::{AppSettings, save_settings}; +use crate::db::{save_settings, AppSettings}; use crate::offline_audio::{ - ActiveDownloadEntry, DownloadCollectionEntry, DownloadCollectionMembershipEntry, - DownloadIndexEntry, clear_downloads, download_stats, list_active_downloads, - list_downloaded_collection_memberships, list_downloaded_collections, list_downloaded_entries, - refresh_downloaded_cache, remove_downloaded_album, remove_downloaded_collection, - remove_downloaded_song, run_auto_download_pass, sync_downloaded_collection_members, - sync_downloaded_collection_metadata, + clear_downloads, download_stats, list_active_downloads, list_downloaded_collection_memberships, + list_downloaded_collections, list_downloaded_entries, refresh_downloaded_cache, + remove_downloaded_album, remove_downloaded_collection, remove_downloaded_song, + run_auto_download_pass, sync_downloaded_collection_members, + sync_downloaded_collection_metadata, ActiveDownloadEntry, DownloadCollectionEntry, + DownloadCollectionMembershipEntry, DownloadIndexEntry, }; use dioxus::prelude::*; use rand::seq::SliceRandom; diff --git a/src/components/views/favorites.rs b/src/components/views/favorites.rs index 549d639..e3b8ff5 100644 --- a/src/components/views/favorites.rs +++ b/src/components/views/favorites.rs @@ -6,7 +6,7 @@ use crate::components::audio_manager::{ use crate::components::views::home::{AlbumCard, SongRow}; use crate::components::views::search::ArtistCard; use crate::components::{AppView, Icon, Navigation}; -use crate::diagnostics::{PerfTimer, log_perf}; +use crate::diagnostics::{log_perf, PerfTimer}; use dioxus::prelude::*; use std::collections::HashSet; diff --git a/src/components/views/home.rs b/src/components/views/home.rs index 2e952a9..5912562 100644 --- a/src/components/views/home.rs +++ b/src/components/views/home.rs @@ -1,19 +1,19 @@ -use super::artist_links::{ArtistNameLinks, parse_artist_names, resolve_artist_id_for_name}; +use super::artist_links::{parse_artist_names, resolve_artist_id_for_name, ArtistNameLinks}; use super::home_layout::{ - HomeAlbumSectionConfig, HomeAlbumSource, HomeFeedLoadProfile, HomeLayoutSettings, - HomeQuickPicksLayout, HomeQuickPicksSize, HomeQuickPlayAction, HomeSongSectionConfig, - HomeSongSource, HomeSortDirection, HomeTopStripMode, parse_home_layout_settings, - serialize_home_layout_settings, + parse_home_layout_settings, serialize_home_layout_settings, HomeAlbumSectionConfig, + HomeAlbumSource, HomeFeedLoadProfile, HomeLayoutSettings, HomeQuickPicksLayout, + HomeQuickPicksSize, HomeQuickPlayAction, HomeSongSectionConfig, HomeSongSource, + HomeSortDirection, HomeTopStripMode, }; use crate::api::*; use crate::components::audio_manager::{ apply_collection_shuffle_mode, assign_collection_queue_meta, }; use crate::components::{ - AddIntent, AddMenuController, AppView, HomeFeedState, HomeRefreshSignal, Icon, Navigation, - ios_audio_log_snapshot, ios_diag_log, + ios_audio_log_snapshot, ios_diag_log, AddIntent, AddMenuController, AppView, HomeFeedState, + HomeRefreshSignal, Icon, Navigation, }; -use crate::db::{AppSettings, save_settings}; +use crate::db::{save_settings, AppSettings}; use crate::offline_audio::{ download_songs_batch, is_album_downloaded, is_song_downloaded, mark_collection_downloaded, prefetch_song_audio, sync_downloaded_collection_members, diff --git a/src/components/views/playlist_detail.rs b/src/components/views/playlist_detail.rs index 85ceedd..4f3965c 100644 --- a/src/components/views/playlist_detail.rs +++ b/src/components/views/playlist_detail.rs @@ -4,18 +4,18 @@ use crate::components::audio_manager::{ apply_collection_shuffle_mode, assign_collection_queue_meta, }; use crate::components::views::artist_links::{ - ArtistNameLinks, parse_artist_names, resolve_artist_id_for_name, + parse_artist_names, resolve_artist_id_for_name, ArtistNameLinks, }; use crate::components::{ AddIntent, AddMenuController, AppView, Icon, Navigation, PlaybackPositionSignal, PreviewPlaybackSignal, SeekRequestSignal, }; use crate::db::AppSettings; -use crate::diagnostics::{PerfTimer, log_perf}; +use crate::diagnostics::{log_perf, PerfTimer}; use crate::offline_audio::{ - DownloadOrigin, download_songs_batch, is_playlist_auto_download_tracked, is_song_downloaded, + download_songs_batch, is_playlist_auto_download_tracked, is_song_downloaded, mark_collection_downloaded, mark_playlist_auto_download_tracked, prefetch_song_audio, - prefetch_song_audio_with_origin, sync_downloaded_collection_members, + prefetch_song_audio_with_origin, sync_downloaded_collection_members, DownloadOrigin, }; use dioxus::prelude::*; use std::cell::RefCell; @@ -622,6 +622,10 @@ pub fn PlaylistDetailView(playlist_id: String, server_id: String) -> Element { let mut show_playlist_menu = use_signal(|| false); let mut playlist_menu_x = use_signal(|| 0f64); let mut playlist_menu_y = use_signal(|| 0f64); + let mut show_rename_dialog = use_signal(|| false); + let mut rename_value = use_signal(String::new); + let rename_busy = use_signal(|| false); + let rename_error = use_signal(|| None::); let mut current_playlist_id = use_signal(|| playlist_id.clone()); let mut current_server_id = use_signal(|| server_id.clone()); let playlist_queue_source = format!("{}::{}", server_id.clone(), playlist_id.clone()); @@ -633,10 +637,12 @@ pub fn PlaylistDetailView(playlist_id: String, server_id: String) -> Element { if current_playlist_id() != playlist_id { current_playlist_id.set(playlist_id.clone()); show_playlist_menu.set(false); + show_rename_dialog.set(false); } if current_server_id() != server_id { current_server_id.set(server_id.clone()); show_playlist_menu.set(false); + show_rename_dialog.set(false); } } }); @@ -1181,6 +1187,80 @@ pub fn PlaylistDetailView(playlist_id: String, server_id: String) -> Element { } }; + let on_open_rename_dialog = { + let playlist_data_ref = playlist_data.clone(); + let mut show_playlist_menu = show_playlist_menu.clone(); + let mut show_rename_dialog = show_rename_dialog.clone(); + let mut rename_value = rename_value.clone(); + let mut rename_error = rename_error.clone(); + move |evt: MouseEvent| { + evt.stop_propagation(); + show_playlist_menu.set(false); + rename_error.set(None); + if let Some(Some((playlist, _))) = playlist_data_ref() { + rename_value.set(playlist.name.clone()); + show_rename_dialog.set(true); + } + } + }; + + let on_confirm_rename = { + let playlist_data_ref = playlist_data.clone(); + let servers = servers.clone(); + let mut rename_busy = rename_busy.clone(); + let mut rename_error = rename_error.clone(); + let mut show_rename_dialog = show_rename_dialog.clone(); + let rename_value = rename_value.clone(); + let mut reload = reload.clone(); + move |_: MouseEvent| { + if rename_busy() { + return; + } + + let next_name = rename_value().trim().to_string(); + if next_name.is_empty() { + rename_error.set(Some("Playlist name cannot be empty.".to_string())); + return; + } + + let Some(Some((playlist, _))) = playlist_data_ref() else { + rename_error.set(Some("Playlist is not available.".to_string())); + return; + }; + + if playlist.name.trim() == next_name { + show_rename_dialog.set(false); + return; + } + + let Some(server) = servers() + .into_iter() + .find(|server| server.id == playlist.server_id) + else { + rename_error.set(Some("Server not found.".to_string())); + return; + }; + + let playlist_id = playlist.id.clone(); + rename_busy.set(true); + rename_error.set(None); + spawn(async move { + let client = NavidromeClient::new(server); + match client.rename_playlist(&playlist_id, &next_name).await { + Ok(_) => { + rename_busy.set(false); + show_rename_dialog.set(false); + reload.set(reload().saturating_add(1)); + } + Err(err) => { + rename_busy.set(false); + rename_error.set(Some(err)); + } + } + }); + } + }; + let delete_playlist_action = { let playlist_data_ref = playlist_data.clone(); let servers = servers.clone(); @@ -1330,14 +1410,14 @@ pub fn PlaylistDetailView(playlist_id: String, server_id: String) -> Element { span { "{format_duration(playlist.duration / 1000)}" } span { "{downloaded_song_count} downloaded" } } - div { class: "mt-6 w-full max-w-sm grid grid-cols-5 gap-2 md:max-w-none md:flex md:flex-wrap md:gap-3 justify-center md:justify-start", + div { class: if editing_allowed && edit_mode() { "mt-6 w-full max-w-sm grid grid-cols-1 gap-2 md:max-w-none md:flex md:flex-wrap md:gap-3 justify-center md:justify-start" } else { "mt-6 w-full max-w-sm grid grid-cols-5 gap-2 md:max-w-none md:flex md:flex-wrap md:gap-3 justify-center md:justify-start" }, if editing_allowed && edit_mode() { button { - class: "col-span-1 p-3 rounded-full bg-emerald-500 hover:bg-emerald-400 text-white font-medium transition-colors flex items-center justify-center gap-2 md:px-8", + class: "col-span-1 p-3 rounded-full border border-emerald-500/60 text-emerald-300 hover:text-white hover:bg-emerald-500/20 transition-colors flex items-center justify-center gap-2 md:px-4", onclick: move |_| on_toggle_edit_mode(()), title: "Done editing playlist", Icon { name: "check".to_string(), class: "w-5 h-5".to_string() } - span { class: "hidden md:inline", "Done Editing" } + span { class: "hidden md:inline", "Done editing" } } } else { button { @@ -1347,45 +1427,45 @@ pub fn PlaylistDetailView(playlist_id: String, server_id: String) -> Element { Icon { name: "play".to_string(), class: "w-5 h-5".to_string() } span { class: "hidden md:inline", "Play" } } - } - button { - class: if download_busy() { "col-span-1 p-3 rounded-full border border-zinc-700 text-zinc-500 cursor-not-allowed flex items-center justify-center" } else if playlist_fully_downloaded { "col-span-1 p-3 rounded-full bg-emerald-500 text-white hover:bg-emerald-400 transition-colors flex items-center justify-center" } else { "col-span-1 p-3 rounded-full border border-emerald-500/60 text-emerald-300 hover:text-white hover:border-emerald-400 transition-colors flex items-center justify-center" }, - disabled: download_busy(), - onclick: on_download_playlist, - title: if download_busy() { "Downloading playlist" } else if playlist_fully_downloaded { "Playlist fully downloaded" } else { "Download playlist" }, - Icon { - name: if download_busy() { "loader".to_string() } else if playlist_fully_downloaded { "check".to_string() } else { "download".to_string() }, - class: "w-5 h-5".to_string(), + button { + class: if download_busy() { "col-span-1 p-3 rounded-full border border-zinc-700 text-zinc-500 cursor-not-allowed flex items-center justify-center" } else if playlist_fully_downloaded { "col-span-1 p-3 rounded-full bg-emerald-500 text-white hover:bg-emerald-400 transition-colors flex items-center justify-center" } else { "col-span-1 p-3 rounded-full border border-emerald-500/60 text-emerald-300 hover:text-white hover:border-emerald-400 transition-colors flex items-center justify-center" }, + disabled: download_busy(), + onclick: on_download_playlist, + title: if download_busy() { "Downloading playlist" } else if playlist_fully_downloaded { "Playlist fully downloaded" } else { "Download playlist" }, + Icon { + name: if download_busy() { "loader".to_string() } else if playlist_fully_downloaded { "check".to_string() } else { "download".to_string() }, + class: "w-5 h-5".to_string(), + } } - } - button { - class: if shuffle_enabled() { - "col-span-1 p-3 rounded-full bg-emerald-500 text-white hover:bg-emerald-400 transition-colors flex items-center justify-center" - } else { - "col-span-1 p-3 rounded-full border border-zinc-700 text-zinc-300 hover:text-white hover:border-emerald-500/60 transition-colors flex items-center justify-center" - }, - onclick: on_toggle_shuffle, - title: if shuffle_enabled() { - "Shuffle is on" - } else { - "Shuffle is off" - }, - Icon { name: "shuffle".to_string(), class: "w-5 h-5".to_string() } - } - button { - class: "col-span-1 p-3 rounded-full border border-zinc-700 text-zinc-400 hover:text-emerald-400 hover:border-emerald-500/50 transition-colors flex items-center justify-center", - onclick: on_favorite_toggle, - Icon { - name: if is_favorited() { "heart-filled".to_string() } else { "heart".to_string() }, - class: "w-5 h-5".to_string(), + button { + class: if shuffle_enabled() { + "col-span-1 p-3 rounded-full bg-emerald-500 text-white hover:bg-emerald-400 transition-colors flex items-center justify-center" + } else { + "col-span-1 p-3 rounded-full border border-zinc-700 text-zinc-300 hover:text-white hover:border-emerald-500/60 transition-colors flex items-center justify-center" + }, + onclick: on_toggle_shuffle, + title: if shuffle_enabled() { + "Shuffle is on" + } else { + "Shuffle is off" + }, + Icon { name: "shuffle".to_string(), class: "w-5 h-5".to_string() } } - } - button { - class: "col-span-1 p-3 rounded-full border border-zinc-700 text-zinc-300 hover:text-white hover:border-emerald-500/60 transition-colors flex items-center justify-center", - onclick: on_open_playlist_menu, - Icon { - name: "more-horizontal".to_string(), - class: "w-5 h-5".to_string(), + button { + class: "col-span-1 p-3 rounded-full border border-zinc-700 text-zinc-400 hover:text-emerald-400 hover:border-emerald-500/50 transition-colors flex items-center justify-center", + onclick: on_favorite_toggle, + Icon { + name: if is_favorited() { "heart-filled".to_string() } else { "heart".to_string() }, + class: "w-5 h-5".to_string(), + } + } + button { + class: "col-span-1 p-3 rounded-full border border-zinc-700 text-zinc-300 hover:text-white hover:border-emerald-500/60 transition-colors flex items-center justify-center", + onclick: on_open_playlist_menu, + Icon { + name: "more-horizontal".to_string(), + class: "w-5 h-5".to_string(), + } } } if show_playlist_menu() { @@ -1427,6 +1507,15 @@ pub fn PlaylistDetailView(playlist_id: String, server_id: String) -> Element { } } if editing_allowed { + button { + class: "w-full flex items-center gap-2 px-2.5 py-2.5 rounded-lg text-sm text-zinc-200 hover:bg-zinc-800/80 transition-colors", + onclick: on_open_rename_dialog, + Icon { + name: "edit".to_string(), + class: "w-4 h-4".to_string(), + } + "Rename playlist" + } button { class: "w-full flex items-center gap-2 px-2.5 py-2.5 rounded-lg text-sm text-zinc-200 hover:bg-zinc-800/80 transition-colors", onclick: { @@ -1925,6 +2014,52 @@ pub fn PlaylistDetailView(playlist_id: String, server_id: String) -> Element { } }, } + if show_rename_dialog() { + div { class: "fixed inset-0 bg-black/50 flex items-center justify-center z-50", + onclick: move |evt: MouseEvent| { + evt.stop_propagation(); + if !rename_busy() { + show_rename_dialog.set(false); + } + }, + div { + class: "bg-zinc-900 border border-zinc-700 rounded-lg p-6 max-w-md w-full mx-4", + onclick: move |evt: MouseEvent| evt.stop_propagation(), + h2 { class: "text-xl font-bold text-white mb-4", "Rename Playlist" } + p { class: "text-zinc-400 text-sm mb-3", + "Choose a new name for this playlist." + } + input { + class: "w-full px-3 py-2 rounded-lg bg-zinc-950/60 border border-zinc-800 text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 focus:ring-2 focus:ring-emerald-500/20 mb-3", + value: rename_value, + disabled: rename_busy(), + oninput: move |e| rename_value.set(e.value()), + placeholder: "Playlist name", + } + if let Some(err) = rename_error() { + p { class: "text-sm text-red-300 mb-3", "{err}" } + } + div { class: "flex gap-3 justify-end", + button { + class: "px-4 py-2 rounded-lg border border-zinc-700 text-zinc-300 hover:text-white hover:border-zinc-500 transition-colors", + disabled: rename_busy(), + onclick: move |_| show_rename_dialog.set(false), + "Cancel" + } + button { + class: "px-4 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white transition-colors", + disabled: rename_busy(), + onclick: on_confirm_rename, + if rename_busy() { + "Saving..." + } else { + "Save" + } + } + } + } + } + } if show_delete_confirm() { div { class: "fixed inset-0 bg-black/50 flex items-center justify-center z-50", div { class: "bg-zinc-900 border border-zinc-700 rounded-lg p-6 max-w-md w-full mx-4", diff --git a/src/components/views/playlists.rs b/src/components/views/playlists.rs index 17c0f19..a27899e 100644 --- a/src/components/views/playlists.rs +++ b/src/components/views/playlists.rs @@ -250,6 +250,10 @@ fn PlaylistCard( let mut show_delete_confirm = use_signal(|| false); let mut delete_error = use_signal(|| None::); let mut deleting = use_signal(|| false); + let mut show_rename_dialog = use_signal(|| false); + let mut rename_value = use_signal(|| playlist.name.clone()); + let mut rename_error = use_signal(|| None::); + let mut renaming = use_signal(|| false); let is_auto_imported = playlist .comment @@ -317,6 +321,64 @@ fn PlaylistCard( } }; + let on_open_rename = { + let playlist_name = playlist.name.clone(); + move |_: MouseEvent| { + show_menu.set(false); + rename_error.set(None); + rename_value.set(playlist_name.clone()); + show_rename_dialog.set(true); + } + }; + + let on_confirm_rename = { + let servers = servers.clone(); + let playlist_id = playlist.id.clone(); + let playlist_server_id = playlist.server_id.clone(); + let current_name = playlist.name.clone(); + move |_: MouseEvent| { + if renaming() { + return; + } + + let next_name = rename_value().trim().to_string(); + if next_name.is_empty() { + rename_error.set(Some("Playlist name cannot be empty.".to_string())); + return; + } + if current_name.trim() == next_name { + show_rename_dialog.set(false); + return; + } + + let servers_snapshot = servers(); + if let Some(server) = servers_snapshot + .into_iter() + .find(|s| s.id == playlist_server_id && s.active) + { + let client = NavidromeClient::new(server); + let playlist_id = playlist_id.clone(); + renaming.set(true); + rename_error.set(None); + spawn(async move { + match client.rename_playlist(&playlist_id, &next_name).await { + Ok(_) => { + renaming.set(false); + show_rename_dialog.set(false); + on_delete.call(()); + } + Err(err) => { + rename_error.set(Some(err)); + renaming.set(false); + } + } + }); + } else { + rename_error.set(Some("Server not available.".to_string())); + } + } + }; + rsx! { div { class: "relative", button { @@ -408,6 +470,15 @@ fn PlaylistCard( } } if editing_allowed { + button { + class: "w-full flex items-center gap-2 px-2.5 py-2.5 rounded-lg text-sm text-zinc-200 hover:bg-zinc-800/80 transition-colors", + onclick: on_open_rename, + Icon { + name: "edit".to_string(), + class: "w-4 h-4".to_string(), + } + "Rename playlist" + } button { class: "w-full flex items-center gap-2 px-2.5 py-2.5 rounded-lg text-sm text-red-300 hover:bg-red-500/10 transition-colors", onclick: move |_: MouseEvent| { @@ -442,6 +513,51 @@ fn PlaylistCard( } } + if show_rename_dialog() { + div { + class: "fixed inset-0 z-[10000] flex items-center justify-center bg-black/60", + onclick: move |evt: MouseEvent| { + evt.stop_propagation(); + if !renaming() { + show_rename_dialog.set(false); + } + }, + div { + class: "bg-zinc-900 border border-zinc-700 rounded-2xl p-6 max-w-sm w-full mx-4 shadow-2xl", + onclick: move |evt: MouseEvent| evt.stop_propagation(), + h3 { class: "text-lg font-semibold text-white mb-2", "Rename playlist" } + input { + class: "w-full px-3 py-2 rounded-lg bg-zinc-950/60 border border-zinc-800 text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 focus:ring-2 focus:ring-emerald-500/20 mb-3", + value: rename_value, + disabled: renaming(), + placeholder: "Playlist name", + oninput: move |e| rename_value.set(e.value()), + } + if let Some(err) = rename_error() { + p { class: "text-sm text-red-400 mb-3", "{err}" } + } + div { class: "flex gap-3 justify-end", + button { + class: "px-4 py-2 rounded-lg border border-zinc-700 text-zinc-300 hover:text-white hover:border-zinc-500 transition-colors text-sm", + disabled: renaming(), + onclick: move |_| show_rename_dialog.set(false), + "Cancel" + } + button { + class: "px-4 py-2 rounded-lg bg-emerald-500/20 border border-emerald-500/60 text-emerald-300 hover:text-white hover:bg-emerald-500/30 transition-colors text-sm", + disabled: renaming(), + onclick: on_confirm_rename, + if renaming() { + "Saving..." + } else { + "Save" + } + } + } + } + } + } + // Delete confirm dialog if show_delete_confirm() { div { diff --git a/src/components/views/queue.rs b/src/components/views/queue.rs index 9d20a56..a44ff58 100644 --- a/src/components/views/queue.rs +++ b/src/components/views/queue.rs @@ -3,11 +3,11 @@ use crate::api::*; use crate::cache_service::{get_json as cache_get_json, put_json as cache_put_json}; use crate::components::views::artist_links::ArtistNameLinks; use crate::components::{ - AddIntent, AddMenuController, AppView, Icon, Navigation, PlaybackPositionSignal, - PreviewPlaybackSignal, SeekRequestSignal, generate_queue_extension_from_seed, + generate_queue_extension_from_seed, AddIntent, AddMenuController, AppView, Icon, Navigation, + PlaybackPositionSignal, PreviewPlaybackSignal, SeekRequestSignal, }; -use crate::db::{AppSettings, TemporaryQueueSnapshot, load_temporary_queue_snapshots}; -use crate::diagnostics::{PerfTimer, log_perf}; +use crate::db::{load_temporary_queue_snapshots, AppSettings, TemporaryQueueSnapshot}; +use crate::diagnostics::{log_perf, PerfTimer}; use crate::offline_audio::{is_song_downloaded, prefetch_song_audio}; use dioxus::prelude::*; use std::collections::HashSet; diff --git a/src/components/views/random.rs b/src/components/views/random.rs index e8b5da4..4c365bf 100644 --- a/src/components/views/random.rs +++ b/src/components/views/random.rs @@ -1,10 +1,10 @@ use crate::api::*; use crate::cache_service::{get_json as cache_get_json, put_json as cache_put_json}; -use crate::components::Icon; use crate::components::audio_manager::{ apply_collection_shuffle_mode, assign_collection_queue_meta, normalize_manual_queue_songs, }; use crate::components::views::home::SongRow; +use crate::components::Icon; use dioxus::prelude::*; use std::collections::HashSet; diff --git a/src/components/views/settings.rs b/src/components/views/settings.rs index 16ab302..f5c5062 100644 --- a/src/components/views/settings.rs +++ b/src/components/views/settings.rs @@ -4,10 +4,10 @@ use crate::cache_service::{ stats as current_cache_stats, }; use crate::components::{ - AppView, Icon, Navigation, VolumeSignal, ios_audio_log_clear, ios_audio_log_export_txt, - ios_audio_log_snapshot, + ios_audio_log_clear, ios_audio_log_export_txt, ios_audio_log_snapshot, AppView, Icon, + Navigation, VolumeSignal, }; -use crate::db::{AppSettings, ArtworkDownloadPreference, save_servers_now, save_settings}; +use crate::db::{save_servers_now, save_settings, AppSettings, ArtworkDownloadPreference}; use crate::offline_audio::{ clear_downloads, download_stats, prune_temporary_queue_prefetch_downloads, refresh_downloaded_cache, run_auto_download_pass, diff --git a/src/components/views/songs.rs b/src/components/views/songs.rs index 8b9e9dc..a240b07 100644 --- a/src/components/views/songs.rs +++ b/src/components/views/songs.rs @@ -1,8 +1,8 @@ use crate::api::*; -use crate::components::Icon; use crate::components::views::artist_links::{ - ArtistNameLinks, parse_artist_names, resolve_artist_id_for_name, + parse_artist_names, resolve_artist_id_for_name, ArtistNameLinks, }; +use crate::components::Icon; use crate::components::{AddIntent, AddMenuController, AppView, Navigation}; use crate::db::AppSettings; use crate::offline_audio::{is_song_downloaded, prefetch_song_audio}; diff --git a/src/db/mod.rs b/src/db/mod.rs index b47b612..bd0598b 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -580,7 +580,9 @@ pub async fn load_playback_state() -> Result { } #[cfg(not(target_arch = "wasm32"))] -pub async fn save_temporary_queue_snapshot(snapshot: TemporaryQueueSnapshot) -> Result<(), DbError> { +pub async fn save_temporary_queue_snapshot( + snapshot: TemporaryQueueSnapshot, +) -> Result<(), DbError> { if snapshot.queue.is_empty() { return Ok(()); } @@ -598,7 +600,9 @@ pub async fn save_temporary_queue_snapshot(snapshot: TemporaryQueueSnapshot) -> } #[cfg(target_arch = "wasm32")] -pub async fn save_temporary_queue_snapshot(snapshot: TemporaryQueueSnapshot) -> Result<(), StorageError> { +pub async fn save_temporary_queue_snapshot( + snapshot: TemporaryQueueSnapshot, +) -> Result<(), StorageError> { if snapshot.queue.is_empty() { return Ok(()); }