This commit is contained in:
Ultradesu
2026-08-13 11:55:35 +01:00
parent e26159f85f
commit c2ca09aecf
12 changed files with 534 additions and 38 deletions
+197
View File
@@ -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" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Furumi Desktop</string>
<key>CFBundleExecutable</key>
<string>furumi-desktop</string>
<key>CFBundleIdentifier</key>
<string>cy.hexor.furumi-desktop</string>
<key>CFBundleIconFile</key>
<string>furumi.icns</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Furumi Desktop</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>${version}</string>
<key>CFBundleVersion</key>
<string>${GITHUB_RUN_NUMBER}</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.music</string>
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>
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
+1 -1
View File
@@ -11,7 +11,7 @@ members = [
]
[workspace.package]
version = "0.1.0"
version = "0.1.1"
edition = "2024"
rust-version = "1.97"
license = "WTFPL"
+11 -1
View File
@@ -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<Effect> {
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,
}
}
}
+25
View File
@@ -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,
+57 -7
View File
@@ -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::<Vec<_>>();
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,
}
}
+40 -4
View File
@@ -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());
+108
View File
@@ -326,6 +326,7 @@ pub struct Queue {
items: Vec<QueueItem>,
current: Option<usize>,
play_next_end: Option<usize>,
original_order: Option<Vec<TrackKey>>,
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<Item = Track>) {
@@ -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<Track>, 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);
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#b8bdca" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 2.8 20.2 6 17 9.2"/>
<path d="M3.8 11V9a3 3 0 0 1 3-3h13.4"/>
<path d="M7 21.2 3.8 18 7 14.8"/>
<path d="M20.2 13v2a3 3 0 0 1-3 3H3.8"/>
</svg>

After

Width:  |  Height:  |  Size: 321 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#b8bdca" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 7h3.4c4.8 0 6.5 10 11.2 10H21"/>
<path d="m18 14 3 3-3 3"/>
<path d="M3 17h3.4c1.8 0 3.1-1.4 4.3-3.2"/>
<path d="M13.4 9.4C14.6 8 15.9 7 17.6 7H21"/>
<path d="m18 4 3 3-3 3"/>
</svg>

After

Width:  |  Height:  |  Size: 361 B

+13 -1
View File
@@ -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);
+6
View File
@@ -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) {
+63 -24
View File
@@ -100,6 +100,9 @@ export component AppWindow inherits Window {
in property <string> duration;
in property <float> progress;
in property <float> volume;
in property <bool> shuffle-enabled;
// 0 = off, 1 = current track, 2 = entire queue.
in property <int> repeat-mode;
in property <string> 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 {
}
}
}
}
}
}
}