From c2ca09aecf63ba11ee2a2be768c40f5320a25121 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Thu, 13 Aug 2026 11:55:35 +0100 Subject: [PATCH] It works --- .github/workflows/release.yml | 197 ++++++++++++++++++++++++++++ Cargo.toml | 2 +- crates/application/src/lib.rs | 12 +- crates/backend-api/src/lib.rs | 25 ++++ crates/backend/src/actor_devices.rs | 64 ++++++++- crates/backend/src/lib.rs | 44 ++++++- crates/domain/src/lib.rs | 108 +++++++++++++++ crates/ui/assets/repeat.svg | 6 + crates/ui/assets/shuffle.svg | 7 + crates/ui/src/lib.rs | 14 +- crates/ui/src/render.rs | 6 + crates/ui/ui/app.slint | 87 ++++++++---- 12 files changed, 534 insertions(+), 38 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 crates/ui/assets/repeat.svg create mode 100644 crates/ui/assets/shuffle.svg diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..467ae7a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,197 @@ +name: Build and Release + +on: + push: + tags: + - "v*" + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + CARGO_NET_RETRY: 10 + +jobs: + build: + name: Build ${{ matrix.asset_name }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + asset_name: furumi-desktop-linux-x86_64 + archive_ext: tar.gz + binary_name: furumi-desktop + - os: macos-latest + target: aarch64-apple-darwin + asset_name: furumi-desktop-macos-aarch64 + archive_ext: tar.gz + binary_name: furumi-desktop + - os: windows-latest + target: x86_64-pc-windows-msvc + asset_name: furumi-desktop-windows-x86_64 + archive_ext: zip + binary_name: furumi-desktop.exe + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Linux build dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libasound2-dev \ + libegl1-mesa-dev \ + libfontconfig1-dev \ + libfreetype-dev \ + libgl1-mesa-dev \ + libudev-dev \ + libwayland-dev \ + libx11-dev \ + libx11-xcb-dev \ + libxkbcommon-dev \ + libxkbcommon-x11-dev \ + libxcursor-dev \ + libxi-dev \ + libxrandr-dev \ + pkg-config + + - name: Install Rust target + shell: bash + run: rustup target add "${{ matrix.target }}" + + - name: Show Rust version + shell: bash + run: | + rustc --version --verbose + cargo --version + + - name: Fetch locked dependencies + shell: bash + run: cargo fetch --locked --target "${{ matrix.target }}" + + - name: Build desktop application + shell: bash + run: | + cargo build \ + --release \ + --locked \ + --offline \ + --package furumi-desktop \ + --target "${{ matrix.target }}" + + - name: Package + shell: bash + run: | + set -euo pipefail + + version="${GITHUB_REF_NAME#v}" + archive_name="${{ matrix.asset_name }}-${version}.${{ matrix.archive_ext }}" + package_dir="dist/${{ matrix.asset_name }}" + binary="target/${{ matrix.target }}/release/${{ matrix.binary_name }}" + + mkdir -p "$package_dir" + if [[ "${{ runner.os }}" == "macOS" ]]; then + app_dir="$package_dir/Furumi Desktop.app" + mkdir -p "$app_dir/Contents/MacOS" "$app_dir/Contents/Resources" + cp "$binary" "$app_dir/Contents/MacOS/furumi-desktop" + iconset="$RUNNER_TEMP/furumi.iconset" + mkdir -p "$iconset" + sips -s format png -z 16 16 crates/ui/assets/federation.svg --out "$iconset/icon_16x16.png" >/dev/null + sips -s format png -z 32 32 crates/ui/assets/federation.svg --out "$iconset/icon_16x16@2x.png" >/dev/null + sips -s format png -z 32 32 crates/ui/assets/federation.svg --out "$iconset/icon_32x32.png" >/dev/null + sips -s format png -z 64 64 crates/ui/assets/federation.svg --out "$iconset/icon_32x32@2x.png" >/dev/null + sips -s format png -z 128 128 crates/ui/assets/federation.svg --out "$iconset/icon_128x128.png" >/dev/null + sips -s format png -z 256 256 crates/ui/assets/federation.svg --out "$iconset/icon_128x128@2x.png" >/dev/null + sips -s format png -z 256 256 crates/ui/assets/federation.svg --out "$iconset/icon_256x256.png" >/dev/null + sips -s format png -z 512 512 crates/ui/assets/federation.svg --out "$iconset/icon_256x256@2x.png" >/dev/null + sips -s format png -z 512 512 crates/ui/assets/federation.svg --out "$iconset/icon_512x512.png" >/dev/null + sips -s format png -z 1024 1024 crates/ui/assets/federation.svg --out "$iconset/icon_512x512@2x.png" >/dev/null + iconutil -c icns "$iconset" -o "$app_dir/Contents/Resources/furumi.icns" + cat > "$app_dir/Contents/Info.plist" < + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Furumi Desktop + CFBundleExecutable + furumi-desktop + CFBundleIdentifier + cy.hexor.furumi-desktop + CFBundleIconFile + furumi.icns + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Furumi Desktop + CFBundlePackageType + APPL + CFBundleShortVersionString + ${version} + CFBundleVersion + ${GITHUB_RUN_NUMBER} + LSApplicationCategoryType + public.app-category.music + LSMinimumSystemVersion + 11.0 + NSHighResolutionCapable + + + + PLIST + plutil -lint "$app_dir/Contents/Info.plist" + codesign --force --deep --sign - "$app_dir" + codesign --verify --deep --strict "$app_dir" + else + cp "$binary" "$package_dir/" + fi + cp README.md "$package_dir/" + cp LICENSE "$package_dir/" + + if [[ "${{ runner.os }}" == "Windows" ]]; then + (cd dist && 7z a "../${archive_name}" "${{ matrix.asset_name }}") + else + tar -C dist -czf "$archive_name" "${{ matrix.asset_name }}" + fi + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.asset_name }} + path: ${{ matrix.asset_name }}-* + if-no-files-found: error + + publish: + name: Publish release assets + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Create release and upload assets + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + + gh release create "$RELEASE_TAG" artifacts/*/* \ + --title "furumi-desktop ${RELEASE_TAG}" \ + --generate-notes \ + --verify-tag diff --git a/Cargo.toml b/Cargo.toml index 308ccbe..8fb90c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ members = [ ] [workspace.package] -version = "0.1.0" +version = "0.1.1" edition = "2024" rust-version = "1.97" license = "WTFPL" diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 58f061a..987287e 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -1,6 +1,8 @@ //! Deterministic frontend state, reducers and UI projections. -use furumi_backend_api::{BackendCommand, BackendSnapshot, PlaybackStatus, RequestId}; +use furumi_backend_api::{ + BackendCommand, BackendSnapshot, PlaybackRepeat, PlaybackStatus, RequestId, +}; use furumi_domain::{ArtistKey, QueueItemId, ReleaseKey, TrackKey}; use std::time::Duration; @@ -105,6 +107,8 @@ pub enum UiAction { TogglePlayback, Next, Previous, + ToggleShuffle, + CycleRepeat, Seek(f64), SetVolume(f32), PlayRelease { @@ -258,6 +262,8 @@ pub fn reduce_action(state: &mut AppState, action: UiAction) -> Vec { UiAction::TogglePlayback => send(BackendCommand::TogglePlayback), UiAction::Next => send(BackendCommand::Next), UiAction::Previous => send(BackendCommand::Previous), + UiAction::ToggleShuffle => send(BackendCommand::ToggleShuffle), + UiAction::CycleRepeat => send(BackendCommand::CycleRepeat), UiAction::Seek(position_seconds) => send(BackendCommand::Seek { position_seconds }), UiAction::SetVolume(volume) => send(BackendCommand::SetVolume { volume }), UiAction::PlayRelease { release_id, start } => { @@ -433,6 +439,8 @@ pub struct PlayerProjection { pub duration: String, pub progress: f32, pub volume: f32, + pub shuffle: bool, + pub repeat: PlaybackRepeat, } impl AppState { @@ -450,6 +458,8 @@ impl AppState { duration: duration_label(duration), progress: progress_ratio(elapsed, duration), volume: self.backend.playback.volume, + shuffle: self.backend.playback.shuffle, + repeat: self.backend.playback.repeat, } } } diff --git a/crates/backend-api/src/lib.rs b/crates/backend-api/src/lib.rs index b663939..e7b6645 100644 --- a/crates/backend-api/src/lib.rs +++ b/crates/backend-api/src/lib.rs @@ -27,12 +27,33 @@ pub enum PlaybackStatus { Paused, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum PlaybackRepeat { + #[default] + Off, + One, + All, +} + +impl PlaybackRepeat { + #[must_use] + pub const fn next(self) -> Self { + match self { + Self::Off => Self::All, + Self::All => Self::One, + Self::One => Self::Off, + } + } +} + #[derive(Debug, Clone, PartialEq)] pub struct PlaybackSnapshot { pub status: PlaybackStatus, pub position_seconds: f64, pub duration_seconds: f64, pub volume: f32, + pub shuffle: bool, + pub repeat: PlaybackRepeat, } impl Default for PlaybackSnapshot { @@ -42,6 +63,8 @@ impl Default for PlaybackSnapshot { position_seconds: 0.0, duration_seconds: 0.0, volume: 0.72, + shuffle: false, + repeat: PlaybackRepeat::Off, } } } @@ -251,6 +274,8 @@ pub enum BackendCommand { SetVolume { volume: f32, }, + ToggleShuffle, + CycleRepeat, PlayRelease { release_id: ReleaseKey, start: usize, diff --git a/crates/backend/src/actor_devices.rs b/crates/backend/src/actor_devices.rs index 3d1be2f..b96351b 100644 --- a/crates/backend/src/actor_devices.rs +++ b/crates/backend/src/actor_devices.rs @@ -265,8 +265,8 @@ impl Actor { .then_some(unix_time_ms()), position_secs: self.state.playback.position_seconds, volume: volume_percent(self.state.playback.volume), - shuffle: false, - repeat: music_dht::device_sync::PlaybackRepeat::Off, + shuffle: self.state.playback.shuffle, + repeat: repeat_to_wire(self.state.playback.repeat), } } @@ -311,16 +311,39 @@ impl Actor { start_audio: bool, seek: bool, ) { + let previous_status = self.state.playback.status; + let previous_index = self.state.queue.current_index(); + let previous_track = self + .state + .queue + .current() + .map(|item| item.track.key.clone()); let tracks = wire .queue .iter() .filter_map(|track| self.resolve_playback_track(track)) .collect::>(); if !tracks.is_empty() { - self.state.queue.replace_context(tracks, wire.queue_pos); + if wire.shuffle { + if !self.state.playback.shuffle { + self.state.queue.remember_order(); + } + self.state + .queue + .replace_shuffled_context(tracks, wire.queue_pos); + } else { + self.state.queue.replace_context(tracks, wire.queue_pos); + } self.resolve_queue_artwork(); } + let current_changed = previous_index != self.state.queue.current_index() + || previous_track + .as_ref() + .zip(self.state.queue.current().map(|item| &item.track.key)) + .is_none_or(|(previous, current)| !previous.matches(current)); self.state.playback.volume = f32::from(wire.volume.min(100)) / 100.0; + self.state.playback.shuffle = wire.shuffle; + self.state.playback.repeat = repeat_from_wire(wire.repeat); self.state.playback.position_seconds = wire.position_secs.max(0.0); self.state.playback.duration_seconds = self .state @@ -338,9 +361,11 @@ impl Actor { if !start_audio { return; } - if wire.playing { + if !wire.playing { + self.audio.stop(); + } else if previous_status == PlaybackStatus::Stopped || current_changed { self.play_current(); - if seek || wire.position_secs > 0.0 { + if seek { self.audio .seek(Duration::from_secs_f64(wire.position_secs.max(0.0))); self.state.playback.position_seconds = wire.position_secs.max(0.0); @@ -349,8 +374,13 @@ impl Actor { self.audio.pause(); self.state.playback.status = PlaybackStatus::Paused; } - } else { - self.audio.stop(); + } else if wire.paused { + self.audio.pause(); + } else if previous_status == PlaybackStatus::Paused { + self.audio.resume(); + } else if seek { + self.audio + .seek(Duration::from_secs_f64(wire.position_secs.max(0.0))); } } @@ -479,3 +509,23 @@ impl Actor { }) } } + +fn repeat_to_wire( + repeat: furumi_backend_api::PlaybackRepeat, +) -> music_dht::device_sync::PlaybackRepeat { + match repeat { + furumi_backend_api::PlaybackRepeat::Off => music_dht::device_sync::PlaybackRepeat::Off, + furumi_backend_api::PlaybackRepeat::One => music_dht::device_sync::PlaybackRepeat::One, + furumi_backend_api::PlaybackRepeat::All => music_dht::device_sync::PlaybackRepeat::All, + } +} + +fn repeat_from_wire( + repeat: music_dht::device_sync::PlaybackRepeat, +) -> furumi_backend_api::PlaybackRepeat { + match repeat { + music_dht::device_sync::PlaybackRepeat::Off => furumi_backend_api::PlaybackRepeat::Off, + music_dht::device_sync::PlaybackRepeat::One => furumi_backend_api::PlaybackRepeat::One, + music_dht::device_sync::PlaybackRepeat::All => furumi_backend_api::PlaybackRepeat::All, + } +} diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 7d4a5b4..48ef57f 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -11,8 +11,9 @@ use furumi_backend_api::{ BackendCommand, BackendSnapshot, BuildInfoSnapshot, ConnectedDeviceSnapshot, ConnectedDevicesSnapshot, DevicePlaybackRole, DevicePresence, DeviceTrust, FederationActivitySnapshot, FederationDebugSnapshot, FederationOperation, LibrarySnapshot, - PendingPairingSnapshot, PlaybackStatus, PlaylistSnapshot, RemoteData, RequestId, SearchResults, - SearchSnapshot, SearchStats, SendCommandError, SettingsSnapshot, VersionEntrySnapshot, + PendingPairingSnapshot, PlaybackRepeat, PlaybackStatus, PlaylistSnapshot, RemoteData, + RequestId, SearchResults, SearchSnapshot, SearchStats, SendCommandError, SettingsSnapshot, + VersionEntrySnapshot, }; use furumi_domain::{ Artist, ArtistId, ArtistKey, ArtistRef, Artwork, AudioSource, CatalogSource, ContentId, @@ -688,9 +689,25 @@ impl Actor { } self.publish(); } + BackendCommand::ToggleShuffle => { + self.state.playback.shuffle = !self.state.playback.shuffle; + if self.state.playback.shuffle { + self.state.queue.shuffle_upcoming(); + } else { + self.state.queue.restore_upcoming_order(); + } + self.send_control_state(false); + self.publish(); + } + BackendCommand::CycleRepeat => { + self.state.playback.repeat = self.state.playback.repeat.next(); + self.send_control_state(false); + self.publish(); + } BackendCommand::PlayRelease { release_id, start } => { if let Some(release) = self.release(&release_id).cloned() { self.state.queue.replace_context(release.tracks, start); + self.shuffle_new_context(); self.resolve_queue_artwork(); self.play_current(); } @@ -698,6 +715,7 @@ impl Actor { BackendCommand::PlayTrack { track } => { if let Some(track) = self.track(&track).cloned() { self.state.queue.replace_context(vec![track], 0); + self.shuffle_new_context(); self.resolve_queue_artwork(); self.play_current(); } @@ -712,6 +730,7 @@ impl Actor { if !tracks.is_empty() { let start = selected_track_position(&tracks, &selected); self.state.queue.replace_context(tracks, start); + self.shuffle_new_context(); self.resolve_queue_artwork(); self.play_current(); } @@ -808,7 +827,7 @@ impl Actor { self.select_playback_device(&device_id); } BackendCommand::Next => { - if self.state.queue.advance().is_some() { + if self.advance_queue(false) { self.play_current(); } } @@ -1427,7 +1446,7 @@ impl Actor { } audio::Event::Finished => { self.remove_ephemeral_audio(); - if self.state.queue.advance().is_some() { + if self.advance_queue(true) { self.play_current(); } else { self.state.playback.status = PlaybackStatus::Stopped; @@ -1476,6 +1495,23 @@ impl Actor { self.publish(); } + fn shuffle_new_context(&mut self) { + if self.state.playback.shuffle { + self.state.queue.shuffle_upcoming(); + } + } + + fn advance_queue(&mut self, natural_finish: bool) -> bool { + if natural_finish && self.state.playback.repeat == PlaybackRepeat::One { + return self.state.queue.current().is_some(); + } + if self.state.queue.advance().is_some() { + return true; + } + self.state.playback.repeat == PlaybackRepeat::All + && self.state.queue.select_index(0).is_some() + } + fn start_federated_playback(&mut self, track: Track, peer_id: String, content_id: ContentId) { let Some(client) = self.federation.clone() else { self.state.playback_error = Some("federation is still starting".into()); diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 4d657ac..1877124 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -326,6 +326,7 @@ pub struct Queue { items: Vec, current: Option, play_next_end: Option, + original_order: Option>, next_item_id: u64, } @@ -360,6 +361,7 @@ impl Queue { self.items = items; self.current = (!self.items.is_empty()).then(|| start.min(self.items.len() - 1)); self.play_next_end = None; + self.original_order = None; } pub fn add_to_end(&mut self, tracks: impl IntoIterator) { @@ -425,6 +427,87 @@ impl Queue { self.current() } + /// Selects a position directly, preserving the existing queue context. + pub fn select_index(&mut self, index: usize) -> Option<&QueueItem> { + if index >= self.items.len() { + return None; + } + self.current = Some(index); + self.play_next_end = None; + self.current() + } + + /// Randomizes only the part of the queue that has not played yet. + /// + /// The original order is retained so disabling shuffle can restore it. + pub fn shuffle_upcoming(&mut self) { + let start = self + .current + .map_or(0, |current| (current + 1).min(self.items.len())); + if self.items.len().saturating_sub(start) < 2 { + return; + } + if self.original_order.is_none() { + self.remember_order(); + } + let mut seed = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(1, |duration| { + duration.as_secs() ^ u64::from(duration.subsec_nanos()) + }) + | 1; + let mut random = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + let tail = &mut self.items[start..]; + for index in (1..tail.len()).rev() { + let bound = u64::try_from(index + 1).unwrap_or(u64::MAX); + let shuffled = usize::try_from(random() % bound).unwrap_or(0); + tail.swap(index, shuffled); + } + self.play_next_end = None; + } + + /// Remembers the current catalog order before another device sends a + /// physically shuffled queue through the connected-devices protocol. + pub fn remember_order(&mut self) { + if self.original_order.is_none() { + self.original_order = Some( + self.items + .iter() + .map(|item| item.track.key.clone()) + .collect(), + ); + } + } + + /// Replaces a synchronized queue without losing its pre-shuffle order. + pub fn replace_shuffled_context(&mut self, tracks: Vec, start: usize) { + let original_order = self.original_order.take(); + self.replace_context(tracks, start); + self.original_order = original_order; + } + + /// Restores the unplayed queue tail to its order before shuffle. + pub fn restore_upcoming_order(&mut self) { + let Some(order) = self.original_order.take() else { + return; + }; + let start = self + .current + .map_or(0, |current| (current + 1).min(self.items.len())); + self.items[start..].sort_by_key(|item| { + order + .iter() + .position(|original| original.matches(&item.track.key)) + .unwrap_or(usize::MAX) + }); + self.play_next_end = None; + } + fn new_item(&mut self, track: Track) -> QueueItem { self.next_item_id = self.next_item_id.saturating_add(1); QueueItem { @@ -506,6 +589,31 @@ mod tests { ); } + #[test] + fn shuffle_preserves_current_track_and_can_restore_the_tail() { + let mut queue = Queue::default(); + queue.replace_context((1..=8).map(track).collect(), 2); + let current = queue.current().unwrap().id; + + queue.shuffle_upcoming(); + + assert_eq!(queue.current().unwrap().id, current); + let mut shuffled_tail: Vec<_> = queue.items()[3..] + .iter() + .map(|item| item.track.key.local_id().unwrap().get()) + .collect(); + shuffled_tail.sort_unstable(); + assert_eq!(shuffled_tail, vec![4, 5, 6, 7, 8]); + + queue.restore_upcoming_order(); + let restored: Vec<_> = queue + .items() + .iter() + .map(|item| item.track.key.local_id().unwrap().get()) + .collect(); + assert_eq!(restored, vec![1, 2, 3, 4, 5, 6, 7, 8]); + } + #[test] fn catalog_track_matching_falls_back_to_album_position() { let local = track(1); diff --git a/crates/ui/assets/repeat.svg b/crates/ui/assets/repeat.svg new file mode 100644 index 0000000..2f86797 --- /dev/null +++ b/crates/ui/assets/repeat.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/ui/assets/shuffle.svg b/crates/ui/assets/shuffle.svg new file mode 100644 index 0000000..a497f60 --- /dev/null +++ b/crates/ui/assets/shuffle.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 593ca93..7a3df0a 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -14,7 +14,7 @@ use furumi_application::{ use furumi_backend::BackendHandle; use furumi_backend_api::{ BackendCommand, DevicePlaybackRole, DevicePresence, DeviceTrust, FederationOperation, - PlaybackStatus, RemoteData, + PlaybackRepeat, PlaybackStatus, RemoteData, }; use furumi_domain::{ Artist, ArtistKey, ArtistRef, AudioSource, CatalogSource, ContentId, LocalTrackId, QueueItemId, @@ -364,6 +364,18 @@ fn bind_player_callbacks( let backend = backend.clone(); move || dispatch_action(&window, &state, &backend, UiAction::Previous) }); + window.on_toggle_shuffle({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move || dispatch_action(&window, &state, &backend, UiAction::ToggleShuffle) + }); + window.on_cycle_repeat({ + let window = window.as_weak(); + let state = Arc::clone(state); + let backend = backend.clone(); + move || dispatch_action(&window, &state, &backend, UiAction::CycleRepeat) + }); window.on_seek({ let window = window.as_weak(); let state = Arc::clone(state); diff --git a/crates/ui/src/render.rs b/crates/ui/src/render.rs index d4aebb5..7f4cb3e 100644 --- a/crates/ui/src/render.rs +++ b/crates/ui/src/render.rs @@ -770,6 +770,12 @@ pub(super) fn render_playback(window: &AppWindow, state: &AppState) { window.set_duration(player.duration.into()); window.set_progress(player.progress); window.set_volume(player.volume); + window.set_shuffle_enabled(player.shuffle); + window.set_repeat_mode(match player.repeat { + PlaybackRepeat::Off => 0, + PlaybackRepeat::One => 1, + PlaybackRepeat::All => 2, + }); } pub(super) fn render_current_track(window: &AppWindow, state: &AppState) { diff --git a/crates/ui/ui/app.slint b/crates/ui/ui/app.slint index 03d3a5e..d59ff5b 100644 --- a/crates/ui/ui/app.slint +++ b/crates/ui/ui/app.slint @@ -100,6 +100,9 @@ export component AppWindow inherits Window { in property duration; in property progress; in property volume; + in property shuffle-enabled; + // 0 = off, 1 = current track, 2 = entire queue. + in property repeat-mode; in property error-message; callback navigate(string); @@ -114,6 +117,8 @@ export component AppWindow inherits Window { callback toggle-playback; callback next; callback previous; + callback toggle-shuffle; + callback cycle-repeat; callback seek(float); callback set-volume(float); callback play-release(string); @@ -414,26 +419,34 @@ export component AppWindow inherits Window { IconButton { icon-source: @image-url("../assets/close.svg"); clicked => root.toggle-queue(); } } Text { text: "Now playing and up next"; color: #777d8d; font-size: 11px; } - VerticalLayout { - spacing: 4px; - for item in root.queue-items: Rectangle { - height: 52px; border-radius: 7px; background: item.active ? #242a35 : transparent; - TouchArea { double-clicked => root.play-queue-item(item.key); } - HorizontalLayout { - padding: 7px; spacing: 10px; - Rectangle { width: 38px; height: 38px; border-radius: 5px; background: #292d38; Image { source: @image-url("../assets/note.svg"); width: 21px; height: 21px; x: 8px; y: 8px; } if item.has-artwork: Image { source: item.artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; } } - Rectangle { - width: 190px; height: parent.height; background: transparent; clip: true; - VerticalLayout { - x: 0px; y: 0px; width: parent.width; height: parent.height; alignment: center; - MarqueeText { width: parent.width; height: 18px; label: item.title; text-color: item.active ? #55dbaa : #e7e9ef; text-size: 12px; text-weight: 600; } - LinkText { width: parent.width; label: item.artist; base-color: #777d8d; text-size: 10px; height: 15px; clicked => root.open-artist(item.artist-key); } + ScrollView { + vertical-stretch: 1; + min-height: 0px; + viewport-width: self.width; + horizontal-scrollbar-policy: ScrollBarPolicy.always-off; + vertical-scrollbar-policy: ScrollBarPolicy.always-off; + VerticalLayout { + width: parent.width; + spacing: 4px; + alignment: start; + for item in root.queue-items: Rectangle { + height: 52px; border-radius: 7px; background: item.active ? #242a35 : transparent; + TouchArea { double-clicked => root.play-queue-item(item.key); } + HorizontalLayout { + padding: 7px; spacing: 10px; + Rectangle { width: 38px; height: 38px; border-radius: 5px; background: #292d38; Image { source: @image-url("../assets/note.svg"); width: 21px; height: 21px; x: 8px; y: 8px; } if item.has-artwork: Image { source: item.artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; } } + Rectangle { + width: 190px; height: parent.height; background: transparent; clip: true; + VerticalLayout { + x: 0px; y: 0px; width: parent.width; height: parent.height; alignment: center; + MarqueeText { width: parent.width; height: 18px; label: item.title; text-color: item.active ? #55dbaa : #e7e9ef; text-size: 12px; text-weight: 600; } + LinkText { width: parent.width; label: item.artist; base-color: #777d8d; text-size: 10px; height: 15px; clicked => root.open-artist(item.artist-key); } + } } } } } } - Rectangle { vertical-stretch: 1; background: transparent; } } } } @@ -444,10 +457,7 @@ export component AppWindow inherits Window { border-color: #282b35; border-width: 1px; HorizontalLayout { - padding-left: 18px; padding-right: 18px; padding-top: 12px; padding-bottom: 10px; - spacing: 18px; - HorizontalLayout { - width: 260px; spacing: 11px; + x: 18px; y: 12px; width: 260px; height: 68px; spacing: 11px; Rectangle { width: 54px; height: 54px; border-radius: 6px; background: #292d38; clip: true; Image { source: @image-url("../assets/note.svg"); width: 28px; height: 28px; x: 13px; y: 13px; } @@ -461,11 +471,24 @@ export component AppWindow inherits Window { ArtistLinksRow { width: 195px; height: 16px; artists: root.current-artists; row-color: #818797; open-artist(key) => root.open-artist(key); } Text { width: 195px; text: root.current-metadata; color: #656b7b; font-size: 10px; overflow: elide; vertical-alignment: center; } } - } + } + Rectangle { + // Keep transport controls at the true center of the window. + // The bar grows on compact windows but stops at a comfortable width. + width: min(560px, max(320px, parent.width - 570px)); + height: 68px; + x: (parent.width - self.width) / 2; + y: 10px; + background: transparent; VerticalLayout { - horizontal-stretch: 1; spacing: 7px; + spacing: 7px; HorizontalLayout { alignment: center; spacing: 8px; + IconButton { + icon-source: @image-url("../assets/shuffle.svg"); + icon-color: root.shuffle-enabled ? #e58bd7 : transparent; + clicked => root.toggle-shuffle(); + } IconButton { icon-source: @image-url("../assets/previous.svg"); clicked => root.previous(); } Rectangle { width: 42px; height: 42px; border-radius: 21px; background: #f4f5f8; @@ -474,6 +497,22 @@ export component AppWindow inherits Window { TouchArea { clicked => root.toggle-playback(); } } IconButton { icon-source: @image-url("../assets/next.svg"); clicked => root.next(); } + Rectangle { + width: 38px; height: 38px; background: transparent; + IconButton { + icon-source: @image-url("../assets/repeat.svg"); + icon-color: root.repeat-mode != 0 ? #e58bd7 : transparent; + clicked => root.cycle-repeat(); + } + if root.repeat-mode == 1: Rectangle { + x: 24px; y: 23px; width: 12px; height: 12px; border-radius: 6px; + background: #11131a; + Text { + text: "1"; color: #e58bd7; font-size: 8px; font-weight: 800; + horizontal-alignment: center; vertical-alignment: center; + } + } + } } HorizontalLayout { spacing: 9px; @@ -482,8 +521,9 @@ export component AppWindow inherits Window { Text { text: root.duration; color: #818797; font-size: 10px; width: 34px; vertical-alignment: center; } } } - Rectangle { - width: 238px; background: transparent; + } + Rectangle { + x: parent.width - 256px; y: 12px; width: 238px; height: 68px; background: transparent; IconButton { x: 0px; y: (parent.height - self.height) / 2; icon-source: @image-url("../assets/queue.svg"); clicked => root.toggle-queue(); } Image { x: 48px; y: (parent.height - self.height) / 2; source: @image-url("../assets/volume.svg"); width: 18px; height: 18px; } Slider { x: 75px; y: (parent.height - self.height) / 2; width: 100px; height: 18px; minimum: 0; maximum: 1; value: root.volume; changed(value) => root.set-volume(value); } @@ -562,7 +602,6 @@ export component AppWindow inherits Window { } } } - } } } }