Init commit

This commit is contained in:
Ultradesu
2026-08-12 01:30:57 +01:00
commit 423ea59371
67 changed files with 23280 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "furumi-application"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
furumi-backend-api.workspace = true
furumi-domain.workspace = true
[lints]
workspace = true
+613
View File
@@ -0,0 +1,613 @@
//! Deterministic frontend state, reducers and UI projections.
use furumi_backend_api::{BackendCommand, BackendSnapshot, PlaybackStatus, RequestId};
use furumi_domain::{ArtistKey, QueueItemId, ReleaseKey, TrackKey};
use std::time::Duration;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Locale {
#[default]
En,
}
#[derive(Debug, Clone, Copy)]
pub struct Strings {
pub app_name: &'static str,
pub home: &'static str,
pub search: &'static str,
pub library: &'static str,
pub queue: &'static str,
pub recently_played: &'static str,
pub made_for_listening: &'static str,
pub empty_queue: &'static str,
pub search_placeholder: &'static str,
}
pub const EN: Strings = Strings {
app_name: "Furumi",
home: "Home",
search: "Search",
library: "Your library",
queue: "Queue",
recently_played: "Recently played",
made_for_listening: "Made for listening",
empty_queue: "Your queue is empty",
search_placeholder: "Artists, albums or tracks",
};
impl Locale {
#[must_use]
pub const fn strings(self) -> &'static Strings {
match self {
Self::En => &EN,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum Screen {
#[default]
Home,
Search,
Library,
Artist(ArtistKey),
Release(ReleaseKey, Option<ArtistKey>),
Playlist(i64),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FrontendState {
pub screen: Screen,
pub navigation_history: Vec<Screen>,
pub navigation_forward: Vec<Screen>,
pub queue_open: bool,
pub settings_open: bool,
pub devices_open: bool,
pub search_query: String,
pub locale: Locale,
pub track_info: Option<TrackKey>,
pub playlist_picker_track: Option<TrackKey>,
}
impl Default for FrontendState {
fn default() -> Self {
Self {
screen: Screen::Home,
navigation_history: Vec::new(),
navigation_forward: Vec::new(),
queue_open: false,
settings_open: false,
devices_open: false,
search_query: String::new(),
locale: Locale::En,
track_info: None,
playlist_picker_track: None,
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct AppState {
pub frontend: FrontendState,
pub backend: BackendSnapshot,
pub next_request_id: u64,
pub transient_error: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum UiAction {
Navigate(Screen),
Back,
Forward,
ToggleQueue,
ToggleSettings,
ToggleDevices,
TogglePlayback,
Next,
Previous,
Seek(f64),
SetVolume(f32),
PlayRelease {
release_id: ReleaseKey,
start: usize,
},
PlayTrack(TrackKey),
PlayQueueItem(QueueItemId),
PlayContext {
tracks: Vec<TrackKey>,
selected: TrackKey,
},
AddNext(Vec<TrackKey>),
AddToEnd(Vec<TrackKey>),
ToggleLike(TrackKey),
ShowPlaylistPicker(TrackKey),
ClosePlaylistPicker,
CreatePlaylist(String),
AddToPlaylist {
playlist_id: i64,
tracks: Vec<TrackKey>,
},
CreateDeviceInvite,
ConnectDevice(String),
AnswerDevicePairing {
request_id: String,
accept: bool,
use_requester_group: bool,
},
SelectPlaybackDevice(String),
SearchChanged(String),
NetworkIdChanged(String),
DeviceNameChanged(String),
LibraryPathChanged(String),
FederationChanged(bool),
SaveFederatedOnListenChanged(bool),
LanguageChanged(String),
ShowTrackInfo(TrackKey),
CloseTrackInfo,
DismissError,
}
#[derive(Debug, Clone, PartialEq)]
pub enum AppEvent {
BackendSnapshot(Box<BackendSnapshot>),
CommandRejected(String),
}
#[derive(Debug, Clone, PartialEq)]
pub enum Effect {
Send(BackendCommand),
}
#[allow(
clippy::too_many_lines,
reason = "central exhaustive reducer keeps state transitions together"
)]
pub fn reduce_action(state: &mut AppState, action: UiAction) -> Vec<Effect> {
match action {
UiAction::Navigate(screen) => {
if state.frontend.screen != screen {
state
.frontend
.navigation_history
.push(state.frontend.screen.clone());
state.frontend.navigation_forward.clear();
}
state.frontend.screen = screen.clone();
state.next_request_id = state.next_request_id.saturating_add(1);
let request_id = RequestId::new(state.next_request_id);
match screen {
Screen::Artist(key) => find_artist(state, &key).map_or_else(Vec::new, |artist| {
send(BackendCommand::LoadArtist {
request_id,
key,
name: artist.name.clone(),
})
}),
Screen::Release(key, preferred) => {
find_release(state, &key).map_or_else(Vec::new, |release| {
let selected_artist = preferred
.as_ref()
.and_then(|preferred| {
release
.artists
.iter()
.find(|artist| &artist.key == preferred)
.map(|artist| (artist.key.clone(), artist.name.clone()))
.or_else(|| {
find_artist(state, preferred)
.map(|artist| (artist.key.clone(), artist.name.clone()))
})
})
.or_else(|| {
release
.artists
.first()
.map(|artist| (artist.key.clone(), artist.name.clone()))
});
selected_artist.map_or_else(Vec::new, |(artist_key, artist_name)| {
send(BackendCommand::LoadRelease {
request_id,
key,
artist_key,
artist_name,
})
})
})
}
_ => Vec::new(),
}
}
UiAction::Back => {
if let Some(screen) = state.frontend.navigation_history.pop() {
state
.frontend
.navigation_forward
.push(state.frontend.screen.clone());
state.frontend.screen = screen;
}
Vec::new()
}
UiAction::Forward => {
if let Some(screen) = state.frontend.navigation_forward.pop() {
state
.frontend
.navigation_history
.push(state.frontend.screen.clone());
state.frontend.screen = screen;
}
Vec::new()
}
UiAction::ToggleQueue => {
state.frontend.queue_open = !state.frontend.queue_open;
Vec::new()
}
UiAction::ToggleSettings => {
state.frontend.settings_open = !state.frontend.settings_open;
if state.frontend.settings_open {
state.frontend.devices_open = false;
}
Vec::new()
}
UiAction::ToggleDevices => {
state.frontend.devices_open = !state.frontend.devices_open;
if state.frontend.devices_open {
state.frontend.settings_open = false;
}
Vec::new()
}
UiAction::TogglePlayback => send(BackendCommand::TogglePlayback),
UiAction::Next => send(BackendCommand::Next),
UiAction::Previous => send(BackendCommand::Previous),
UiAction::Seek(position_seconds) => send(BackendCommand::Seek { position_seconds }),
UiAction::SetVolume(volume) => send(BackendCommand::SetVolume { volume }),
UiAction::PlayRelease { release_id, start } => {
send(BackendCommand::PlayRelease { release_id, start })
}
UiAction::PlayTrack(track) => send(BackendCommand::PlayTrack { track }),
UiAction::PlayQueueItem(item_id) => send(BackendCommand::PlayQueueItem { item_id }),
UiAction::PlayContext { tracks, selected } => {
send(BackendCommand::PlayContext { tracks, selected })
}
UiAction::AddNext(tracks) => send(BackendCommand::AddNext { tracks }),
UiAction::AddToEnd(tracks) => send(BackendCommand::AddToEnd { tracks }),
UiAction::ToggleLike(track) => send(BackendCommand::ToggleLike { track }),
UiAction::ShowPlaylistPicker(track) => {
state.frontend.playlist_picker_track = Some(track);
Vec::new()
}
UiAction::ClosePlaylistPicker => {
state.frontend.playlist_picker_track = None;
Vec::new()
}
UiAction::CreatePlaylist(title) => send(BackendCommand::CreatePlaylist { title }),
UiAction::AddToPlaylist {
playlist_id,
tracks,
} => {
state.frontend.playlist_picker_track = None;
send(BackendCommand::AddToPlaylist {
playlist_id,
tracks,
})
}
UiAction::CreateDeviceInvite => send(BackendCommand::CreateDeviceInvite),
UiAction::ConnectDevice(invite) => send(BackendCommand::ConnectDevice { invite }),
UiAction::AnswerDevicePairing {
request_id,
accept,
use_requester_group,
} => send(BackendCommand::AnswerDevicePairing {
request_id,
accept,
use_requester_group,
}),
UiAction::SelectPlaybackDevice(device_id) => {
send(BackendCommand::SelectPlaybackDevice { device_id })
}
UiAction::SearchChanged(query) => {
let previous = active_search_request(&state.backend);
state.frontend.search_query.clone_from(&query);
if state.frontend.screen != Screen::Search {
state
.frontend
.navigation_history
.push(state.frontend.screen.clone());
state.frontend.navigation_forward.clear();
state.frontend.screen = Screen::Search;
}
state.next_request_id = state.next_request_id.saturating_add(1);
let request_id = RequestId::new(state.next_request_id);
let mut effects = Vec::with_capacity(2);
if let Some(previous) = previous {
effects.push(Effect::Send(BackendCommand::CancelSearch {
request_id: previous,
}));
}
effects.push(Effect::Send(BackendCommand::Search { request_id, query }));
effects
}
UiAction::NetworkIdChanged(value) => {
state.backend.settings.network_id = value;
send(BackendCommand::UpdateSettings(
state.backend.settings.clone(),
))
}
UiAction::DeviceNameChanged(value) => {
state.backend.settings.device_name = value;
send(BackendCommand::UpdateSettings(
state.backend.settings.clone(),
))
}
UiAction::LibraryPathChanged(value) => {
state.backend.settings.library_path = value;
send(BackendCommand::UpdateSettings(
state.backend.settings.clone(),
))
}
UiAction::FederationChanged(enabled) => {
state.backend.settings.federation_enabled = enabled;
send(BackendCommand::UpdateSettings(
state.backend.settings.clone(),
))
}
UiAction::SaveFederatedOnListenChanged(enabled) => {
state.backend.settings.save_federated_on_listen = enabled;
send(BackendCommand::UpdateSettings(
state.backend.settings.clone(),
))
}
UiAction::LanguageChanged(language) => {
state.frontend.locale = Locale::En;
state.backend.settings.language = language;
send(BackendCommand::UpdateSettings(
state.backend.settings.clone(),
))
}
UiAction::ShowTrackInfo(track) => {
state.frontend.track_info = Some(track);
Vec::new()
}
UiAction::CloseTrackInfo => {
state.frontend.track_info = None;
Vec::new()
}
UiAction::DismissError => {
state.transient_error = None;
Vec::new()
}
}
}
fn find_artist<'a>(state: &'a AppState, key: &ArtistKey) -> Option<&'a furumi_domain::Artist> {
match &state.backend.library {
furumi_backend_api::RemoteData::Ready(l) => Some(l),
_ => None,
}
.into_iter()
.flat_map(|l| l.artists.iter())
.chain(state.backend.search.results.artists.iter())
.find(|a| &a.key == key)
}
fn find_release<'a>(state: &'a AppState, key: &ReleaseKey) -> Option<&'a furumi_domain::Release> {
match &state.backend.library {
furumi_backend_api::RemoteData::Ready(l) => Some(l),
_ => None,
}
.into_iter()
.flat_map(|l| l.featured_releases.iter())
.chain(state.backend.search.results.releases.iter())
.find(|r| &r.key == key)
}
pub fn reduce_event(state: &mut AppState, event: AppEvent) {
match event {
AppEvent::BackendSnapshot(snapshot) => {
if snapshot.revision >= state.backend.revision {
state.backend = *snapshot;
}
}
AppEvent::CommandRejected(message) => state.transient_error = Some(message),
}
}
fn active_search_request(snapshot: &BackendSnapshot) -> Option<RequestId> {
snapshot
.search
.federation_pending
.then_some(snapshot.search.request_id)
.flatten()
}
fn send(command: BackendCommand) -> Vec<Effect> {
vec![Effect::Send(command)]
}
#[derive(Debug, Clone, PartialEq)]
pub struct PlayerProjection {
pub has_track: bool,
pub playing: bool,
pub title: String,
pub artist: String,
pub elapsed: String,
pub duration: String,
pub progress: f32,
pub volume: f32,
}
impl AppState {
#[must_use]
pub fn player_projection(&self) -> PlayerProjection {
let current = self.backend.queue.current().map(|item| &item.track);
let duration = self.backend.playback.duration_seconds.max(0.0);
let elapsed = self.backend.playback.position_seconds.clamp(0.0, duration);
PlayerProjection {
has_track: current.is_some(),
playing: self.backend.playback.status == PlaybackStatus::Playing,
title: current.map_or_else(|| "Nothing playing".into(), |track| track.title.clone()),
artist: current.map_or_else(String::new, |track| track.artist.clone()),
elapsed: duration_label(elapsed),
duration: duration_label(duration),
progress: progress_ratio(elapsed, duration),
volume: self.backend.playback.volume,
}
}
}
#[must_use]
pub fn duration_label(seconds: f64) -> String {
let seconds = if seconds.is_finite() {
seconds.max(0.0)
} else {
0.0
};
let total = Duration::from_secs_f64(seconds).as_secs();
format!("{}:{:02}", total / 60, total % 60)
}
#[allow(
clippy::cast_possible_truncation,
reason = "the clamped unit interval is the precision exposed by Slint"
)]
fn progress_ratio(elapsed: f64, duration: f64) -> f32 {
if duration > 0.0 {
(elapsed / duration).clamp(0.0, 1.0) as f32
} else {
0.0
}
}
#[cfg(test)]
mod tests {
use super::*;
use furumi_backend_api::{SearchResults, SearchSnapshot};
use furumi_domain::{Artist, ArtistRef, Artwork, CatalogSource, Release, ReleaseId};
#[test]
fn a_new_search_cancels_the_active_request() {
let mut state = AppState::default();
state.backend.search = SearchSnapshot {
request_id: Some(RequestId::new(9)),
federation_pending: true,
..SearchSnapshot::default()
};
let effects = reduce_action(&mut state, UiAction::SearchChanged("ambient".into()));
assert_eq!(effects.len(), 2);
assert_eq!(
effects[0],
Effect::Send(BackendCommand::CancelSearch {
request_id: RequestId::new(9)
})
);
assert!(matches!(
effects[1],
Effect::Send(BackendCommand::Search { .. })
));
}
#[test]
fn stale_backend_snapshots_are_ignored() {
let mut state = AppState::default();
state.backend.revision = 8;
reduce_event(
&mut state,
AppEvent::BackendSnapshot(Box::new(BackendSnapshot {
revision: 7,
..BackendSnapshot::default()
})),
);
assert_eq!(state.backend.revision, 8);
}
#[test]
fn detail_navigation_returns_to_the_previous_screen() {
let mut state = AppState::default();
let artist = ArtistKey::Federation {
peer_id: "peer-a".into(),
id: "artist-a".into(),
};
reduce_action(&mut state, UiAction::Navigate(Screen::Artist(artist)));
reduce_action(&mut state, UiAction::Back);
assert_eq!(state.frontend.screen, Screen::Home);
assert!(state.frontend.navigation_history.is_empty());
}
#[test]
fn forward_navigation_restores_the_screen_left_by_back() {
let mut state = AppState::default();
let artist = ArtistKey::Federation {
peer_id: "peer-a".into(),
id: "artist-a".into(),
};
let artist_screen = Screen::Artist(artist);
reduce_action(&mut state, UiAction::Navigate(artist_screen.clone()));
reduce_action(&mut state, UiAction::Back);
reduce_action(&mut state, UiAction::Forward);
assert_eq!(state.frontend.screen, artist_screen);
assert!(state.frontend.navigation_forward.is_empty());
assert_eq!(state.frontend.navigation_history, vec![Screen::Home]);
}
#[test]
fn release_lookup_keeps_the_preferred_artist_name_and_key_together() {
let mut state = AppState::default();
let pasha_key = ArtistKey::Local(furumi_domain::ArtistId::new(1));
let kunteynir_key = ArtistKey::Local(furumi_domain::ArtistId::new(2));
let release_key = ReleaseKey::Local(ReleaseId::new(7));
let artists = [
(kunteynir_key.clone(), "KUNTEYNIR"),
(pasha_key.clone(), "Паша Техник"),
];
state.backend.search.results = SearchResults {
artists: artists
.iter()
.map(|(key, name)| Artist {
key: key.clone(),
source: CatalogSource::Local,
name: (*name).into(),
artwork: Artwork::default(),
release_count: 1,
track_count: 1,
})
.collect(),
releases: vec![Release {
key: release_key.clone(),
source: CatalogSource::Local,
title: "Порядочный".into(),
artists: artists
.iter()
.map(|(key, name)| ArtistRef {
key: key.clone(),
name: (*name).into(),
})
.collect(),
featured_artists: Vec::new(),
release_type: "album".into(),
year: None,
artwork: Artwork::default(),
tracks: Vec::new(),
}],
tracks: Vec::new(),
};
let effects = reduce_action(
&mut state,
UiAction::Navigate(Screen::Release(release_key, Some(pasha_key.clone()))),
);
assert_eq!(
effects,
vec![Effect::Send(BackendCommand::LoadRelease {
request_id: RequestId::new(1),
key: ReleaseKey::Local(ReleaseId::new(7)),
artist_key: pasha_key,
artist_name: "Паша Техник".into(),
})]
);
}
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "furumi-backend-api"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
furumi-domain.workspace = true
[lints]
workspace = true
+346
View File
@@ -0,0 +1,346 @@
//! Transport-independent contract between the frontend application and backend.
use std::fmt;
use furumi_domain::{Artist, Queue, QueueItemId, Release, ReleaseKey, Track, TrackKey};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct RequestId(u64);
impl RequestId {
#[must_use]
pub const fn new(value: u64) -> Self {
Self(value)
}
#[must_use]
pub const fn get(self) -> u64 {
self.0
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum PlaybackStatus {
#[default]
Stopped,
Playing,
Paused,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PlaybackSnapshot {
pub status: PlaybackStatus,
pub position_seconds: f64,
pub duration_seconds: f64,
pub volume: f32,
}
impl Default for PlaybackSnapshot {
fn default() -> Self {
Self {
status: PlaybackStatus::Stopped,
position_seconds: 0.0,
duration_seconds: 0.0,
volume: 0.72,
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub enum RemoteData<T> {
#[default]
NotRequested,
Loading {
request_id: RequestId,
},
Ready(T),
Failed {
message: String,
},
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct LibrarySnapshot {
pub artists: Vec<Artist>,
pub featured_releases: Vec<Release>,
pub recently_played: Vec<Track>,
pub playlists: Vec<PlaylistSnapshot>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct PlaylistSnapshot {
pub id: i64,
pub title: String,
pub is_likes: bool,
pub tracks: Vec<Track>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum DevicePlaybackRole {
#[default]
Active,
Control,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ConnectedDeviceSnapshot {
pub id: String,
pub name: String,
pub client_version: String,
pub is_self: bool,
pub presence: DevicePresence,
pub trust: DeviceTrust,
pub is_active: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum DevicePresence {
#[default]
Offline,
Online,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum DeviceTrust {
#[default]
Trusted,
Revoked,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PendingPairingSnapshot {
pub request_id: String,
pub device_id: String,
pub name: String,
pub client_version: String,
pub requester_group_id: Option<String>,
pub requester_group_active_devices: usize,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ConnectedDevicesSnapshot {
pub this_device_id: String,
pub this_device_name: String,
pub group_id: String,
pub role: DevicePlaybackRole,
pub active_device_id: String,
pub active_device_name: String,
pub devices: Vec<ConnectedDeviceSnapshot>,
pub pending_pairings: Vec<PendingPairingSnapshot>,
pub invite: Option<String>,
pub busy: bool,
pub last_sync: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct SearchResults {
pub artists: Vec<Artist>,
pub releases: Vec<Release>,
pub tracks: Vec<Track>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SearchStats {
pub tracks: usize,
pub artists: usize,
pub peers_queried: usize,
pub duration_ms: u64,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct SearchSnapshot {
pub request_id: Option<RequestId>,
pub results: SearchResults,
pub federation_pending: bool,
pub stats: Option<SearchStats>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum FederationOperation {
#[default]
Idle,
Search,
Artist,
Release,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FederationActivitySnapshot {
pub operation: FederationOperation,
pub pending: bool,
pub stats: Option<SearchStats>,
pub error: Option<String>,
}
/// Lightweight live diagnostics for the federation node.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FederationDebugSnapshot {
pub running: bool,
pub endpoint_id: String,
pub dht_node_id: String,
pub connected_peers: usize,
pub known_contacts: usize,
pub stored_dht_records: Option<usize>,
pub published_items: usize,
pub error: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct VersionEntrySnapshot {
pub name: String,
pub version: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BuildInfoSnapshot {
pub software: Vec<VersionEntrySnapshot>,
pub protocols: Vec<VersionEntrySnapshot>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SettingsSnapshot {
pub network_id: String,
pub device_name: String,
pub library_path: String,
pub federation_enabled: bool,
pub save_federated_on_listen: bool,
pub language: String,
}
impl Default for SettingsSnapshot {
fn default() -> Self {
Self {
network_id: "furumi".into(),
device_name: String::new(),
library_path: "~/Music/Furumi".into(),
federation_enabled: true,
save_federated_on_listen: true,
language: "English".into(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct BackendSnapshot {
pub revision: u64,
pub library: RemoteData<LibrarySnapshot>,
pub playback: PlaybackSnapshot,
pub queue: Queue,
pub search: SearchSnapshot,
pub federation_activity: FederationActivitySnapshot,
pub federation_debug: FederationDebugSnapshot,
pub build_info: BuildInfoSnapshot,
pub connected_devices: ConnectedDevicesSnapshot,
pub settings: SettingsSnapshot,
pub playback_error: Option<String>,
pub settings_error: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum BackendCommand {
Initialize,
TogglePlayback,
Play,
Pause,
Stop,
Seek {
position_seconds: f64,
},
SetVolume {
volume: f32,
},
PlayRelease {
release_id: ReleaseKey,
start: usize,
},
PlayTrack {
track: TrackKey,
},
PlayQueueItem {
item_id: QueueItemId,
},
PlayContext {
tracks: Vec<TrackKey>,
selected: TrackKey,
},
AddNext {
tracks: Vec<TrackKey>,
},
AddToEnd {
tracks: Vec<TrackKey>,
},
ToggleLike {
track: TrackKey,
},
CreatePlaylist {
title: String,
},
RenamePlaylist {
playlist_id: i64,
title: String,
},
DeletePlaylist {
playlist_id: i64,
},
AddToPlaylist {
playlist_id: i64,
tracks: Vec<TrackKey>,
},
RemoveFromPlaylist {
playlist_id: i64,
tracks: Vec<TrackKey>,
},
CreateDeviceInvite,
ConnectDevice {
invite: String,
},
AnswerDevicePairing {
request_id: String,
accept: bool,
use_requester_group: bool,
},
SelectPlaybackDevice {
device_id: String,
},
Next,
Previous,
Search {
request_id: RequestId,
query: String,
},
CancelSearch {
request_id: RequestId,
},
LoadArtist {
request_id: RequestId,
key: furumi_domain::ArtistKey,
name: String,
},
LoadRelease {
request_id: RequestId,
key: ReleaseKey,
artist_key: furumi_domain::ArtistKey,
artist_name: String,
},
UpdateSettings(SettingsSnapshot),
Shutdown,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SendCommandError {
Busy,
Closed,
}
impl fmt::Display for SendCommandError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Busy => formatter.write_str("backend command queue is full"),
Self::Closed => formatter.write_str("backend is unavailable"),
}
}
}
impl std::error::Error for SendCommandError {}
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "furumi-backend"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
anyhow.workspace = true
furumi-backend-api.workspace = true
furumi-domain.workspace = true
furumi-library.workspace = true
music-dht.workspace = true
directories.workspace = true
rusqlite.workspace = true
rodio.workspace = true
tokio.workspace = true
tokio-util.workspace = true
serde_json.workspace = true
serde.workspace = true
[lints]
workspace = true
+33
View File
@@ -0,0 +1,33 @@
use std::path::PathBuf;
fn main() {
let manifest_dir = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap());
let lock_path = manifest_dir.join("../../Cargo.lock");
println!("cargo:rerun-if-changed={}", lock_path.display());
let lock = std::fs::read_to_string(&lock_path).unwrap_or_default();
for (package, variable) in [
("furumi-library", "FURUMI_LIBRARY_VERSION"),
("music-dht", "FURUMI_MUSIC_DHT_VERSION"),
("federation-net", "FURUMI_FEDERATION_NET_VERSION"),
] {
println!(
"cargo:rustc-env={variable}={}",
package_version(&lock, package).unwrap_or("unknown")
);
}
}
fn package_version<'a>(lock: &'a str, package: &str) -> Option<&'a str> {
lock.split("[[package]]").find_map(|section| {
let mut name = None;
let mut version = None;
for line in section.lines().map(str::trim) {
if let Some(value) = line.strip_prefix("name = \"") {
name = value.strip_suffix('"');
} else if let Some(value) = line.strip_prefix("version = \"") {
version = value.strip_suffix('"');
}
}
(name == Some(package)).then_some(version).flatten()
})
}
+481
View File
@@ -0,0 +1,481 @@
use super::{
Actor, ArtistKey, ArtistRef, AudioSource, ConnectedDeviceSnapshot, ConnectedDevicesSnapshot,
ContentId, ControlPlaybackAnchor, DeviceOperationResult, DevicePlaybackRole, DevicePresence,
DeviceTrust, Duration, Instant, InternalEvent, PendingControlState, PendingPairingSnapshot,
PlaybackStatus, ReleaseKey, Track, TrackKey, library_track, normalize_device_name,
portable_playback_placeholder, track_to_playback_track, unix_time_ms, volume_percent,
};
impl Actor {
pub(super) fn create_device_invite(&mut self) {
let Some(service) = self.device_service.clone() else {
self.state.connected_devices.error =
Some("Federation network is still starting".into());
self.publish();
return;
};
self.state.connected_devices.busy = true;
self.state.connected_devices.error = None;
self.publish();
let devices = std::sync::Arc::clone(&self.devices);
let internal = self.internal.clone();
tokio::spawn(async move {
let result = devices
.create_invite(service)
.await
.map(DeviceOperationResult::Invite)
.map_err(|error| format!("{error:#}"));
let _ = internal
.send(InternalEvent::DeviceOperationFinished(result))
.await;
});
}
pub(super) fn connect_device(&mut self, invite: String) {
let Some(service) = self.device_service.clone() else {
self.state.connected_devices.error =
Some("Federation network is still starting".into());
self.publish();
return;
};
self.state.connected_devices.busy = true;
self.state.connected_devices.error = None;
self.publish();
let devices = std::sync::Arc::clone(&self.devices);
let internal = self.internal.clone();
tokio::spawn(async move {
let result = devices
.connect_invite(service, &invite)
.await
.map(DeviceOperationResult::Connected)
.map_err(|error| format!("{error:#}"));
let _ = internal
.send(InternalEvent::DeviceOperationFinished(result))
.await;
});
}
pub(super) fn apply_local_device_name(&mut self, name: &str) {
let name = normalize_device_name(name);
if let Err(error) = self.devices.set_device_name(&name, None) {
self.state.connected_devices.error = Some(format!("device name: {error:#}"));
return;
}
if self.active_device_id == self.state.connected_devices.this_device_id {
self.active_device_name = name;
}
self.refresh_connected_devices();
}
pub(super) fn schedule_device_name_publish(&self, candidate: String) {
let internal = self.internal.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(500)).await;
let _ = internal
.send(InternalEvent::DeviceNamePublishDue(candidate))
.await;
});
}
pub(super) fn publish_device_name(&self, name: String) {
let Some(service) = self.device_service.clone() else {
return;
};
let devices = std::sync::Arc::clone(&self.devices);
let internal = self.internal.clone();
tokio::spawn(async move {
let result = async {
let ticket = service
.ticket()
.await
.map_err(|error| format!("device name: {error:#}"))?;
devices
.set_device_name(&name, Some(&ticket.to_string()))
.map_err(|error| format!("device name: {error:#}"))
}
.await;
let _ = internal
.send(InternalEvent::DeviceNamePublished(result))
.await;
});
}
pub(super) fn refresh_connected_devices(&mut self) {
let status = self.devices.status();
let invite = self.state.connected_devices.invite.take();
let busy = self.state.connected_devices.busy;
let existing_error = self.state.connected_devices.error.take();
self.state.connected_devices = ConnectedDevicesSnapshot {
this_device_id: status.this_device_id,
this_device_name: status.this_device_name,
group_id: status.group_id,
role: self.device_role,
active_device_id: self.active_device_id.clone(),
active_device_name: self.active_device_name.clone(),
devices: status
.devices
.into_iter()
.filter(|device| !device.revoked)
.map(|device| ConnectedDeviceSnapshot {
is_active: device.id == self.active_device_id,
id: device.id,
name: device.name,
client_version: device.client_version,
is_self: device.is_self,
presence: if device.online {
DevicePresence::Online
} else {
DevicePresence::Offline
},
trust: if device.revoked {
DeviceTrust::Revoked
} else {
DeviceTrust::Trusted
},
})
.collect(),
pending_pairings: status
.pending
.into_iter()
.map(|pending| PendingPairingSnapshot {
request_id: pending.request_id,
device_id: pending.device_id,
name: pending.name,
client_version: pending.client_version,
requester_group_id: pending.requester_group_id,
requester_group_active_devices: pending.requester_group_active_devices,
})
.collect(),
invite,
busy,
last_sync: status.last_sync,
error: existing_error.or(status.error),
};
}
pub(super) fn select_playback_device(&mut self, device_id: &str) {
// Selecting the device that already owns playback must not rebuild or
// restart the current audio stream.
if device_id == self.active_device_id {
return;
}
let target = self
.state
.connected_devices
.devices
.iter()
.find(|device| device.id == device_id && device.trust == DeviceTrust::Trusted)
.cloned();
let Some(target) = target else {
return;
};
let wire = self.playback_wire_state();
let previous = self.active_device_id.clone();
let mut urgent_targets = Vec::with_capacity(2);
if target.is_self {
if previous != target.id {
let command = music_dht::device_sync::PlaybackCommand::ActiveChanged {
active_device_id: target.id.clone(),
active_device_name: target.name.clone(),
state: wire.clone(),
};
if let Err(error) = self.devices.record_playback_command(&previous, command) {
self.state.connected_devices.error = Some(format!("device handoff: {error:#}"));
self.publish();
return;
}
urgent_targets.push(previous.clone());
}
self.device_role = DevicePlaybackRole::Active;
self.active_device_id = target.id;
self.active_device_name = target.name;
self.control_anchor = None;
self.pending_control = None;
if self.state.playback.status == PlaybackStatus::Playing {
self.play_current();
}
} else {
let command = music_dht::device_sync::PlaybackCommand::ActiveChanged {
active_device_id: target.id.clone(),
active_device_name: target.name.clone(),
state: wire.clone(),
};
if let Err(error) = self
.devices
.record_playback_command(&target.id, command.clone())
{
self.state.connected_devices.error = Some(format!("device handoff: {error:#}"));
self.publish();
return;
}
urgent_targets.push(target.id.clone());
if previous != target.id && previous != self.state.connected_devices.this_device_id {
if let Err(error) = self.devices.record_playback_command(&previous, command) {
self.state.connected_devices.error =
Some(format!("previous device handoff: {error:#}"));
}
urgent_targets.push(previous);
}
self.audio.stop();
self.device_role = DevicePlaybackRole::Control;
self.active_device_id.clone_from(&target.id);
self.active_device_name = target.name;
self.control_anchor = Some(ControlPlaybackAnchor {
device_id: target.id.clone(),
state: wire.clone(),
observed_at: Instant::now(),
});
self.pending_control = Some(PendingControlState {
device_id: target.id,
state: wire,
seek: true,
sent_at: Instant::now(),
});
}
self.devices.request_sync();
if let Some(service) = self.device_service.clone() {
urgent_targets.sort();
urgent_targets.dedup();
for target_id in urgent_targets {
let devices = std::sync::Arc::clone(&self.devices);
let service = std::sync::Arc::clone(&service);
tokio::spawn(async move {
let _ = devices.sync_target(service, &target_id).await;
});
}
}
self.refresh_connected_devices();
self.publish_device_playback();
self.publish();
}
pub(super) fn playback_wire_state(&self) -> music_dht::device_sync::PlaybackStateWire {
music_dht::device_sync::PlaybackStateWire {
queue: self
.state
.queue
.items()
.iter()
.map(|item| track_to_playback_track(&item.track))
.collect(),
queue_pos: self.state.queue.current_index().unwrap_or(0),
playing: self.state.playback.status != PlaybackStatus::Stopped,
paused: self.state.playback.status == PlaybackStatus::Paused,
idle_since_ms: (self.state.playback.status != PlaybackStatus::Playing)
.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,
}
}
pub(super) fn publish_device_playback(&self) {
let snapshot = music_dht::device_sync::PlaybackSnapshot {
device_id: self.state.connected_devices.this_device_id.clone(),
device_name: self.state.connected_devices.this_device_name.clone(),
active: self.device_role == DevicePlaybackRole::Active,
updated_at_ms: unix_time_ms(),
state: self.playback_wire_state(),
};
self.devices.publish_playback(snapshot);
}
pub(super) fn send_control_state(&mut self, seek: bool) {
if self.device_role != DevicePlaybackRole::Control {
return;
}
let state = self.playback_wire_state();
let command = music_dht::device_sync::PlaybackCommand::SetState {
state: state.clone(),
seek,
};
if let Err(error) = self
.devices
.record_playback_command(&self.active_device_id, command)
{
self.state.connected_devices.error = Some(format!("device control: {error:#}"));
} else {
self.pending_control = Some(PendingControlState {
device_id: self.active_device_id.clone(),
state,
seek,
sent_at: Instant::now(),
});
}
}
pub(super) fn apply_device_playback_state(
&mut self,
wire: &music_dht::device_sync::PlaybackStateWire,
start_audio: bool,
seek: bool,
) {
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);
self.resolve_queue_artwork();
}
self.state.playback.volume = f32::from(wire.volume.min(100)) / 100.0;
self.state.playback.position_seconds = wire.position_secs.max(0.0);
self.state.playback.duration_seconds = self
.state
.queue
.current()
.map_or(0.0, |item| item.track.duration_seconds);
self.state.playback.status = if !wire.playing {
PlaybackStatus::Stopped
} else if wire.paused {
PlaybackStatus::Paused
} else {
PlaybackStatus::Playing
};
self.audio.set_volume(self.state.playback.volume);
if !start_audio {
return;
}
if wire.playing {
self.play_current();
if seek || wire.position_secs > 0.0 {
self.audio
.seek(Duration::from_secs_f64(wire.position_secs.max(0.0)));
self.state.playback.position_seconds = wire.position_secs.max(0.0);
}
if wire.paused {
self.audio.pause();
self.state.playback.status = PlaybackStatus::Paused;
}
} else {
self.audio.stop();
}
}
pub(super) fn apply_device_playback_command(
&mut self,
command: music_dht::device_sync::PlaybackCommand,
) {
match command {
music_dht::device_sync::PlaybackCommand::SetState { state, seek } => {
self.device_role = DevicePlaybackRole::Active;
self.active_device_id = self.state.connected_devices.this_device_id.clone();
self.active_device_name = self.state.connected_devices.this_device_name.clone();
self.control_anchor = None;
self.pending_control = None;
self.apply_device_playback_state(&state, true, seek);
}
music_dht::device_sync::PlaybackCommand::ActiveChanged {
active_device_id,
active_device_name,
state,
} => {
let is_self = active_device_id == self.state.connected_devices.this_device_id;
self.active_device_id = active_device_id;
self.active_device_name = active_device_name;
self.device_role = if is_self {
DevicePlaybackRole::Active
} else {
DevicePlaybackRole::Control
};
self.pending_control = None;
self.control_anchor = (!is_self).then(|| ControlPlaybackAnchor {
device_id: self.active_device_id.clone(),
state: state.clone(),
observed_at: Instant::now(),
});
if !is_self {
self.audio.stop();
}
self.apply_device_playback_state(&state, is_self, true);
}
}
self.refresh_connected_devices();
self.publish();
}
pub(super) fn resolve_playback_track(
&self,
wire: &music_dht::device_sync::PlaybackTrack,
) -> Option<Track> {
if let Some(content_id) = wire
.content_id
.as_deref()
.and_then(|id| ContentId::parse(id).ok())
{
let key = TrackKey::remote(content_id.clone());
if let Some(track) = self.track(&key) {
return Some(track.clone());
}
if let Ok(Some(track)) = self.catalog.track_by_content_id(content_id.as_str()) {
return Some(library_track(track, ""));
}
}
let Some(fed) = wire.fed.as_ref() else {
let content_id = wire
.content_id
.as_deref()
.and_then(|id| ContentId::parse(id).ok())?;
return Some(portable_playback_placeholder(wire, content_id));
};
let content_id = ContentId::parse(&fed.content_id).ok()?;
let release_key = ReleaseKey::Federation {
peer_id: fed.owner.clone(),
id: format!(
"name:{}",
music_dht::normalize_name(fed.release_title.as_deref().unwrap_or_default())
),
};
let refs = |names: &[String]| {
names
.iter()
.map(|name| ArtistRef {
key: ArtistKey::Federation {
peer_id: fed.owner.clone(),
id: format!("name:{}", music_dht::normalize_name(name)),
},
name: name.clone(),
})
.collect::<Vec<_>>()
};
Some(Track {
key: TrackKey::federation(
fed.owner.clone(),
fed.item_id.clone(),
Some(content_id.clone()),
),
title: wire.title.clone(),
artist: wire.artist_names.join(", "),
artists: refs(&wire.artist_names),
featured_artists: refs(&wire.featured_artist_names),
release: wire.release_title.clone(),
release_id: release_key,
duration_seconds: wire.duration_seconds,
track_number: wire
.track_number
.and_then(|value| u32::try_from(value).ok()),
disc_number: wire.disc_number.and_then(|value| u32::try_from(value).ok()),
cover_uri: None,
audio_format: wire.audio_format.clone(),
audio_bitrate_kbps: wire
.audio_bitrate
.and_then(|value| u32::try_from(value).ok()),
audio_sample_rate_hz: wire
.audio_sample_rate
.and_then(|value| u32::try_from(value).ok()),
audio_bit_depth: wire
.audio_bit_depth
.and_then(|value| u32::try_from(value).ok()),
file_size_bytes: wire
.file_size_bytes
.and_then(|value| u64::try_from(value).ok()),
liked: false,
audio_source: AudioSource::Federation {
peer_id: fed.owner.clone(),
content_id,
},
})
}
}
+281
View File
@@ -0,0 +1,281 @@
//! Real audio output isolated from the backend actor and UI thread.
use std::fs::File;
use std::io;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
use std::time::Duration;
use rodio::{Decoder, DeviceSinkBuilder, Player, stream::MixerDeviceSink};
pub trait TrackReadSeek: std::io::Read + std::io::Seek + Send + Sync {}
impl<T> TrackReadSeek for T where T: std::io::Read + std::io::Seek + Send + Sync {}
pub type TrackReader = Box<dyn TrackReadSeek>;
#[derive(Debug)]
pub enum Event {
Started,
Finished,
Failed(String),
}
enum Command {
Play {
path: PathBuf,
volume: f32,
},
PlayStream {
reader: TrackReader,
mime_type: String,
volume: f32,
},
Pause,
Resume,
Stop,
Seek(Duration),
SetVolume(f32),
}
#[derive(Debug, Default)]
pub struct Shared {
position_ms: AtomicU64,
}
impl Shared {
pub fn position_seconds(&self) -> f64 {
Duration::from_millis(self.position_ms.load(Ordering::Relaxed)).as_secs_f64()
}
}
#[derive(Clone)]
pub struct Controller {
commands: Sender<Command>,
pub shared: Arc<Shared>,
}
impl Controller {
pub fn play(&self, path: PathBuf, volume: f32) {
self.shared.position_ms.store(0, Ordering::Relaxed);
let _ = self.commands.send(Command::Play { path, volume });
}
pub fn play_stream(&self, reader: TrackReader, mime_type: String, volume: f32) {
self.shared.position_ms.store(0, Ordering::Relaxed);
let _ = self.commands.send(Command::PlayStream {
reader,
mime_type,
volume,
});
}
pub fn pause(&self) {
let _ = self.commands.send(Command::Pause);
}
pub fn resume(&self) {
let _ = self.commands.send(Command::Resume);
}
pub fn stop(&self) {
let _ = self.commands.send(Command::Stop);
}
pub fn seek(&self, position: Duration) {
let millis = u64::try_from(position.as_millis()).unwrap_or(u64::MAX);
self.shared.position_ms.store(millis, Ordering::Relaxed);
let _ = self.commands.send(Command::Seek(position));
}
pub fn set_volume(&self, volume: f32) {
let _ = self.commands.send(Command::SetVolume(volume));
}
}
pub fn spawn(on_event: impl Fn(Event) + Send + 'static) -> io::Result<Controller> {
let (commands, receiver) = std::sync::mpsc::channel();
let shared = Arc::new(Shared::default());
let thread_shared = Arc::clone(&shared);
std::thread::Builder::new()
.name("furumi-audio".into())
.spawn(move || run(&receiver, &thread_shared, &on_event))?;
Ok(Controller { commands, shared })
}
struct Output {
_device: MixerDeviceSink,
player: Player,
}
fn run(receiver: &Receiver<Command>, shared: &Arc<Shared>, on_event: &impl Fn(Event)) {
let mut output: Option<Output> = None;
let mut track_loaded = false;
let mut previous_queue_len = 0;
loop {
match receiver.recv_timeout(Duration::from_millis(50)) {
Ok(command) => {
handle(command, shared, &mut output, &mut track_loaded, on_event);
previous_queue_len = output.as_ref().map_or(0, |output| output.player.len());
}
Err(RecvTimeoutError::Timeout) => {
if let Some(output) = &output {
let millis =
u64::try_from(output.player.get_pos().as_millis()).unwrap_or(u64::MAX);
shared.position_ms.store(millis, Ordering::Relaxed);
let queue_len = output.player.len();
if track_loaded && queue_len < previous_queue_len {
track_loaded = false;
on_event(Event::Finished);
}
previous_queue_len = queue_len;
}
}
Err(RecvTimeoutError::Disconnected) => return,
}
}
}
#[allow(clippy::too_many_lines, reason = "exhaustive audio command dispatcher")]
fn handle(
command: Command,
shared: &Arc<Shared>,
output: &mut Option<Output>,
track_loaded: &mut bool,
on_event: &impl Fn(Event),
) {
match command {
Command::Play { path, volume } => {
let output = match ensure_output(output) {
Ok(output) => output,
Err(error) => {
on_event(Event::Failed(format!("cannot open audio output: {error}")));
return;
}
};
let file = match File::open(&path) {
Ok(file) => file,
Err(error) => {
on_event(Event::Failed(format!(
"cannot open {}: {error}",
path.display()
)));
return;
}
};
let byte_len = file.metadata().ok().map(|metadata| metadata.len());
let mut decoder = Decoder::builder()
.with_data(file)
.with_seekable(true)
.with_gapless(true);
if let Some(byte_len) = byte_len {
decoder = decoder.with_byte_len(byte_len);
}
match decoder.build() {
Ok(source) => {
output.player.stop();
output.player.set_volume(amplitude(volume));
output.player.append(source);
output.player.play();
shared.position_ms.store(0, Ordering::Relaxed);
*track_loaded = true;
on_event(Event::Started);
}
Err(error) => on_event(Event::Failed(format!(
"cannot decode {}: {error}",
path.display()
))),
}
}
Command::PlayStream {
reader,
mime_type,
volume,
} => {
let output = match ensure_output(output) {
Ok(output) => output,
Err(error) => {
on_event(Event::Failed(format!("cannot open audio output: {error}")));
return;
}
};
match Decoder::builder()
.with_data(reader)
.with_mime_type(&mime_type)
.with_seekable(false)
.with_gapless(true)
.build()
{
Ok(source) => {
output.player.stop();
output.player.set_volume(amplitude(volume));
output.player.append(source);
output.player.play();
shared.position_ms.store(0, Ordering::Relaxed);
*track_loaded = true;
on_event(Event::Started);
}
Err(error) => on_event(Event::Failed(format!(
"cannot decode federated stream: {error}"
))),
}
}
Command::Pause => {
if let Some(output) = output {
output.player.pause();
}
}
Command::Resume => {
if let Some(output) = output {
output.player.play();
}
}
Command::Stop => {
if let Some(output) = output {
output.player.stop();
}
shared.position_ms.store(0, Ordering::Relaxed);
*track_loaded = false;
}
Command::Seek(position) => {
if let Some(output) = output
&& let Err(error) = output.player.try_seek(position)
{
on_event(Event::Failed(format!("cannot seek: {error}")));
}
}
Command::SetVolume(volume) => {
if let Some(output) = output {
output.player.set_volume(amplitude(volume));
}
}
}
}
fn ensure_output(output: &mut Option<Output>) -> Result<&Output, rodio::stream::DeviceSinkError> {
if output.is_none() {
let device = DeviceSinkBuilder::open_default_sink()?;
let player = Player::connect_new(device.mixer());
*output = Some(Output {
_device: device,
player,
});
}
Ok(output.as_ref().expect("audio output initialized above"))
}
fn amplitude(volume: f32) -> f32 {
volume.clamp(0.0, 1.0).powi(3)
}
#[cfg(test)]
mod tests {
use super::amplitude;
#[test]
fn perceptual_volume_is_clamped_and_cubic() {
assert!(amplitude(-1.0).abs() < f32::EPSILON);
assert!((amplitude(0.5) - 0.125).abs() < f32::EPSILON);
assert!((amplitude(2.0) - 1.0).abs() < f32::EPSILON);
}
}
File diff suppressed because it is too large Load Diff
+875
View File
@@ -0,0 +1,875 @@
use super::*;
impl DeviceSync {
pub(super) fn ensure_identity(&self) -> Result<Identity> {
let conn = lock(&self.conn);
if let (Some(device_id), Some(group_id), Some(name)) = (
get_meta(&conn, "device_id")?,
get_meta(&conn, "group_id")?,
get_meta(&conn, "device_name")?,
) {
return Ok(Identity {
device_id,
group_id,
name,
});
}
let seed = random_hex(32);
let digest = hash_secret(&seed);
let device_id = format!("dev_{}", &digest[..24]);
let group_id = format!("grp_{}", &hash_secret(&device_id)[..24]);
let name = format!("Furumi on {}", std::env::consts::OS);
set_meta(&conn, "device_id", &device_id)?;
set_meta(&conn, "device_secret", &seed)?;
set_meta(&conn, "group_id", &group_id)?;
set_meta(&conn, "device_name", &name)?;
set_meta(&conn, "local_seq", "0")?;
set_meta(&conn, "last_hlc_ms", "0")?;
conn.execute(
"INSERT INTO sync_devices
(device_id, name, client_version, protocol_version, trusted_at_ms, last_seen_ms)
VALUES (?1, ?2, ?3, ?4, ?5, ?5)
ON CONFLICT(device_id) DO UPDATE SET name = excluded.name,
client_version = excluded.client_version,
protocol_version = excluded.protocol_version,
trusted_at_ms = COALESCE(sync_devices.trusted_at_ms, excluded.trusted_at_ms)",
params![
device_id,
name,
CLIENT_VERSION,
DEVICE_SYNC_PROTOCOL_VERSION,
now_ms()
],
)?;
Ok(Identity {
device_id,
group_id,
name,
})
}
pub(super) fn set_group_id(&self, group_id: &str) -> Result<()> {
let conn = lock(&self.conn);
set_meta(&conn, "group_id", group_id)
}
pub(super) fn set_meta(&self, key: &str, value: &str) -> Result<()> {
set_meta(&lock(&self.conn), key, value)
}
pub(super) fn own_profile(&self, ticket: &str) -> Result<DeviceProfileWire> {
let identity = self.ensure_identity()?;
Ok(DeviceProfileWire {
device_id: identity.device_id,
name: identity.name,
client_version: CLIENT_VERSION.into(),
protocol_version: DEVICE_SYNC_PROTOCOL_VERSION,
endpoint_id: ticket_endpoint_id(ticket).unwrap_or_default(),
endpoint_ticket: ticket.into(),
revoked: false,
revoke_cutoff_seq: None,
updated_at_ms: now_ms(),
})
}
pub(super) fn active_device_count(&self) -> Result<usize> {
let count = lock(&self.conn).query_row(
"SELECT COUNT(*) FROM sync_devices
WHERE trusted_at_ms IS NOT NULL AND revoked_at_ms IS NULL",
[],
|row| row.get::<_, i64>(0),
)?;
Ok(usize::try_from(count.max(0)).unwrap_or(usize::MAX))
}
pub(super) fn status_inner(&self) -> Result<Status> {
let identity = self.ensure_identity()?;
let conn = lock(&self.conn);
let now = now_ms();
let devices = {
let mut stmt = conn.prepare(
"SELECT device_id, name, client_version, last_seen_ms
FROM sync_devices
WHERE trusted_at_ms IS NOT NULL AND revoked_at_ms IS NULL
ORDER BY name COLLATE NOCASE, device_id",
)?;
stmt.query_map([], |row| {
let id: String = row.get(0)?;
let last_seen: Option<i64> = row.get(3)?;
Ok(DeviceRow {
is_self: id == identity.device_id,
online: id == identity.device_id
|| last_seen.is_some_and(|seen| now.saturating_sub(seen) <= ONLINE_TTL_MS),
id,
name: row.get(1)?,
client_version: row.get(2)?,
revoked: false,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?
};
let pending = {
let mut stmt = conn.prepare(
"SELECT request_id, device_id, name, client_version,
requester_group_id, requester_group_active_devices
FROM sync_pending_pairing WHERE status = 'pending'
ORDER BY created_at_ms",
)?;
stmt.query_map([], |row| {
Ok(PendingPairing {
request_id: row.get(0)?,
device_id: row.get(1)?,
name: row.get(2)?,
client_version: row.get(3)?,
requester_group_id: row.get(4)?,
requester_group_active_devices: usize::try_from(row.get::<_, i64>(5)?.max(0))
.unwrap_or(usize::MAX),
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?
};
Ok(Status {
this_device_id: identity.device_id,
this_device_name: identity.name,
group_id: identity.group_id,
devices,
pending,
last_sync: get_meta(&conn, "last_sync")?,
error: get_meta(&conn, "last_error")?,
})
}
pub(super) fn device_profiles(&self) -> Result<Vec<DeviceProfileWire>> {
let conn = lock(&self.conn);
let mut stmt = conn.prepare(
"SELECT device_id, name, client_version, protocol_version, endpoint_id,
endpoint_ticket, revoked_at_ms IS NOT NULL, revoke_cutoff_seq,
MAX(COALESCE(last_seen_ms, 0), COALESCE(trusted_at_ms, 0),
COALESCE(revoked_at_ms, 0))
FROM sync_devices WHERE trusted_at_ms IS NOT NULL",
)?;
Ok(stmt
.query_map([], |row| {
Ok(DeviceProfileWire {
device_id: row.get(0)?,
name: row.get(1)?,
client_version: row.get(2)?,
protocol_version: row.get::<_, u16>(3)?,
endpoint_id: row.get(4)?,
endpoint_ticket: row.get(5)?,
revoked: row.get::<_, i64>(6)? != 0,
revoke_cutoff_seq: row.get(7)?,
updated_at_ms: row.get(8)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?)
}
pub(super) fn apply_device_profiles(&self, profiles: &[DeviceProfileWire]) -> Result<()> {
for profile in profiles {
self.apply_device_profile(profile, false)?;
}
Ok(())
}
pub(super) fn apply_device_profile(
&self,
profile: &DeviceProfileWire,
trusted: bool,
) -> Result<()> {
if profile.device_id == self.ensure_identity()?.device_id {
return Ok(());
}
let now = now_ms();
lock(&self.conn).execute(
"INSERT INTO sync_devices
(device_id, name, client_version, protocol_version, endpoint_id,
endpoint_ticket, trusted_at_ms, last_seen_ms, revoked_at_ms,
revoke_cutoff_seq)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
ON CONFLICT(device_id) DO UPDATE SET
name = CASE WHEN excluded.last_seen_ms >= COALESCE(sync_devices.last_seen_ms, 0)
THEN excluded.name ELSE sync_devices.name END,
client_version = excluded.client_version,
protocol_version = excluded.protocol_version,
endpoint_id = CASE WHEN excluded.endpoint_id != '' THEN excluded.endpoint_id
ELSE sync_devices.endpoint_id END,
endpoint_ticket = CASE WHEN excluded.endpoint_ticket != '' THEN excluded.endpoint_ticket
ELSE sync_devices.endpoint_ticket END,
trusted_at_ms = COALESCE(sync_devices.trusted_at_ms, excluded.trusted_at_ms),
last_seen_ms = MAX(COALESCE(sync_devices.last_seen_ms, 0), excluded.last_seen_ms),
revoked_at_ms = CASE WHEN excluded.revoked_at_ms IS NOT NULL
THEN excluded.revoked_at_ms ELSE sync_devices.revoked_at_ms END,
revoke_cutoff_seq = COALESCE(excluded.revoke_cutoff_seq,
sync_devices.revoke_cutoff_seq)",
params![
profile.device_id,
profile.name,
profile.client_version,
profile.protocol_version,
profile.endpoint_id,
profile.endpoint_ticket,
trusted.then_some(now),
profile.updated_at_ms.max(now),
profile.revoked.then_some(profile.updated_at_ms.max(now)),
profile.revoke_cutoff_seq,
],
)?;
Ok(())
}
pub(super) fn mark_seen(&self, device_id: &str, endpoint_id: &str) -> Result<()> {
lock(&self.conn).execute(
"UPDATE sync_devices SET last_seen_ms = ?2,
endpoint_id = CASE WHEN ?3 != '' THEN ?3 ELSE endpoint_id END
WHERE device_id = ?1",
params![device_id, now_ms(), endpoint_id],
)?;
Ok(())
}
pub(super) fn record_op(&self, payload: SyncOpPayload) -> Result<()> {
let op = {
let conn = lock(&self.conn);
let identity = Self::ensure_identity_with_conn(&conn)?;
let seq = get_meta(&conn, "local_seq")?
.and_then(|value| value.parse::<i64>().ok())
.unwrap_or(0)
.saturating_add(1);
let previous_hlc = get_meta(&conn, "last_hlc_ms")?
.and_then(|value| value.parse::<i64>().ok())
.unwrap_or(0);
let hlc_ms = now_ms().max(previous_hlc.saturating_add(1));
set_meta(&conn, "local_seq", &seq.to_string())?;
set_meta(&conn, "last_hlc_ms", &hlc_ms.to_string())?;
SyncOpWire {
op_id: format!("{}:{seq}", identity.device_id),
origin_device_id: identity.device_id,
seq,
hlc_ms,
payload,
}
};
self.store_and_apply_op(&op)?;
self.request_sync();
self.notify();
Ok(())
}
pub(super) fn ensure_identity_with_conn(conn: &Connection) -> Result<Identity> {
Ok(Identity {
device_id: get_meta(conn, "device_id")?.context("missing device id")?,
group_id: get_meta(conn, "group_id")?.context("missing device group")?,
name: get_meta(conn, "device_name")?.context("missing device name")?,
})
}
pub(super) fn store_and_apply_op(&self, op: &SyncOpWire) -> Result<()> {
let inserted = lock(&self.conn).execute(
"INSERT OR IGNORE INTO sync_ops
(op_id, origin_device_id, seq, kind, payload_json, hlc_ms,
received_at_ms, tombstone)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
op.op_id,
op.origin_device_id,
op.seq,
payload_kind(&op.payload),
serde_json::to_string(&op.payload)?,
op.hlc_ms,
now_ms(),
i64::from(op.payload.is_tombstone()),
],
)?;
if inserted == 0 {
return Ok(());
}
lock(&self.conn).execute(
"INSERT INTO sync_vectors (device_id, max_seq) VALUES (?1, ?2)
ON CONFLICT(device_id) DO UPDATE SET max_seq = MAX(max_seq, excluded.max_seq)",
params![op.origin_device_id, op.seq],
)?;
self.apply_op(op)
}
pub(super) fn apply_ops(&self, ops: Vec<SyncOpWire>) -> Result<()> {
for op in ops {
if self.should_accept_op(&op)? {
self.store_and_apply_op(&op)?;
}
}
self.notify();
Ok(())
}
pub(super) fn should_accept_op(&self, op: &SyncOpWire) -> Result<bool> {
if op.origin_device_id == self.ensure_identity()?.device_id {
return Ok(true);
}
let conn = lock(&self.conn);
let row = conn
.query_row(
"SELECT trusted_at_ms IS NOT NULL, revoked_at_ms IS NOT NULL,
COALESCE(revoke_cutoff_seq, 0)
FROM sync_devices WHERE device_id = ?1",
[&op.origin_device_id],
|row| {
Ok((
row.get::<_, i64>(0)? != 0,
row.get::<_, i64>(1)? != 0,
row.get::<_, i64>(2)?,
))
},
)
.optional()?;
Ok(row.is_some_and(|(trusted, revoked, cutoff)| trusted && (!revoked || op.seq <= cutoff)))
}
pub(super) fn vector(&self) -> Result<BTreeMap<String, i64>> {
let conn = lock(&self.conn);
let mut stmt = conn.prepare("SELECT device_id, max_seq FROM sync_vectors")?;
Ok(stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
.collect::<rusqlite::Result<BTreeMap<_, _>>>()?)
}
pub(super) fn ops_for_peer(&self, peer: &str) -> Result<Vec<SyncOpWire>> {
let conn = lock(&self.conn);
let mut stmt = conn.prepare(
"SELECT payload_json, op_id, origin_device_id, seq, hlc_ms
FROM sync_ops o
WHERE seq > COALESCE((SELECT max_seq FROM sync_peer_acks a
WHERE a.peer_device_id = ?1
AND a.origin_device_id = o.origin_device_id), 0)
AND (kind != 'playback_command' OR hlc_ms >= ?2)
ORDER BY received_at_ms, origin_device_id, seq LIMIT ?3",
)?;
Ok(stmt
.query_map(
params![
peer,
now_ms().saturating_sub(PLAYBACK_COMMAND_TTL_MS),
i64::try_from(MAX_OPS_PER_BATCH).unwrap_or(i64::MAX)
],
|row| {
let payload: String = row.get(0)?;
Ok(SyncOpWire {
op_id: row.get(1)?,
origin_device_id: row.get(2)?,
seq: row.get(3)?,
hlc_ms: row.get(4)?,
payload: serde_json::from_str(&payload).map_err(|error| {
rusqlite::Error::FromSqlConversionFailure(
0,
rusqlite::types::Type::Text,
Box::new(error),
)
})?,
})
},
)?
.collect::<rusqlite::Result<Vec<_>>>()?)
}
pub(super) fn note_peer_vector(
&self,
peer: &str,
vector: &BTreeMap<String, i64>,
) -> Result<()> {
let conn = lock(&self.conn);
for (origin, seq) in vector {
conn.execute(
"INSERT INTO sync_peer_acks
(peer_device_id, origin_device_id, max_seq, updated_at_ms)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(peer_device_id, origin_device_id) DO UPDATE SET
max_seq = MAX(max_seq, excluded.max_seq),
updated_at_ms = excluded.updated_at_ms",
params![peer, origin, seq, now_ms()],
)?;
}
Ok(())
}
}
impl DeviceSync {
pub(super) fn apply_op(&self, op: &SyncOpWire) -> Result<()> {
match &op.payload {
SyncOpPayload::TrackLikeSet {
content_id,
liked,
fed,
} => self.apply_like(content_id, *liked, fed.as_ref(), op.hlc_ms, &op.op_id)?,
SyncOpPayload::PlaylistCreated { playlist_id, title }
| SyncOpPayload::PlaylistRenamed { playlist_id, title } => {
self.apply_playlist(playlist_id, title, false, op.hlc_ms, &op.op_id)?;
}
SyncOpPayload::PlaylistDeleted { playlist_id } => {
self.apply_playlist(playlist_id, "", true, op.hlc_ms, &op.op_id)?;
}
SyncOpPayload::PlaylistTrackAdded {
playlist_id,
content_id,
position,
fed,
} => self.apply_playlist_item(
playlist_id,
content_id,
true,
*position,
fed.as_ref(),
op.hlc_ms,
&op.op_id,
)?,
SyncOpPayload::PlaylistTrackRemoved {
playlist_id,
content_id,
} => self.apply_playlist_item(
playlist_id,
content_id,
false,
0,
None,
op.hlc_ms,
&op.op_id,
)?,
SyncOpPayload::DeviceProfileSet {
name,
client_version,
endpoint_ticket,
endpoint_id,
} => self.apply_device_profile(
&DeviceProfileWire {
device_id: op.origin_device_id.clone(),
name: name.clone(),
client_version: client_version.clone(),
protocol_version: DEVICE_SYNC_PROTOCOL_VERSION,
endpoint_id: endpoint_id.clone(),
endpoint_ticket: endpoint_ticket.clone(),
revoked: false,
revoke_cutoff_seq: None,
updated_at_ms: op.hlc_ms,
},
false,
)?,
SyncOpPayload::DeviceTrusted { target_device_id } => {
lock(&self.conn).execute(
"UPDATE sync_devices SET trusted_at_ms = MAX(COALESCE(trusted_at_ms, 0), ?2),
revoked_at_ms = CASE WHEN COALESCE(revoked_at_ms, 0) <= ?2
THEN NULL ELSE revoked_at_ms END
WHERE device_id = ?1",
params![target_device_id, op.hlc_ms],
)?;
}
SyncOpPayload::DeviceRevoked {
target_device_id,
target_max_seq_seen,
} => {
lock(&self.conn).execute(
"UPDATE sync_devices SET revoked_at_ms = ?2, revoked_by = ?3,
revoke_cutoff_seq = ?4 WHERE device_id = ?1",
params![
target_device_id,
op.hlc_ms,
op.origin_device_id,
target_max_seq_seen
],
)?;
}
SyncOpPayload::PlaybackCommand {
target_device_id,
command,
} => self.apply_playback_command(target_device_id, command, &op.op_id)?,
SyncOpPayload::ListenRecorded { event } => {
self.library
.apply_listen_event(event, &op.origin_device_id)?;
}
}
Ok(())
}
pub(super) fn apply_like(
&self,
content_id: &str,
liked: bool,
fed: Option<&SyncedFedTrack>,
hlc_ms: i64,
op_id: &str,
) -> Result<()> {
let Some(content_id) = music_dht::normalize_content_id(content_id) else {
return Ok(());
};
if !self.lww_wins("sync_state_likes", "content_id", &content_id, hlc_ms, op_id)? {
return Ok(());
}
lock(&self.conn).execute(
"INSERT INTO sync_state_likes (content_id, liked, hlc_ms, op_id)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(content_id) DO UPDATE SET liked = excluded.liked,
hlc_ms = excluded.hlc_ms, op_id = excluded.op_id",
params![content_id, i64::from(liked), hlc_ms, op_id],
)?;
if let Some(track_id) = self.library.track_id_by_content_id(&content_id)? {
self.library.set_synced_like(track_id, liked, hlc_ms)?;
if liked {
self.library.remove_fed_like_by_content_id(&content_id)?;
}
} else if liked {
if let Some(fed) = fed {
self.library
.upsert_synced_fed_like(&to_library_fed(fed), hlc_ms)?;
}
} else {
self.library.remove_fed_like_by_content_id(&content_id)?;
}
self.notify_library();
Ok(())
}
pub(super) fn apply_playlist(
&self,
playlist_id: &str,
title: &str,
deleted: bool,
hlc_ms: i64,
op_id: &str,
) -> Result<()> {
if !self.lww_wins(
"sync_state_playlists",
"playlist_id",
playlist_id,
hlc_ms,
op_id,
)? {
return Ok(());
}
lock(&self.conn).execute(
"INSERT INTO sync_state_playlists
(playlist_id, title, deleted, hlc_ms, op_id)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(playlist_id) DO UPDATE SET title = excluded.title,
deleted = excluded.deleted, hlc_ms = excluded.hlc_ms,
op_id = excluded.op_id",
params![playlist_id, title, i64::from(deleted), hlc_ms, op_id],
)?;
if deleted {
self.library.delete_playlist_by_sync_id(playlist_id)?;
} else if !title.trim().is_empty() {
self.library.upsert_synced_playlist(playlist_id, title)?;
}
self.notify_library();
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub(super) fn apply_playlist_item(
&self,
playlist_id: &str,
content_id: &str,
present: bool,
position: i64,
fed: Option<&SyncedFedTrack>,
hlc_ms: i64,
op_id: &str,
) -> Result<()> {
let Some(content_id) = music_dht::normalize_content_id(content_id) else {
return Ok(());
};
let current = lock(&self.conn)
.query_row(
"SELECT hlc_ms, op_id FROM sync_state_playlist_items
WHERE playlist_id = ?1 AND content_id = ?2",
params![playlist_id, content_id],
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
)
.optional()?;
if current.is_some_and(|(current_hlc, current_op)| {
(current_hlc, current_op.as_str()) >= (hlc_ms, op_id)
}) {
return Ok(());
}
lock(&self.conn).execute(
"INSERT INTO sync_state_playlist_items
(playlist_id, content_id, present, position, hlc_ms, op_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(playlist_id, content_id) DO UPDATE SET
present = excluded.present, position = excluded.position,
hlc_ms = excluded.hlc_ms, op_id = excluded.op_id",
params![
playlist_id,
content_id,
i64::from(present),
position,
hlc_ms,
op_id
],
)?;
if present {
self.library
.add_content_id_to_synced_playlist(playlist_id, &content_id)?;
if let Some(fed) = fed {
self.library.upsert_fed_playlist_track(
playlist_id,
&to_library_fed(fed),
position,
)?;
} else if let Some(fed) = self.library.fed_like_by_content_id(&content_id)? {
self.library
.upsert_fed_playlist_track(playlist_id, &fed, position)?;
}
} else {
self.library
.remove_content_id_from_synced_playlist(playlist_id, &content_id)?;
}
self.notify_library();
Ok(())
}
pub(super) fn lww_wins(
&self,
table: &str,
key_name: &str,
key: &str,
hlc_ms: i64,
op_id: &str,
) -> Result<bool> {
let sql = format!("SELECT hlc_ms, op_id FROM {table} WHERE {key_name} = ?1");
let current = lock(&self.conn)
.query_row(&sql, [key], |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
})
.optional()?;
Ok(current.is_none_or(|(current_hlc, current_op)| {
(hlc_ms, op_id) > (current_hlc, current_op.as_str())
}))
}
pub(super) fn apply_playback_command(
&self,
target_device_id: &str,
command: &PlaybackCommand,
op_id: &str,
) -> Result<()> {
if target_device_id != self.ensure_identity()?.device_id {
return Ok(());
}
let inserted = lock(&self.conn).execute(
"INSERT OR IGNORE INTO sync_playback_applied (op_id, applied_at_ms)
VALUES (?1, ?2)",
params![op_id, now_ms()],
)?;
if inserted > 0 {
let _ = self
.events
.try_send(InternalEvent::DevicePlaybackCommand(command.clone()));
}
Ok(())
}
pub(super) fn apply_playback_snapshot(&self, snapshot: PlaybackSnapshot) {
if self
.ensure_identity()
.is_ok_and(|identity| identity.device_id == snapshot.device_id)
{
return;
}
let changed = {
let mut playback = lock(&self.playback);
let changed = playback
.remote
.get(&snapshot.device_id)
.is_none_or(|current| snapshot.updated_at_ms > current.updated_at_ms);
if changed {
playback
.remote
.insert(snapshot.device_id.clone(), snapshot.clone());
}
changed
};
if changed {
let _ = self
.events
.try_send(InternalEvent::DevicePlaybackSnapshot(snapshot));
}
}
#[allow(
clippy::too_many_lines,
reason = "the snapshot is one transactional projection of all synchronized entities"
)]
pub(super) fn snapshot(&self) -> Result<SyncSnapshot> {
let conn = lock(&self.conn);
let mut snapshot = SyncSnapshot::default();
{
let mut stmt =
conn.prepare("SELECT content_id, liked, hlc_ms, op_id FROM sync_state_likes")?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, i64>(1)? != 0,
row.get::<_, i64>(2)?,
row.get::<_, String>(3)?,
))
})?;
for row in rows {
let (content_id, liked, hlc_ms, op_id) = row?;
if liked {
snapshot.likes.push(SnapshotLike {
content_id,
hlc_ms,
op_id,
fed: None,
});
} else {
snapshot.unlikes.push(SnapshotLikeTombstone {
content_id,
hlc_ms,
op_id,
});
}
}
}
let mut playlists = BTreeMap::<String, SnapshotPlaylist>::new();
{
let mut stmt = conn.prepare(
"SELECT playlist_id, title, deleted, hlc_ms, op_id
FROM sync_state_playlists",
)?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)? != 0,
row.get::<_, i64>(3)?,
row.get::<_, String>(4)?,
))
})?;
for row in rows {
let (playlist_id, title, deleted, hlc_ms, op_id) = row?;
if deleted {
snapshot.deleted_playlists.push(SnapshotPlaylistTombstone {
playlist_id,
hlc_ms,
op_id,
});
} else {
playlists.insert(
playlist_id.clone(),
SnapshotPlaylist {
playlist_id,
title,
hlc_ms,
op_id,
items: Vec::new(),
},
);
}
}
}
{
let mut stmt = conn.prepare(
"SELECT playlist_id, content_id, present, position, hlc_ms, op_id
FROM sync_state_playlist_items",
)?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)? != 0,
row.get::<_, i64>(3)?,
row.get::<_, i64>(4)?,
row.get::<_, String>(5)?,
))
})?;
for row in rows {
let (playlist_id, content_id, present, position, hlc_ms, op_id) = row?;
if present {
if let Some(playlist) = playlists.get_mut(&playlist_id) {
playlist.items.push(SnapshotPlaylistItem {
content_id,
position,
hlc_ms,
op_id,
fed: None,
});
}
} else {
snapshot
.removed_playlist_items
.push(SnapshotPlaylistItemTombstone {
playlist_id,
content_id,
hlc_ms,
op_id,
});
}
}
}
snapshot.playlists = playlists.into_values().collect();
Ok(snapshot)
}
pub(super) fn apply_snapshot(&self, snapshot: SyncSnapshot) -> Result<()> {
for like in snapshot.likes {
self.apply_like(
&like.content_id,
true,
like.fed.as_ref(),
like.hlc_ms,
&like.op_id,
)?;
}
for like in snapshot.unlikes {
self.apply_like(&like.content_id, false, None, like.hlc_ms, &like.op_id)?;
}
for playlist in snapshot.playlists {
self.apply_playlist(
&playlist.playlist_id,
&playlist.title,
false,
playlist.hlc_ms,
&playlist.op_id,
)?;
for item in playlist.items {
self.apply_playlist_item(
&playlist.playlist_id,
&item.content_id,
true,
item.position,
item.fed.as_ref(),
item.hlc_ms,
&item.op_id,
)?;
}
}
for playlist in snapshot.deleted_playlists {
self.apply_playlist(
&playlist.playlist_id,
"",
true,
playlist.hlc_ms,
&playlist.op_id,
)?;
}
for item in snapshot.removed_playlist_items {
self.apply_playlist_item(
&item.playlist_id,
&item.content_id,
false,
0,
None,
item.hlc_ms,
&item.op_id,
)?;
}
self.notify();
Ok(())
}
pub(super) fn notify(&self) {
let _ = self.events.try_send(InternalEvent::DevicesChanged);
}
pub(super) fn notify_library(&self) {
let _ = self.events.try_send(InternalEvent::DeviceLibraryChanged);
}
}
+858
View File
@@ -0,0 +1,858 @@
//! Federation lifecycle, DHT search and catalog artwork fetching.
use std::collections::{HashMap, HashSet};
use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{Context, Result};
use furumi_backend_api::{FederationDebugSnapshot, SearchResults, SearchStats};
use furumi_domain::{
Artist, ArtistKey, ArtistRef, Artwork, AudioSource, CatalogSource, ContentId, Release,
ReleaseKey, Track, TrackKey,
};
use music_dht::catalog::{
CATALOG_ALPN, CatalogArtist, CatalogImageHeader, CatalogRequest, CatalogResponse,
};
use music_dht::{
EndpointId, ItemKind, ItemSpec, LibraryItem, MusicDhtConfig, MusicDhtService, NetworkId,
RendezvousConfig,
};
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncRead, AsyncReadExt as _};
pub const AUDIO_ALPN: &[u8] = b"furumi-fd/audio/1";
pub const AUDIO_PROTOCOL_VERSION: u16 = 1;
const STREAM_BUFFER: u64 = 2 * 1024 * 1024;
#[derive(Serialize)]
struct AudioRequest {
item_id: String,
offset: u64,
want_cover: bool,
metadata_only: bool,
}
#[derive(Deserialize)]
struct AudioHeader {
ok: bool,
#[serde(default)]
error: Option<String>,
#[serde(default)]
mime_type: String,
#[serde(default)]
total_size: u64,
#[serde(default)]
cover_size: u64,
#[serde(default)]
artist_image_size: u64,
#[serde(default)]
metadata: Option<TrackMetadata>,
}
#[derive(Clone, Default, Deserialize)]
pub struct TrackMetadata {
#[serde(default)]
pub title: String,
#[serde(default)]
pub artists: Vec<String>,
#[serde(default)]
pub featured_artists: Vec<String>,
#[serde(default)]
pub album_artists: Vec<String>,
#[serde(default)]
pub release_title: String,
#[serde(default)]
pub release_type: Option<String>,
pub year: Option<i32>,
pub track_number: Option<i32>,
pub disc_number: Option<i32>,
pub duration_seconds: Option<f64>,
pub audio_format: Option<String>,
pub audio_bitrate: Option<i32>,
pub audio_sample_rate: Option<i32>,
pub audio_bit_depth: Option<i32>,
}
pub enum StreamEvent {
Ready(crate::streaming::GrowingFileReader, String),
Complete(PathBuf, Box<Option<TrackMetadata>>),
Failed(String),
}
const MAX_IMAGE_BYTES: u64 = 16 * 1024 * 1024;
const IMAGE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(4);
pub struct Client {
service: Arc<MusicDhtService>,
media_dir: PathBuf,
}
impl Client {
pub async fn start(data_dir: PathBuf, media_dir: PathBuf, network: &str) -> Result<Arc<Self>> {
tokio::fs::create_dir_all(&data_dir).await?;
tokio::fs::create_dir_all(&media_dir).await?;
let config = MusicDhtConfig::builder()
.data_dir(data_dir)
.network_id(NetworkId::from_name(network))
.rendezvous(RendezvousConfig::default())
.stream_protocol(CATALOG_ALPN)
.stream_protocol(AUDIO_ALPN)
.stream_protocol(music_dht::device_sync::SYNC_ALPN_V1)
.stream_protocol(music_dht::device_sync::SYNC_ALPN_V2)
.build()
.context("invalid federation configuration")?;
let (service, mut events) = MusicDhtService::start(config)
.await
.context("starting federation node")?;
tokio::spawn(async move { while events.recv().await.is_some() {} });
Ok(Arc::new(Self {
service: Arc::new(service),
media_dir,
}))
}
pub fn service(&self) -> Arc<MusicDhtService> {
Arc::clone(&self.service)
}
pub async fn debug_snapshot(&self) -> FederationDebugSnapshot {
let stored_dht_records = self.service.dht_record_count().await.ok();
let published_items = self
.service
.list_local_items()
.await
.map_or(0, |items| items.len());
FederationDebugSnapshot {
running: true,
endpoint_id: self.service.endpoint_id().to_string(),
dht_node_id: self.service.node_id().to_string(),
connected_peers: self.service.connected_peers().len(),
known_contacts: self.service.known_peers().len(),
stored_dht_records,
published_items,
error: None,
}
}
/// Resolves album artwork for a queue item independently of the screen
/// from which the track was enqueued.
pub async fn artwork_for_track(&self, track: &Track) -> Option<PathBuf> {
let peer_id = match &track.audio_source {
AudioSource::Federation { peer_id, .. } if !peer_id.is_empty() => peer_id.as_str(),
_ => track.key.federation_id()?.0,
};
let owner = peer_id.parse::<EndpointId>().ok()?;
let artist = track
.artists
.first()
.map(|artist| artist.name.as_str())
.or_else(|| (!track.artist.is_empty()).then_some(track.artist.as_str()))?;
if track.release.is_empty() {
return None;
}
self.cached_image(
owner,
artist,
Some(&track.release),
&format!("release-{peer_id}-{artist}-{}", track.release),
)
.await
}
pub async fn search(&self, query: &str) -> Result<(SearchResults, SearchStats)> {
let outcome = self.service.search_network(query).await?;
let own = self.service.endpoint_id();
let mut results = convert_items(&outcome.network_results, own);
self.fetch_artwork(&mut results).await;
let stats = SearchStats {
tracks: results.tracks.len(),
artists: results.artists.len(),
peers_queried: outcome.queried_nodes,
duration_ms: u64::try_from(outcome.duration.as_millis()).unwrap_or(u64::MAX),
};
Ok((results, stats))
}
/// Resolves a portable queue entry when another connected device only
/// knows its stable audio content id.
pub async fn track_by_content_id(&self, content_id: &str) -> Result<Track> {
let outcome = self.service.search_content_id(content_id).await?;
let own = self.service.endpoint_id();
let items = outcome
.local_results
.into_iter()
.chain(outcome.network_results)
.collect::<Vec<_>>();
convert_items(&items, own)
.tracks
.into_iter()
.next()
.context("no federation peer currently publishes this track")
}
pub async fn publish(&self, specs: Vec<ItemSpec>) -> Result<()> {
self.service.sync_library(specs).await?;
Ok(())
}
pub async fn artist_card(&self, name: &str) -> Result<(SearchResults, SearchStats)> {
let started = std::time::Instant::now();
let outcome = self.service.search_network(name).await?;
let own = self.service.endpoint_id();
let normalized = music_dht::normalize_name(name);
let owners = outcome
.network_results
.iter()
.filter(|item| {
item.owner != own
&& ((item.kind == ItemKind::Artist && item.normalized_name == normalized)
|| item
.artist_names
.iter()
.chain(item.featured_artist_names.iter())
.any(|artist| music_dht::normalize_name(artist) == normalized))
})
.map(|item| item.owner)
.collect::<HashSet<_>>();
let mut catalogs = Vec::new();
for owner in owners {
if let Ok(Ok(catalog)) = tokio::time::timeout(
std::time::Duration::from_secs(5),
fetch_catalog(&self.service, owner, name),
)
.await
{
catalogs.push((owner.to_string(), catalog));
}
}
let mut result = card_results(name, catalogs);
self.fetch_artwork(&mut result).await;
let stats = SearchStats {
tracks: result.tracks.len(),
artists: result.artists.len(),
peers_queried: outcome.queried_nodes,
duration_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
};
Ok((result, stats))
}
pub async fn stream_track(
&self,
peer: &str,
item_id: &str,
directory: &std::path::Path,
stem: &str,
events: tokio::sync::mpsc::Sender<StreamEvent>,
) -> Result<()> {
use tokio::io::AsyncWriteExt as _;
let owner: EndpointId = peer.parse().context("invalid peer id")?;
tokio::fs::create_dir_all(directory).await?;
let mut stream = self.service.open_stream(owner, AUDIO_ALPN).await?;
let mut request = serde_json::to_vec(&AudioRequest {
item_id: item_id.into(),
offset: 0,
want_cover: false,
metadata_only: false,
})?;
request.push(b'\n');
stream.send.write_all(&request).await?;
stream.send.finish()?;
let header: AudioHeader = serde_json::from_slice(&read_line(&mut stream.recv).await?)?;
anyhow::ensure!(
header.ok,
"{}",
header.error.unwrap_or_else(|| "peer refused audio".into())
);
anyhow::ensure!(
header.cover_size == 0 && header.artist_image_size == 0,
"unexpected image data in audio stream"
);
let extension = match header.mime_type.as_str() {
"audio/mpeg" => "mp3",
"audio/flac" | "audio/x-flac" => "flac",
"audio/ogg" => "ogg",
"audio/opus" => "opus",
"audio/wav" | "audio/x-wav" => "wav",
"audio/mp4" | "audio/x-m4a" => "m4a",
"audio/aac" => "aac",
_ => "bin",
};
let final_path = directory.join(format!("{stem}.{extension}"));
let part_path = directory.join(format!(".{stem}.{extension}.part"));
let mut file = tokio::fs::File::create(&part_path).await?;
let (reader, writer) = crate::streaming::growing_file(&part_path)?;
let mut reader = Some(reader);
let mut started = false;
let mut received = 0u64;
let threshold = header.total_size.clamp(1, STREAM_BUFFER);
let mut chunk = vec![0; 64 * 1024];
while let Some(n) = stream.recv.read(&mut chunk).await? {
file.write_all(&chunk[..n]).await?;
received += n as u64;
writer.add_available(n as u64);
if !started
&& received >= threshold
&& let Some(reader) = reader.take()
{
let _ = events
.send(StreamEvent::Ready(reader, header.mime_type.clone()))
.await;
started = true;
}
}
if !started
&& received > 0
&& let Some(reader) = reader.take()
{
let _ = events
.send(StreamEvent::Ready(reader, header.mime_type.clone()))
.await;
}
writer.finish();
file.flush().await?;
drop(file);
anyhow::ensure!(
header.total_size == 0 || received == header.total_size,
"incomplete audio download: {received}/{}",
header.total_size
);
tokio::fs::rename(&part_path, &final_path).await?;
let _ = events
.send(StreamEvent::Complete(final_path, Box::new(header.metadata)))
.await;
Ok(())
}
async fn fetch_artwork(&self, results: &mut SearchResults) {
for artist in &mut results.artists {
let CatalogSource::Federation { peer_id } = &artist.source else {
continue;
};
let Ok(owner) = peer_id.parse::<EndpointId>() else {
continue;
};
if let Some(path) = self
.cached_image(
owner,
&artist.name,
None,
&format!("artist-{peer_id}-{}", artist.name),
)
.await
{
artist.artwork.uri = Some(path.to_string_lossy().into_owned());
}
}
for release in &mut results.releases {
let CatalogSource::Federation { peer_id } = &release.source else {
continue;
};
let Some(artist) = release.artists.first() else {
continue;
};
let Ok(owner) = peer_id.parse::<EndpointId>() else {
continue;
};
if let Some(path) = self
.cached_image(
owner,
&artist.name,
Some(&release.title),
&format!("release-{peer_id}-{}-{}", artist.name, release.title),
)
.await
{
let uri = path.to_string_lossy().into_owned();
release.artwork.uri = Some(uri.clone());
for track in &mut release.tracks {
if track.cover_uri.is_none() {
track.cover_uri = Some(uri.clone());
}
}
for track in &mut results.tracks {
if track.release == release.title
&& track
.artists
.first()
.is_some_and(|candidate| candidate.name == artist.name)
{
track.cover_uri = Some(uri.clone());
}
}
}
}
for track in &mut results.tracks {
if track.cover_uri.is_some() || track.release.is_empty() {
continue;
}
let AudioSource::Federation { peer_id, .. } = &track.audio_source else {
continue;
};
let Some(artist) = track.artists.first() else {
continue;
};
let Ok(owner) = peer_id.parse::<EndpointId>() else {
continue;
};
if let Some(path) = self
.cached_image(
owner,
&artist.name,
Some(&track.release),
&format!("release-{peer_id}-{}-{}", artist.name, track.release),
)
.await
{
track.cover_uri = Some(path.to_string_lossy().into_owned());
}
}
}
async fn cached_image(
&self,
owner: EndpointId,
artist: &str,
release: Option<&str>,
cache_key: &str,
) -> Option<PathBuf> {
let mut hasher = DefaultHasher::new();
cache_key.hash(&mut hasher);
let base = self.media_dir.join(format!("{:016x}", hasher.finish()));
for extension in ["jpg", "png", "webp", "gif", "bmp"] {
let path = base.with_extension(extension);
if path.is_file() {
return Some(path);
}
}
let fetched = tokio::time::timeout(
IMAGE_TIMEOUT,
fetch_image(&self.service, owner, artist, release),
)
.await
.ok()?
.ok()?;
let (bytes, extension) = fetched?;
let path = base.with_extension(extension);
tokio::fs::write(&path, bytes).await.ok()?;
Some(path)
}
}
async fn fetch_catalog(
service: &MusicDhtService,
owner: EndpointId,
artist: &str,
) -> Result<CatalogArtist> {
let mut stream = service.open_stream(owner, CATALOG_ALPN).await?;
let mut request = serde_json::to_vec(&CatalogRequest {
artist: artist.into(),
..CatalogRequest::default()
})?;
request.push(b'\n');
stream.send.write_all(&request).await?;
stream.send.finish()?;
let mut payload = Vec::new();
stream
.recv
.take(4 * 1024 * 1024 + 1)
.read_to_end(&mut payload)
.await?;
anyhow::ensure!(
payload.len() <= 4 * 1024 * 1024,
"catalog response is too large"
);
let response: CatalogResponse = serde_json::from_slice(&payload)?;
anyhow::ensure!(
response.ok,
"{}",
response.error.unwrap_or_else(|| "catalog rejected".into())
);
response.artist.context("empty artist catalog")
}
#[allow(
clippy::too_many_lines,
reason = "wire catalog conversion is clearest as one pass"
)]
fn card_results(name: &str, catalogs: Vec<(String, CatalogArtist)>) -> SearchResults {
let mut results = SearchResults::default();
if let Some((peer, _)) = catalogs.first() {
results.artists.push(Artist {
key: ArtistKey::Federation {
peer_id: peer.clone(),
id: music_dht::normalize_name(name),
},
source: CatalogSource::Federation {
peer_id: peer.clone(),
},
name: name.into(),
artwork: Artwork::default(),
release_count: 0,
track_count: 0,
});
}
let mut release_slots = HashMap::<String, usize>::new();
for (peer, catalog) in catalogs {
let artist_key = ArtistKey::Federation {
peer_id: peer.clone(),
id: music_dht::normalize_name(name),
};
for remote in catalog.releases {
let normalized = music_dht::normalize_name(&remote.title);
let slot = *release_slots.entry(normalized.clone()).or_insert_with(|| {
results.releases.push(Release {
key: ReleaseKey::Federation {
peer_id: peer.clone(),
id: format!("name:{normalized}"),
},
source: CatalogSource::Federation {
peer_id: peer.clone(),
},
title: remote.title.clone(),
artists: vec![ArtistRef {
key: artist_key.clone(),
name: name.into(),
}],
featured_artists: Vec::new(),
release_type: remote.release_type.clone(),
year: remote.year,
artwork: Artwork::default(),
tracks: Vec::new(),
});
results.releases.len() - 1
});
let release = &mut results.releases[slot];
for item in remote.tracks {
let duplicate = release.tracks.iter().any(|track| {
music_dht::normalize_name(&track.title)
== music_dht::normalize_name(&item.title)
});
if duplicate || item.item_id.is_empty() {
continue;
}
let content_id = item
.content_id
.as_deref()
.and_then(|id| ContentId::parse(id).ok());
let artists = artist_refs(&peer, &item.artists);
let featured = artist_refs(&peer, &item.featured_artists);
let track = Track {
key: TrackKey::federation(
peer.clone(),
item.item_id.clone(),
content_id.clone(),
),
title: item.title,
artist: artist_line(&item.artists, &item.featured_artists),
artists,
featured_artists: featured,
release: release.title.clone(),
release_id: release.key.clone(),
duration_seconds: item.duration_seconds.unwrap_or_default(),
track_number: item
.track_number
.and_then(|value| u32::try_from(value).ok()),
disc_number: item.disc_number.and_then(|value| u32::try_from(value).ok()),
cover_uri: release.artwork.uri.clone(),
audio_format: None,
audio_bitrate_kbps: None,
audio_sample_rate_hz: None,
audio_bit_depth: None,
file_size_bytes: None,
liked: false,
audio_source: AudioSource::Federation {
peer_id: peer.clone(),
content_id: content_id.unwrap_or_else(|| {
ContentId::parse(format!("b3:{}", "0".repeat(64)))
.expect("valid fallback")
}),
},
};
release.tracks.push(track.clone());
results.tracks.push(track);
}
}
for appearance in catalog.appears_on {
if appearance.track.item_id.is_empty() {
continue;
}
let content_id = appearance
.track
.content_id
.as_deref()
.and_then(|id| ContentId::parse(id).ok());
let release_key = ReleaseKey::Federation {
peer_id: peer.clone(),
id: format!(
"name:{}",
music_dht::normalize_name(&appearance.release_title)
),
};
results.tracks.push(Track {
key: TrackKey::federation(
peer.clone(),
appearance.track.item_id.clone(),
content_id.clone(),
),
title: appearance.track.title,
artist: artist_line(
&appearance.track.artists,
&appearance.track.featured_artists,
),
artists: artist_refs(&peer, &appearance.track.artists),
featured_artists: artist_refs(&peer, &appearance.track.featured_artists),
release: appearance.release_title,
release_id: release_key,
duration_seconds: appearance.track.duration_seconds.unwrap_or_default(),
track_number: appearance
.track
.track_number
.and_then(|value| u32::try_from(value).ok()),
disc_number: appearance
.track
.disc_number
.and_then(|value| u32::try_from(value).ok()),
cover_uri: None,
audio_format: None,
audio_bitrate_kbps: None,
audio_sample_rate_hz: None,
audio_bit_depth: None,
file_size_bytes: None,
liked: false,
audio_source: AudioSource::Federation {
peer_id: peer.clone(),
content_id: content_id.unwrap_or_else(|| {
ContentId::parse(format!("b3:{}", "0".repeat(64))).expect("valid fallback")
}),
},
});
}
}
for release in &mut results.releases {
populate_release_contributors(release);
}
if let Some(artist) = results.artists.first_mut() {
artist.release_count = results.releases.len();
artist.track_count = results.tracks.len();
}
results
}
fn populate_release_contributors(release: &mut Release) {
let mut known = release
.artists
.iter()
.chain(release.featured_artists.iter())
.map(|artist| music_dht::normalize_name(&artist.name))
.collect::<HashSet<_>>();
for artist in release
.tracks
.iter()
.flat_map(|track| track.artists.iter().chain(track.featured_artists.iter()))
{
if known.insert(music_dht::normalize_name(&artist.name)) {
release.featured_artists.push(artist.clone());
}
}
}
fn convert_items(items: &[LibraryItem], own: EndpointId) -> SearchResults {
let mut results = SearchResults::default();
let mut artists = HashMap::<(String, String), Artist>::new();
let mut releases = HashSet::<(String, String)>::new();
let mut tracks = HashSet::<(String, String)>::new();
for item in items.iter().filter(|item| item.owner != own) {
let peer = item.owner.to_string();
let source = CatalogSource::Federation {
peer_id: peer.clone(),
};
let refs = artist_refs(&peer, &item.artist_names);
let featured = artist_refs(&peer, &item.featured_artist_names);
for name in item
.artist_names
.iter()
.chain(item.featured_artist_names.iter())
{
note_artist(&mut artists, &peer, name);
}
match item.kind {
ItemKind::Artist => {
note_artist(&mut artists, &peer, &item.name);
}
ItemKind::Release => {
if releases.insert((peer.clone(), item.id.to_string())) {
results.releases.push(Release {
key: ReleaseKey::Federation {
peer_id: peer.clone(),
id: item.id.to_string(),
},
source,
title: item.name.clone(),
artists: refs,
featured_artists: Vec::new(),
release_type: item
.release_type
.clone()
.unwrap_or_else(|| "release".into()),
year: item.year,
artwork: Artwork::default(),
tracks: Vec::new(),
});
}
}
ItemKind::Track => {
if !tracks.insert((peer.clone(), item.id.to_string())) {
continue;
}
let Some(content_id) = item
.content_id
.as_deref()
.and_then(|value| ContentId::parse(value).ok())
else {
continue;
};
let release = item.release_title.clone().unwrap_or_default();
results.tracks.push(Track {
key: TrackKey::federation(
peer.clone(),
item.id.to_string(),
Some(content_id.clone()),
),
title: item.name.clone(),
artist: artist_line(&item.artist_names, &item.featured_artist_names),
artists: refs.clone(),
featured_artists: featured,
release: release.clone(),
release_id: ReleaseKey::Federation {
peer_id: peer.clone(),
id: format!("name:{}", music_dht::normalize_name(&release)),
},
duration_seconds: item.duration_seconds.unwrap_or_default(),
track_number: item
.track_number
.and_then(|value| u32::try_from(value).ok()),
disc_number: item.disc_number.and_then(|value| u32::try_from(value).ok()),
cover_uri: None,
audio_format: None,
audio_bitrate_kbps: None,
audio_sample_rate_hz: None,
audio_bit_depth: None,
file_size_bytes: None,
liked: false,
audio_source: AudioSource::Federation {
peer_id: peer,
content_id,
},
});
}
}
}
results.artists = artists.into_values().collect();
results
.artists
.sort_by(|left, right| left.name.cmp(&right.name));
results
}
fn note_artist(artists: &mut HashMap<(String, String), Artist>, peer: &str, name: &str) {
let normalized = music_dht::normalize_name(name);
if normalized.is_empty() {
return;
}
artists
.entry((peer.to_owned(), normalized.clone()))
.or_insert_with(|| Artist {
key: ArtistKey::Federation {
peer_id: peer.to_owned(),
id: normalized,
},
source: CatalogSource::Federation {
peer_id: peer.to_owned(),
},
name: name.to_owned(),
artwork: Artwork::default(),
release_count: 0,
track_count: 0,
});
}
fn artist_refs(peer: &str, names: &[String]) -> Vec<ArtistRef> {
names
.iter()
.map(|name| ArtistRef {
key: ArtistKey::Federation {
peer_id: peer.to_owned(),
id: music_dht::normalize_name(name),
},
name: name.clone(),
})
.collect()
}
fn artist_line(main: &[String], featured: &[String]) -> String {
let mut line = main.join(", ");
if !featured.is_empty() {
if !line.is_empty() {
line.push_str(" feat. ");
}
line.push_str(&featured.join(", "));
}
line
}
async fn fetch_image(
service: &MusicDhtService,
owner: EndpointId,
artist: &str,
release: Option<&str>,
) -> Result<Option<(Vec<u8>, &'static str)>> {
let mut stream = service.open_stream(owner, CATALOG_ALPN).await?;
let request = CatalogRequest {
artist: artist.to_owned(),
want: Some(
if release.is_some() {
"release_cover"
} else {
"artist_image"
}
.into(),
),
release: release.map(str::to_owned),
cursor: None,
limit: None,
};
let mut line = serde_json::to_vec(&request)?;
line.push(b'\n');
stream.send.write_all(&line).await?;
stream.send.finish()?;
let header: CatalogImageHeader = serde_json::from_slice(&read_line(&mut stream.recv).await?)?;
if !header.ok || header.size == 0 {
return Ok(None);
}
anyhow::ensure!(
header.size <= MAX_IMAGE_BYTES,
"federated image exceeds size limit"
);
let image_size =
usize::try_from(header.size).context("image is too large for this platform")?;
let mut bytes = vec![0; image_size];
stream.recv.read_exact(&mut bytes).await?;
let extension = match header.mime_type.as_str() {
"image/png" => "png",
"image/webp" => "webp",
"image/gif" => "gif",
"image/bmp" => "bmp",
_ => "jpg",
};
Ok(Some((bytes, extension)))
}
async fn read_line(reader: &mut (impl AsyncRead + Unpin)) -> Result<Vec<u8>> {
let mut line = Vec::new();
loop {
let byte = reader.read_u8().await?;
if byte == b'\n' {
return Ok(line);
}
anyhow::ensure!(line.len() < 64 * 1024, "catalog header is too large");
line.push(byte);
}
}
File diff suppressed because it is too large Load Diff
+153
View File
@@ -0,0 +1,153 @@
use std::fs;
use std::path::Path;
use furumi_backend_api::SettingsSnapshot;
use rusqlite::{Connection, OptionalExtension, params};
const MIGRATIONS: &[(i64, &str)] = &[
(
1,
r"
CREATE TABLE app_settings (
singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 1),
network_id TEXT NOT NULL,
library_path TEXT NOT NULL,
federation_enabled INTEGER NOT NULL CHECK (federation_enabled IN (0, 1)),
language TEXT NOT NULL
);
INSERT INTO app_settings (
singleton_id, network_id, library_path, federation_enabled, language
) VALUES (1, 'furumi', '~/Music/Furumi', 1, 'English');
",
),
(
2,
"ALTER TABLE app_settings ADD COLUMN save_federated_on_listen INTEGER NOT NULL DEFAULT 1 CHECK (save_federated_on_listen IN (0, 1));",
),
(
3,
"ALTER TABLE app_settings ADD COLUMN device_name TEXT NOT NULL DEFAULT '';",
),
];
pub struct SettingsStore {
connection: Connection,
}
impl SettingsStore {
pub fn open(path: &Path) -> rusqlite::Result<Self> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?;
}
let connection = Connection::open(path)?;
let mut store = Self { connection };
store.migrate()?;
Ok(store)
}
#[cfg(test)]
fn in_memory() -> rusqlite::Result<Self> {
let connection = Connection::open_in_memory()?;
let mut store = Self { connection };
store.migrate()?;
Ok(store)
}
pub fn load(&self) -> rusqlite::Result<SettingsSnapshot> {
self.connection.query_row(
"SELECT network_id, library_path, federation_enabled, language,
save_federated_on_listen, device_name
FROM app_settings WHERE singleton_id = 1",
[],
|row| {
Ok(SettingsSnapshot {
network_id: row.get(0)?,
library_path: row.get(1)?,
federation_enabled: row.get::<_, i64>(2)? != 0,
language: row.get(3)?,
save_federated_on_listen: row.get::<_, i64>(4)? != 0,
device_name: row.get(5)?,
})
},
)
}
pub fn save(&self, settings: &SettingsSnapshot) -> rusqlite::Result<()> {
self.connection.execute(
"UPDATE app_settings
SET network_id = ?1,
library_path = ?2,
federation_enabled = ?3,
language = ?4,
save_federated_on_listen = ?5,
device_name = ?6
WHERE singleton_id = 1",
params![
settings.network_id,
settings.library_path,
i64::from(settings.federation_enabled),
settings.language,
i64::from(settings.save_federated_on_listen),
settings.device_name,
],
)?;
Ok(())
}
fn migrate(&mut self) -> rusqlite::Result<()> {
self.connection.execute_batch(
"PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);",
)?;
for &(version, sql) in MIGRATIONS {
let applied = self
.connection
.query_row(
"SELECT 1 FROM schema_migrations WHERE version = ?1",
[version],
|_| Ok(()),
)
.optional()?
.is_some();
if applied {
continue;
}
let transaction = self.connection.transaction()?;
transaction.execute_batch(sql)?;
transaction.execute(
"INSERT INTO schema_migrations (version) VALUES (?1)",
[version],
)?;
transaction.commit()?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn migration_creates_defaults_and_settings_round_trip() {
let store = SettingsStore::in_memory().unwrap();
let mut settings = store.load().unwrap();
assert_eq!(settings.network_id, "furumi");
assert!(settings.federation_enabled);
assert!(settings.save_federated_on_listen);
assert!(settings.device_name.is_empty());
settings.network_id = "friends".into();
settings.device_name = "Studio Mac".into();
settings.library_path = "/music/library".into();
settings.federation_enabled = false;
store.save(&settings).unwrap();
assert_eq!(store.load().unwrap(), settings);
}
}
+97
View File
@@ -0,0 +1,97 @@
use std::fs::File;
use std::io::{self, Read, Seek, SeekFrom};
use std::path::Path;
use std::sync::{Arc, Condvar, Mutex};
#[derive(Default)]
struct State {
available: u64,
complete: bool,
}
#[derive(Default)]
struct Shared {
state: Mutex<State>,
changed: Condvar,
}
pub struct GrowingFileReader {
file: File,
pos: u64,
shared: Arc<Shared>,
}
pub struct GrowingFileWriter {
shared: Arc<Shared>,
}
pub fn growing_file(path: &Path) -> io::Result<(GrowingFileReader, GrowingFileWriter)> {
let shared = Arc::new(Shared::default());
Ok((
GrowingFileReader {
file: File::open(path)?,
pos: 0,
shared: Arc::clone(&shared),
},
GrowingFileWriter { shared },
))
}
impl GrowingFileWriter {
pub fn add_available(&self, bytes: u64) {
let mut state = self
.shared
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.available = state.available.saturating_add(bytes);
self.shared.changed.notify_all();
}
pub fn finish(&self) {
let mut state = self
.shared
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.complete = true;
self.shared.changed.notify_all();
}
}
impl Drop for GrowingFileWriter {
fn drop(&mut self) {
self.finish();
}
}
impl Read for GrowingFileReader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
loop {
let mut state = self
.shared
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
while self.pos >= state.available && !state.complete {
state = self
.shared
.changed
.wait(state)
.unwrap_or_else(std::sync::PoisonError::into_inner);
}
if self.pos >= state.available {
return Ok(0);
}
let n = usize::try_from((state.available - self.pos).min(buf.len() as u64))
.unwrap_or(buf.len());
drop(state);
let read = self.file.read(&mut buf[..n])?;
self.pos += read as u64;
if read > 0 {
return Ok(read);
}
}
}
}
impl Seek for GrowingFileReader {
fn seek(&mut self, _: SeekFrom) -> io::Result<u64> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"federated stream is not seekable while downloading",
))
}
}
+955
View File
@@ -0,0 +1,955 @@
use super::{
Artist, ArtistId, ArtistKey, ArtistRef, Artwork, AudioSource, BuildInfoSnapshot,
CONTROL_POSITION_ACK_TOLERANCE_SECONDS, CatalogSource, ContentId, DevicePlaybackRole, Duration,
HashMap, HashSet, InternalEvent, LibrarySnapshot, LocalTrackId, PlaybackStatus,
PlaylistSnapshot, Release, ReleaseId, ReleaseKey, SearchResults, SettingsSnapshot,
SettingsStore, Track, TrackKey, VersionEntrySnapshot, federation, mpsc, std_mpsc, thread,
};
pub(super) fn expand_tilde(value: &str) -> std::path::PathBuf {
if value == "~" {
directories::UserDirs::new().map_or_else(|| value.into(), |dirs| dirs.home_dir().into())
} else if let Some(rest) = value.strip_prefix("~/") {
directories::UserDirs::new().map_or_else(|| value.into(), |dirs| dirs.home_dir().join(rest))
} else {
value.into()
}
}
pub(super) fn normalize_device_name(value: &str) -> String {
let value = value.trim();
if value.is_empty() {
"furumi".into()
} else {
value.into()
}
}
pub(super) fn selected_track_position(tracks: &[Track], selected: &TrackKey) -> usize {
tracks
.iter()
.position(|track| track.key.matches(selected))
.unwrap_or(0)
}
pub(super) fn runtime_build_info() -> BuildInfoSnapshot {
use music_dht::capabilities::{
CATALOG_ID, CapabilityManifest, DEVICE_SYNC_ID, FEDERATION_NET_ID, MUSIC_DHT_ID,
RENDEZVOUS_ID, TICKET_ID,
};
let manifest = CapabilityManifest::frid("furumi-desktop", env!("CARGO_PKG_VERSION"));
let protocol = |name: &str, id: &str| VersionEntrySnapshot {
name: name.into(),
version: manifest
.protocols
.get(id)
.map_or_else(|| "unknown".into(), u16::to_string),
};
BuildInfoSnapshot {
software: vec![
VersionEntrySnapshot {
name: "furumi-desktop".into(),
version: env!("CARGO_PKG_VERSION").into(),
},
VersionEntrySnapshot {
name: "furumi-library".into(),
version: env!("FURUMI_LIBRARY_VERSION").into(),
},
VersionEntrySnapshot {
name: "music-dht".into(),
version: env!("FURUMI_MUSIC_DHT_VERSION").into(),
},
VersionEntrySnapshot {
name: "federation-net".into(),
version: env!("FURUMI_FEDERATION_NET_VERSION").into(),
},
],
protocols: vec![
protocol("Federation transport", FEDERATION_NET_ID),
protocol("Peer ticket", TICKET_ID),
protocol("Rendezvous", RENDEZVOUS_ID),
protocol("Music DHT", MUSIC_DHT_ID),
protocol("Catalog", CATALOG_ID),
VersionEntrySnapshot {
name: "Audio transfer".into(),
version: federation::AUDIO_PROTOCOL_VERSION.to_string(),
},
VersionEntrySnapshot {
name: "Connected devices".into(),
version: format!(
"{} · accepts v1",
manifest
.protocols
.get(DEVICE_SYNC_ID)
.copied()
.unwrap_or_default()
),
},
],
}
}
pub(super) fn find_catalog_track<'a>(
library: &'a LibrarySnapshot,
search: &'a SearchResults,
key: &TrackKey,
) -> Option<&'a Track> {
library
.featured_releases
.iter()
.flat_map(|release| release.tracks.iter())
.chain(
library
.playlists
.iter()
.flat_map(|playlist| playlist.tracks.iter()),
)
.chain(
search
.releases
.iter()
.flat_map(|release| release.tracks.iter()),
)
.chain(search.tracks.iter())
.find(|track| track.key.matches(key))
}
pub(super) fn portable_playback_placeholder(
wire: &music_dht::device_sync::PlaybackTrack,
content_id: ContentId,
) -> Track {
let peer_id = "content".to_owned();
let refs = |names: &[String]| {
names
.iter()
.map(|name| ArtistRef {
key: ArtistKey::Federation {
peer_id: peer_id.clone(),
id: format!("name:{}", music_dht::normalize_name(name)),
},
name: name.clone(),
})
.collect::<Vec<_>>()
};
Track {
key: TrackKey::remote(content_id.clone()),
title: wire.title.clone(),
artist: playback_artist_line(&wire.artist_names, &wire.featured_artist_names),
artists: refs(&wire.artist_names),
featured_artists: refs(&wire.featured_artist_names),
release: wire.release_title.clone(),
release_id: ReleaseKey::Federation {
peer_id: peer_id.clone(),
id: format!("name:{}", music_dht::normalize_name(&wire.release_title)),
},
duration_seconds: wire.duration_seconds,
track_number: wire
.track_number
.and_then(|value| u32::try_from(value).ok()),
disc_number: wire.disc_number.and_then(|value| u32::try_from(value).ok()),
cover_uri: None,
audio_format: wire.audio_format.clone(),
audio_bitrate_kbps: wire
.audio_bitrate
.and_then(|value| u32::try_from(value).ok()),
audio_sample_rate_hz: wire
.audio_sample_rate
.and_then(|value| u32::try_from(value).ok()),
audio_bit_depth: wire
.audio_bit_depth
.and_then(|value| u32::try_from(value).ok()),
file_size_bytes: wire
.file_size_bytes
.and_then(|value| u64::try_from(value).ok()),
liked: false,
audio_source: AudioSource::Federation {
peer_id: String::new(),
content_id,
},
}
}
pub(super) fn playback_artist_line(main: &[String], featured: &[String]) -> String {
match (main.is_empty(), featured.is_empty()) {
(false, false) => format!("{} feat. {}", main.join(", "), featured.join(", ")),
(false, true) => main.join(", "),
(true, false) => format!("feat. {}", featured.join(", ")),
(true, true) => String::new(),
}
}
pub(super) fn extrapolated_control_position(
state: &music_dht::device_sync::PlaybackStateWire,
elapsed: Duration,
) -> f64 {
let elapsed = if state.playing && !state.paused {
elapsed.as_secs_f64()
} else {
0.0
};
(state.position_secs + elapsed).max(0.0)
}
pub(super) fn remote_snapshot_has_authority(
role: DevicePlaybackRole,
active_device_id: &str,
local_status: PlaybackStatus,
local_queue_empty: bool,
snapshot: &music_dht::device_sync::PlaybackSnapshot,
) -> bool {
if !snapshot.active {
return false;
}
match role {
DevicePlaybackRole::Control => snapshot.device_id == active_device_id,
DevicePlaybackRole::Active => local_status == PlaybackStatus::Stopped && local_queue_empty,
}
}
pub(super) fn playback_state_acknowledges_command(
expected: &music_dht::device_sync::PlaybackStateWire,
actual: &music_dht::device_sync::PlaybackStateWire,
seek: bool,
elapsed: Duration,
) -> bool {
let same_queue = expected.queue.len() == actual.queue.len()
&& expected
.queue
.iter()
.zip(&actual.queue)
.all(|(expected, actual)| playback_tracks_equivalent(expected, actual));
let same_transport = expected.queue_pos == actual.queue_pos
&& expected.playing == actual.playing
&& expected.paused == actual.paused
&& expected.volume == actual.volume
&& expected.shuffle == actual.shuffle
&& expected.repeat == actual.repeat;
if !same_queue || !same_transport {
return false;
}
if !seek {
return true;
}
let expected_position = extrapolated_control_position(expected, elapsed);
(expected_position - actual.position_secs).abs() <= CONTROL_POSITION_ACK_TOLERANCE_SECONDS
}
pub(super) fn playback_tracks_equivalent(
left: &music_dht::device_sync::PlaybackTrack,
right: &music_dht::device_sync::PlaybackTrack,
) -> bool {
let content_id = |track: &music_dht::device_sync::PlaybackTrack| {
track
.content_id
.as_deref()
.and_then(music_dht::normalize_content_id)
.or_else(|| {
track
.fed
.as_ref()
.and_then(|fed| music_dht::normalize_content_id(&fed.content_id))
})
};
match (content_id(left), content_id(right)) {
(Some(left), Some(right)) => left == right,
_ => {
music_dht::normalize_name(&left.title) == music_dht::normalize_name(&right.title)
&& music_dht::normalize_name(&left.release_title)
== music_dht::normalize_name(&right.release_title)
&& left.track_number == right.track_number
&& left.disc_number == right.disc_number
&& normalized_artist_names(&left.artist_names)
== normalized_artist_names(&right.artist_names)
}
}
}
pub(super) fn normalized_artist_names(names: &[String]) -> Vec<String> {
names
.iter()
.map(|name| music_dht::normalize_name(name))
.collect()
}
pub(super) fn unix_time_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |duration| {
i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)
})
}
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "the playback volume is clamped to the wire protocol's 0..=100 range"
)]
pub(super) fn volume_percent(volume: f32) -> u8 {
(volume.clamp(0.0, 1.0) * 100.0).round() as u8
}
pub(super) fn track_to_synced_fed(track: &Track) -> Option<music_dht::device_sync::SyncedFedTrack> {
let (owner, item_id) = track.key.federation_id()?;
let content_id = track.key.content_id()?.as_str().to_owned();
Some(music_dht::device_sync::SyncedFedTrack {
item_id: item_id.to_owned(),
owner: owner.to_owned(),
title: track.title.clone(),
artist_names: track
.artists
.iter()
.map(|artist| artist.name.clone())
.collect(),
featured_artist_names: track
.featured_artists
.iter()
.map(|artist| artist.name.clone())
.collect(),
year: None,
duration_seconds: (track.duration_seconds.is_finite() && track.duration_seconds > 0.0)
.then(|| {
i64::try_from(Duration::from_secs_f64(track.duration_seconds).as_secs())
.unwrap_or(i64::MAX)
}),
content_id,
release_title: (!track.release.is_empty()).then(|| track.release.clone()),
track_number: track
.track_number
.and_then(|value| i32::try_from(value).ok()),
disc_number: track
.disc_number
.and_then(|value| i32::try_from(value).ok()),
})
}
pub(super) fn track_to_library_fed(track: &Track) -> Option<furumi_library::FederatedTrack> {
let synced = track_to_synced_fed(track)?;
Some(furumi_library::FederatedTrack {
item_id: synced.item_id,
owner: synced.owner,
own: false,
title: synced.title,
artist_names: synced.artist_names,
featured_artist_names: synced.featured_artist_names,
year: synced.year,
duration_seconds: synced.duration_seconds,
content_id: Some(synced.content_id),
release_title: synced.release_title,
track_number: synced.track_number,
disc_number: synced.disc_number,
})
}
pub(super) fn track_to_playback_track(track: &Track) -> music_dht::device_sync::PlaybackTrack {
let id = track.key.local_id().map_or(-1, LocalTrackId::get);
let release_id = match track.release_id {
ReleaseKey::Local(id) => id.get(),
ReleaseKey::Federation { .. } => -1,
};
music_dht::device_sync::PlaybackTrack {
id,
title: track.title.clone(),
track_number: track
.track_number
.and_then(|value| i32::try_from(value).ok()),
disc_number: track
.disc_number
.and_then(|value| i32::try_from(value).ok()),
duration_seconds: track.duration_seconds,
artist_names: track
.artists
.iter()
.map(|artist| artist.name.clone())
.collect(),
featured_artist_names: track
.featured_artists
.iter()
.map(|artist| artist.name.clone())
.collect(),
release_id,
release_title: track.release.clone(),
release_year: None,
file_path: String::new(),
content_id: track.key.content_id().map(|id| id.as_str().to_owned()),
audio_format: track.audio_format.clone(),
audio_bitrate: track
.audio_bitrate_kbps
.and_then(|value| i32::try_from(value).ok()),
audio_sample_rate: track
.audio_sample_rate_hz
.and_then(|value| i32::try_from(value).ok()),
audio_bit_depth: track
.audio_bit_depth
.and_then(|value| i32::try_from(value).ok()),
file_size_bytes: track
.file_size_bytes
.and_then(|value| i64::try_from(value).ok()),
play_count: 0,
fed: track_to_synced_fed(track),
}
}
pub(super) fn sanitize_filename(value: &str) -> String {
let clean: String = value
.chars()
.map(|c| {
if c.is_alphanumeric() || matches!(c, '-' | '_') {
c
} else {
'_'
}
})
.collect();
if clean.is_empty() {
"track".into()
} else {
clean
}
}
pub(super) fn federation_specs(
library: &furumi_library::Library,
) -> anyhow::Result<Vec<music_dht::ItemSpec>> {
let export = library.federation_export()?;
let mut specs = Vec::new();
for (id, name) in export.artists {
specs.push(music_dht::ItemSpec {
local_key: format!("artist:{id}"),
kind: music_dht::ItemKind::Artist,
name,
artist_names: Vec::new(),
featured_artist_names: Vec::new(),
year: None,
release_type: None,
release_title: None,
track_number: None,
disc_number: None,
duration_seconds: None,
content_id: None,
});
}
for release in export.releases {
specs.push(music_dht::ItemSpec {
local_key: format!("release:{}", release.id),
kind: music_dht::ItemKind::Release,
name: release.title,
artist_names: release.artist_names,
featured_artist_names: Vec::new(),
year: release.year,
release_type: Some(release.release_type),
release_title: None,
track_number: None,
disc_number: None,
duration_seconds: None,
content_id: None,
});
}
for track in export.tracks {
specs.push(music_dht::ItemSpec {
local_key: format!("track:{}", track.id),
kind: music_dht::ItemKind::Track,
name: track.title,
artist_names: track.artist_names,
featured_artist_names: track.featured_artist_names,
year: track.year,
release_type: Some(track.release_type),
release_title: Some(track.release_title),
track_number: track.track_number,
disc_number: track.disc_number,
duration_seconds: (track.duration_seconds > 0.0).then_some(track.duration_seconds),
content_id: track.content_id,
});
}
Ok(specs)
}
pub(super) fn apply_federated_metadata(
import: &mut furumi_library::import::TrackImport,
metadata: Option<federation::TrackMetadata>,
) {
let Some(metadata) = metadata else {
return;
};
if !metadata.title.trim().is_empty() {
import.title = metadata.title;
}
if !metadata.artists.is_empty() {
import.artists = metadata.artists;
}
if !metadata.featured_artists.is_empty() {
import.featured_artists = metadata.featured_artists;
}
if !metadata.album_artists.is_empty() {
import.album_artists = metadata.album_artists;
}
if !metadata.release_title.trim().is_empty() {
import.release_title = metadata.release_title;
}
import.release_type = metadata.release_type.or(import.release_type.take());
import.year = metadata.year.or(import.year);
import.track_number = metadata.track_number.or(import.track_number);
import.disc_number = metadata.disc_number.or(import.disc_number);
import.duration_seconds = metadata.duration_seconds.unwrap_or(import.duration_seconds);
import.audio_format = metadata.audio_format.or(import.audio_format.take());
import.audio_bitrate = metadata.audio_bitrate.or(import.audio_bitrate);
import.audio_sample_rate = metadata.audio_sample_rate.or(import.audio_sample_rate);
import.audio_bit_depth = metadata.audio_bit_depth.or(import.audio_bit_depth);
}
pub(super) fn spawn_settings_worker(
store: SettingsStore,
receiver: std_mpsc::Receiver<SettingsSnapshot>,
events: mpsc::Sender<InternalEvent>,
) {
let report = events.clone();
if let Err(error) = thread::Builder::new()
.name("furumi-settings-storage".into())
.spawn(move || {
while let Ok(mut settings) = receiver.recv() {
for newer in receiver.try_iter() {
settings = newer;
}
let result = store.save(&settings).map_err(|error| error.to_string());
if events
.blocking_send(InternalEvent::SettingsPersisted(result))
.is_err()
{
break;
}
}
})
{
let _ = report.blocking_send(InternalEvent::SettingsPersisted(Err(format!(
"settings worker: {error}"
))));
}
}
pub(super) fn local_search_results(
catalog: &furumi_library::Library,
query: &str,
) -> anyhow::Result<SearchResults> {
let found = catalog.search(query, 50)?;
let artists = found
.artists
.into_iter()
.map(|artist| Artist {
key: ArtistKey::local(ArtistId::new(artist.id)),
source: CatalogSource::Local,
name: artist.name,
artwork: Artwork {
uri: artist.image_path,
},
release_count: usize::try_from(artist.release_count.max(0)).unwrap_or(usize::MAX),
track_count: usize::try_from(artist.track_count.max(0)).unwrap_or(usize::MAX),
})
.collect();
let mut releases = Vec::with_capacity(found.releases.len());
for card in found.releases {
let detail = catalog.release(card.id)?;
releases.push(library_release(detail));
}
let tracks = found
.tracks
.into_iter()
.map(|track| library_track(track, ""))
.collect();
Ok(SearchResults {
artists,
releases,
tracks,
})
}
pub(super) fn merge_search_results(target: &mut SearchResults, incoming: SearchResults) {
for artist in incoming.artists {
if let Some(existing) = target
.artists
.iter_mut()
.find(|item| item.key == artist.key)
{
existing.release_count = existing.release_count.max(artist.release_count);
existing.track_count = existing.track_count.max(artist.track_count);
if existing.artwork.uri.is_none() && artist.artwork.uri.is_some() {
existing.artwork = artist.artwork;
}
} else {
target.artists.push(artist);
}
}
for mut release in incoming.releases {
populate_release_contributors(&mut release);
sort_release_tracks(&mut release.tracks);
if let Some(existing) = target
.releases
.iter_mut()
.find(|item| item.key == release.key)
{
merge_release_preserving_local(existing, release);
} else {
target.releases.push(release);
}
}
for track in incoming.tracks {
if let Some(existing) = target
.tracks
.iter_mut()
.find(|item| item.same_catalog_track(&track))
{
if matches!(existing.audio_source, AudioSource::Federation { .. })
&& matches!(track.audio_source, AudioSource::LocalFile(_))
{
*existing = track;
}
} else {
target.tracks.push(track);
}
}
}
pub(super) fn merge_release_preserving_local(target: &mut Release, incoming: Release) {
if matches!(target.source, CatalogSource::Federation { .. })
&& matches!(incoming.source, CatalogSource::Local)
{
let previous = std::mem::replace(target, incoming);
merge_release_preserving_local(target, previous);
return;
}
if target.artwork.uri.is_none() && incoming.artwork.uri.is_some() {
target.artwork = incoming.artwork;
}
merge_artist_refs(&mut target.artists, incoming.artists);
merge_artist_refs(&mut target.featured_artists, incoming.featured_artists);
for track in incoming.tracks {
if let Some(current) = target
.tracks
.iter_mut()
.find(|item| tracks_match_within_release(item, &track))
{
if matches!(current.audio_source, AudioSource::Federation { .. })
&& matches!(track.audio_source, AudioSource::LocalFile(_))
{
*current = track;
}
} else {
target.tracks.push(track);
}
}
sort_release_tracks(&mut target.tracks);
populate_release_contributors(target);
}
fn sort_release_tracks(tracks: &mut [Track]) {
tracks.sort_by(|left, right| {
left.disc_number
.unwrap_or(1)
.cmp(&right.disc_number.unwrap_or(1))
.then_with(|| {
left.track_number
.unwrap_or(u32::MAX)
.cmp(&right.track_number.unwrap_or(u32::MAX))
})
.then_with(|| left.title.cmp(&right.title))
});
}
/// Match provider records within an already identified release. Track/disc
/// position is more reliable here than optional remote descriptive metadata.
pub(super) fn tracks_match_within_release(left: &Track, right: &Track) -> bool {
if left.key.matches(&right.key) {
return true;
}
if let (Some(left_number), Some(right_number)) = (left.track_number, right.track_number)
&& left_number == right_number
&& left.disc_number.unwrap_or(1) == right.disc_number.unwrap_or(1)
{
return true;
}
music_dht::normalize_name(&left.title) == music_dht::normalize_name(&right.title)
}
pub(super) fn merge_artist_refs(target: &mut Vec<ArtistRef>, incoming: Vec<ArtistRef>) {
let mut known = target
.iter()
.map(|artist| music_dht::normalize_name(&artist.name))
.collect::<HashSet<_>>();
target.extend(
incoming
.into_iter()
.filter(|artist| known.insert(music_dht::normalize_name(&artist.name))),
);
}
pub(super) fn populate_release_contributors(release: &mut Release) {
let mut known = release
.artists
.iter()
.chain(release.featured_artists.iter())
.map(|artist| music_dht::normalize_name(&artist.name))
.collect::<HashSet<_>>();
for artist in release
.tracks
.iter()
.flat_map(|track| track.artists.iter().chain(track.featured_artists.iter()))
{
if known.insert(music_dht::normalize_name(&artist.name)) {
release.featured_artists.push(artist.clone());
}
}
}
pub(super) fn library_release(detail: furumi_library::ReleaseDetail) -> Release {
let (artists, featured_artists) = release_artist_roles(&detail);
let fallback = artists
.iter()
.map(|artist| artist.name.as_str())
.collect::<Vec<_>>()
.join(", ");
Release {
key: ReleaseKey::local(ReleaseId::new(detail.id)),
source: CatalogSource::Local,
title: detail.title,
artists,
featured_artists,
release_type: detail.release_type,
year: detail.year,
artwork: Artwork {
uri: detail.cover_path,
},
tracks: detail
.tracks
.into_iter()
.map(|track| library_track(track, &fallback))
.collect(),
}
}
pub(super) fn library_snapshot(
catalog: &furumi_library::Library,
) -> anyhow::Result<LibrarySnapshot> {
let liked_ids = catalog
.liked_content_ids()?
.into_iter()
.chain(catalog.fed_like_ids()?)
.collect::<HashSet<_>>();
let artist_cards = catalog.artists(
1,
i64::MAX,
furumi_library::LibraryFilters {
source_mode: furumi_library::LibrarySourceMode::Local,
..furumi_library::LibraryFilters::default()
},
)?;
let artists = artist_cards
.items
.into_iter()
.map(|artist| Artist {
key: ArtistKey::local(ArtistId::new(artist.id)),
source: CatalogSource::Local,
name: artist.name,
artwork: Artwork {
uri: artist.image_path,
},
release_count: usize::try_from(artist.release_count.max(0)).unwrap_or(usize::MAX),
track_count: usize::try_from(artist.track_count.max(0)).unwrap_or(usize::MAX),
})
.collect();
let mut releases = Vec::new();
for card in catalog.releases()? {
let detail = catalog.release(card.id)?;
let (artist_refs, featured_artist_refs) = release_artist_roles(&detail);
let artist_line = artist_refs
.iter()
.map(|artist| artist.name.as_str())
.collect::<Vec<_>>()
.join(", ");
let mut tracks = detail
.tracks
.into_iter()
.map(|track| library_track(track, &artist_line))
.collect::<Vec<_>>();
for track in &mut tracks {
track.liked = track_is_liked(track, &liked_ids);
}
releases.push(Release {
key: ReleaseKey::local(ReleaseId::new(detail.id)),
source: CatalogSource::Local,
title: detail.title,
artists: artist_refs,
featured_artists: featured_artist_refs,
release_type: detail.release_type,
year: detail.year,
artwork: Artwork {
uri: detail.cover_path,
},
tracks,
});
}
let recently_played = releases
.iter()
.flat_map(|release| release.tracks.iter().cloned())
.take(12)
.collect();
let mut playlists = Vec::new();
for card in catalog.playlists()? {
let detail = catalog.playlist(card.id)?;
let mut tracks = detail
.tracks
.into_iter()
.map(|track| library_track(track, ""))
.collect::<Vec<_>>();
for track in &mut tracks {
track.liked = track_is_liked(track, &liked_ids);
}
playlists.push(PlaylistSnapshot {
id: card.id,
title: detail.title,
is_likes: card.kind == "likes",
tracks,
});
}
Ok(LibrarySnapshot {
artists,
featured_releases: releases,
recently_played,
playlists,
})
}
pub(super) fn track_is_liked(track: &Track, liked_ids: &HashSet<String>) -> bool {
track
.key
.content_id()
.is_some_and(|id| liked_ids.contains(id.as_str()))
|| track
.key
.federation_id()
.is_some_and(|(_, item)| liked_ids.contains(item))
}
pub(super) fn release_artist_roles(
detail: &furumi_library::ReleaseDetail,
) -> (Vec<ArtistRef>, Vec<ArtistRef>) {
let mut main_counts = HashMap::<i64, usize>::new();
let mut featured = HashMap::<i64, String>::new();
for track in &detail.tracks {
for artist in &track.artists {
*main_counts.entry(artist.id).or_default() += 1;
}
for artist in &track.featured_artists {
featured
.entry(artist.id)
.or_insert_with(|| artist.name.clone());
}
}
let mut main_artists = Vec::new();
let mut featured_artists = Vec::new();
let mut known = HashSet::new();
for artist in &detail.artists {
known.insert(artist.id);
let reference = ArtistRef {
key: ArtistKey::local(ArtistId::new(artist.id)),
name: artist.name.clone(),
};
if main_counts.contains_key(&artist.id) || !featured.contains_key(&artist.id) {
main_artists.push(reference);
} else {
featured_artists.push(reference);
}
}
for track in &detail.tracks {
for artist in &track.artists {
if known.insert(artist.id) {
main_artists.push(ArtistRef {
key: ArtistKey::local(ArtistId::new(artist.id)),
name: artist.name.clone(),
});
}
}
}
for (id, name) in featured {
if known.insert(id) {
featured_artists.push(ArtistRef {
key: ArtistKey::local(ArtistId::new(id)),
name,
});
}
}
main_artists.sort_by(|left, right| {
let count = |artist: &ArtistRef| match artist.key {
ArtistKey::Local(id) => main_counts.get(&id.get()).copied().unwrap_or_default(),
ArtistKey::Federation { .. } => 0,
};
count(right).cmp(&count(left))
});
(main_artists, featured_artists)
}
pub(super) fn library_track(track: furumi_library::TrackItem, fallback_artist: &str) -> Track {
let content_id = track
.content_id
.as_deref()
.and_then(|content_id| ContentId::parse(content_id).ok());
let local_id = LocalTrackId::new(track.id);
let key = content_id.map_or_else(
|| TrackKey::local(local_id),
|content_id| TrackKey::new(local_id, content_id),
);
let artists = track
.artists
.iter()
.map(|artist| ArtistRef {
key: ArtistKey::local(ArtistId::new(artist.id)),
name: artist.name.clone(),
})
.collect::<Vec<_>>();
let featured_artists = track
.featured_artists
.iter()
.map(|artist| ArtistRef {
key: ArtistKey::local(ArtistId::new(artist.id)),
name: artist.name.clone(),
})
.collect::<Vec<_>>();
let artist = {
let value = track.artist_line();
if value.is_empty() {
fallback_artist.to_string()
} else {
value
}
};
Track {
key,
title: track.title,
artist,
artists,
featured_artists,
release: track.release_title,
release_id: ReleaseKey::local(ReleaseId::new(track.release_id)),
duration_seconds: track.duration_seconds,
track_number: track
.track_number
.and_then(|value| u32::try_from(value).ok()),
disc_number: track
.disc_number
.and_then(|value| u32::try_from(value).ok()),
cover_uri: track.cover_path,
audio_format: track.audio_format,
audio_bitrate_kbps: track
.audio_bitrate
.and_then(|value| u32::try_from(value).ok()),
audio_sample_rate_hz: track
.audio_sample_rate
.and_then(|value| u32::try_from(value).ok()),
audio_bit_depth: track
.audio_bit_depth
.and_then(|value| u32::try_from(value).ok()),
file_size_bytes: track
.file_size_bytes
.and_then(|value| u64::try_from(value).ok()),
liked: false,
audio_source: AudioSource::LocalFile(track.file_path.into()),
}
}
+462
View File
@@ -0,0 +1,462 @@
use super::*;
fn merge_test_track(key: TrackKey, audio_source: AudioSource) -> Track {
Track {
key,
title: "Track".into(),
artist: "Artist".into(),
artists: vec![ArtistRef {
key: ArtistKey::local(ArtistId::new(1)),
name: "Artist".into(),
}],
featured_artists: Vec::new(),
release: "Album".into(),
release_id: ReleaseKey::local(ReleaseId::new(1)),
duration_seconds: 180.0,
track_number: Some(1),
disc_number: Some(1),
cover_uri: None,
audio_format: Some("flac".into()),
audio_bitrate_kbps: None,
audio_sample_rate_hz: None,
audio_bit_depth: None,
file_size_bytes: None,
liked: false,
audio_source,
}
}
#[test]
fn merging_search_results_deduplicates_source_keys() {
let artist = Artist {
key: ArtistKey::Federation {
peer_id: "peer".into(),
id: "artist".into(),
},
source: CatalogSource::Federation {
peer_id: "peer".into(),
},
name: "Artist".into(),
artwork: Artwork::default(),
release_count: 0,
track_count: 0,
};
let mut target = SearchResults {
artists: vec![artist.clone()],
..SearchResults::default()
};
merge_search_results(
&mut target,
SearchResults {
artists: vec![artist],
..SearchResults::default()
},
);
assert_eq!(target.artists.len(), 1);
}
#[test]
fn newly_received_release_tracks_are_sorted_by_disc_and_track_number() {
let mut fifth = merge_test_track(
TrackKey::federation("peer".into(), "five".into(), None),
AudioSource::Federation {
peer_id: "peer".into(),
content_id: ContentId::parse(format!("b3:{}", "5".repeat(64))).unwrap(),
},
);
fifth.title = "Los".into();
fifth.track_number = Some(5);
let mut first = merge_test_track(
TrackKey::federation("peer".into(), "one".into(), None),
AudioSource::Federation {
peer_id: "peer".into(),
content_id: ContentId::parse(format!("b3:{}", "1".repeat(64))).unwrap(),
},
);
first.title = "Reise, Reise".into();
let mut target = SearchResults::default();
merge_search_results(
&mut target,
SearchResults {
releases: vec![Release {
key: ReleaseKey::Federation {
peer_id: "peer".into(),
id: "reise-reise".into(),
},
source: CatalogSource::Federation {
peer_id: "peer".into(),
},
title: "Reise, Reise".into(),
artists: first.artists.clone(),
featured_artists: Vec::new(),
release_type: "album".into(),
year: Some(2004),
artwork: Artwork::default(),
tracks: vec![fifth, first],
}],
..SearchResults::default()
},
);
assert_eq!(
target.releases[0]
.tracks
.iter()
.map(|track| track.track_number)
.collect::<Vec<_>>(),
vec![Some(1), Some(5)]
);
}
#[test]
fn selected_track_key_survives_context_filtering_and_reordering() {
let selected = TrackKey::local(LocalTrackId::new(2));
let resolved = vec![
merge_test_track(
TrackKey::local(LocalTrackId::new(3)),
AudioSource::LocalFile("three.flac".into()),
),
merge_test_track(selected.clone(), AudioSource::LocalFile("two.flac".into())),
];
assert_eq!(selected_track_position(&resolved, &selected), 1);
}
#[test]
fn queue_artwork_requests_are_grouped_per_peer_and_release() {
let first_content = ContentId::parse(format!("b3:{}", "1".repeat(64))).unwrap();
let second_content = ContentId::parse(format!("b3:{}", "2".repeat(64))).unwrap();
let mut first = merge_test_track(
TrackKey::federation("peer".into(), "one".into(), Some(first_content.clone())),
AudioSource::Federation {
peer_id: "peer".into(),
content_id: first_content,
},
);
let mut second = merge_test_track(
TrackKey::federation("peer".into(), "two".into(), Some(second_content.clone())),
AudioSource::Federation {
peer_id: "peer".into(),
content_id: second_content,
},
);
first.title = "One".into();
second.title = "Two".into();
assert_eq!(
Actor::queue_artwork_request_id(&first),
Actor::queue_artwork_request_id(&second)
);
second.release = "Another album".into();
assert_ne!(
Actor::queue_artwork_request_id(&first),
Actor::queue_artwork_request_id(&second)
);
}
#[test]
fn federated_release_enrichment_never_replaces_a_local_track() {
let release_key = ReleaseKey::local(ReleaseId::new(1));
let artist = ArtistRef {
key: ArtistKey::local(ArtistId::new(1)),
name: "Artist".into(),
};
let local_track = merge_test_track(
TrackKey::local(LocalTrackId::new(1)),
AudioSource::LocalFile("track.flac".into()),
);
let mut target = SearchResults {
releases: vec![Release {
key: release_key.clone(),
source: CatalogSource::Local,
title: "Album".into(),
artists: vec![artist.clone()],
featured_artists: Vec::new(),
release_type: "album".into(),
year: Some(2026),
artwork: Artwork::default(),
tracks: vec![local_track],
}],
..SearchResults::default()
};
let content_id = ContentId::parse(format!("b3:{}", "a".repeat(64))).unwrap();
let remote_track = merge_test_track(
TrackKey::federation(
"peer".into(),
"remote-item".into(),
Some(content_id.clone()),
),
AudioSource::Federation {
peer_id: "peer".into(),
content_id,
},
);
merge_search_results(
&mut target,
SearchResults {
releases: vec![Release {
key: release_key,
source: CatalogSource::Federation {
peer_id: "peer".into(),
},
title: "Album".into(),
artists: vec![artist],
featured_artists: Vec::new(),
release_type: "album".into(),
year: Some(2026),
artwork: Artwork::default(),
tracks: vec![remote_track],
}],
..SearchResults::default()
},
);
let tracks = &target.releases[0].tracks;
assert_eq!(tracks.len(), 1);
assert!(matches!(tracks[0].audio_source, AudioSource::LocalFile(_)));
}
#[test]
fn incomplete_federated_metadata_cannot_shadow_local_release_metadata() {
let release_key = ReleaseKey::local(ReleaseId::new(1));
let artist = ArtistRef {
key: ArtistKey::local(ArtistId::new(1)),
name: "Artist".into(),
};
let mut local_track = merge_test_track(
TrackKey::local(LocalTrackId::new(1)),
AudioSource::LocalFile("track.flac".into()),
);
local_track.cover_uri = Some("local-cover.png".into());
local_track.audio_bitrate_kbps = Some(1_411);
let mut target = Release {
key: release_key.clone(),
source: CatalogSource::Local,
title: "Complete local album title".into(),
artists: vec![artist.clone()],
featured_artists: Vec::new(),
release_type: "album".into(),
year: Some(2026),
artwork: Artwork {
uri: Some("local-cover.png".into()),
},
tracks: vec![local_track],
};
let content_id = ContentId::parse(format!("b3:{}", "b".repeat(64))).unwrap();
let mut incomplete_remote = merge_test_track(
TrackKey::federation("peer".into(), "item".into(), Some(content_id.clone())),
AudioSource::Federation {
peer_id: "peer".into(),
content_id,
},
);
incomplete_remote.title = String::new();
incomplete_remote.artist = String::new();
incomplete_remote.artists.clear();
incomplete_remote.audio_format = None;
merge_release_preserving_local(
&mut target,
Release {
key: release_key,
source: CatalogSource::Federation {
peer_id: "peer".into(),
},
title: String::new(),
artists: vec![artist],
featured_artists: Vec::new(),
release_type: String::new(),
year: None,
artwork: Artwork::default(),
tracks: vec![incomplete_remote],
},
);
assert!(matches!(target.source, CatalogSource::Local));
assert_eq!(target.title, "Complete local album title");
assert_eq!(target.tracks.len(), 1);
assert_eq!(target.tracks[0].title, "Track");
assert_eq!(target.tracks[0].audio_format.as_deref(), Some("flac"));
assert_eq!(target.tracks[0].audio_bitrate_kbps, Some(1_411));
assert!(matches!(
target.tracks[0].audio_source,
AudioSource::LocalFile(_)
));
}
#[test]
fn queue_resolver_keeps_tracks_nested_in_a_federated_release() {
let content_id = ContentId::parse(format!("b3:{}", "c".repeat(64))).unwrap();
let key = TrackKey::federation(
"peer".into(),
"remote-track".into(),
Some(content_id.clone()),
);
let track = merge_test_track(
key.clone(),
AudioSource::Federation {
peer_id: "peer".into(),
content_id,
},
);
let search = SearchResults {
releases: vec![Release {
key: ReleaseKey::Federation {
peer_id: "peer".into(),
id: "album".into(),
},
source: CatalogSource::Federation {
peer_id: "peer".into(),
},
title: "Album".into(),
artists: track.artists.clone(),
featured_artists: Vec::new(),
release_type: "album".into(),
year: Some(2026),
artwork: Artwork::default(),
tracks: vec![track],
}],
..SearchResults::default()
};
let library = LibrarySnapshot::default();
let resolved = find_catalog_track(&library, &search, &key);
assert!(resolved.is_some());
assert!(matches!(
resolved.unwrap().audio_source,
AudioSource::Federation { .. }
));
}
#[test]
fn portable_device_queue_keeps_unresolved_content_as_a_federated_placeholder() {
let content_id = ContentId::parse(format!("b3:{}", "d".repeat(64))).unwrap();
let wire = music_dht::device_sync::PlaybackTrack {
id: 12,
title: "Portable track".into(),
track_number: Some(2),
disc_number: Some(1),
duration_seconds: 123.0,
artist_names: vec!["Artist".into()],
featured_artist_names: vec!["Guest".into()],
release_id: 4,
release_title: "Release".into(),
release_year: Some(2026),
file_path: String::new(),
content_id: Some(content_id.as_str().into()),
audio_format: Some("flac".into()),
audio_bitrate: Some(1_411),
audio_sample_rate: Some(44_100),
audio_bit_depth: Some(16),
file_size_bytes: Some(42),
play_count: 0,
fed: None,
};
let placeholder = portable_playback_placeholder(&wire, content_id.clone());
assert_eq!(placeholder.key.content_id(), Some(&content_id));
assert_eq!(placeholder.artist, "Artist feat. Guest");
assert!(matches!(
placeholder.audio_source,
AudioSource::Federation { ref peer_id, .. } if peer_id.is_empty()
));
}
fn playback_test_state(content_byte: char) -> music_dht::device_sync::PlaybackStateWire {
music_dht::device_sync::PlaybackStateWire {
queue: vec![music_dht::device_sync::PlaybackTrack {
id: 1,
title: format!("Track {content_byte}"),
track_number: Some(1),
disc_number: Some(1),
duration_seconds: 180.0,
artist_names: vec!["Artist".into()],
featured_artist_names: Vec::new(),
release_id: 1,
release_title: "Album".into(),
release_year: Some(2026),
file_path: String::new(),
content_id: Some(format!("b3:{}", content_byte.to_string().repeat(64))),
audio_format: Some("flac".into()),
audio_bitrate: None,
audio_sample_rate: None,
audio_bit_depth: None,
file_size_bytes: None,
play_count: 0,
fed: None,
}],
queue_pos: 0,
playing: true,
paused: false,
idle_since_ms: None,
position_secs: 40.0,
volume: 72,
shuffle: false,
repeat: music_dht::device_sync::PlaybackRepeat::Off,
}
}
#[test]
fn control_device_ignores_snapshots_from_the_previous_active_device() {
let snapshot = music_dht::device_sync::PlaybackSnapshot {
device_id: "old-device".into(),
device_name: "Old".into(),
active: true,
updated_at_ms: 1,
state: playback_test_state('a'),
};
assert!(!remote_snapshot_has_authority(
DevicePlaybackRole::Control,
"current-device",
PlaybackStatus::Playing,
false,
&snapshot,
));
assert!(remote_snapshot_has_authority(
DevicePlaybackRole::Control,
"old-device",
PlaybackStatus::Playing,
false,
&snapshot,
));
}
#[test]
fn stale_remote_state_cannot_ack_a_new_control_command() {
let expected = playback_test_state('a');
let stale_track = playback_test_state('b');
assert!(!playback_state_acknowledges_command(
&expected,
&stale_track,
true,
Duration::from_secs(2),
));
let mut acknowledged = expected.clone();
acknowledged.position_secs = 42.0;
assert!(playback_state_acknowledges_command(
&expected,
&acknowledged,
true,
Duration::from_secs(2),
));
acknowledged.position_secs = 5.0;
assert!(!playback_state_acknowledges_command(
&expected,
&acknowledged,
true,
Duration::from_secs(2),
));
assert!(playback_state_acknowledges_command(
&expected,
&acknowledged,
false,
Duration::from_secs(2),
));
}
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "furumi-domain"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lints]
workspace = true
+555
View File
@@ -0,0 +1,555 @@
//! Pure Furumi domain types and rules.
use std::fmt;
use std::path::PathBuf;
macro_rules! local_id {
($name:ident) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct $name(i64);
impl $name {
#[must_use]
pub const fn new(value: i64) -> Self {
Self(value)
}
#[must_use]
pub const fn get(self) -> i64 {
self.0
}
}
};
}
local_id!(ArtistId);
local_id!(ReleaseId);
local_id!(LocalTrackId);
/// Origin of catalog metadata. UI and application code render the same
/// entities regardless of which provider supplied them.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum CatalogSource {
Local,
Federation { peer_id: String },
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ArtistKey {
Local(ArtistId),
Federation { peer_id: String, id: String },
}
impl ArtistKey {
#[must_use]
pub const fn local(id: ArtistId) -> Self {
Self::Local(id)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ReleaseKey {
Local(ReleaseId),
Federation { peer_id: String, id: String },
}
impl ReleaseKey {
#[must_use]
pub const fn local(id: ReleaseId) -> Self {
Self::Local(id)
}
}
/// Artwork resolved by a catalog provider. Federation can initially publish
/// `None`, fetch the image into its cache, and then replace the snapshot with
/// the same entity and a local URI.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Artwork {
pub uri: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArtistRef {
pub key: ArtistKey,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Artist {
pub key: ArtistKey,
pub source: CatalogSource,
pub name: String,
pub artwork: Artwork,
pub release_count: usize,
pub track_count: usize,
}
/// Stable Frid/Furumi audio identity (`b3:<64 lowercase hex>`).
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ContentId(String);
impl ContentId {
/// Parses and normalizes an audio content identifier.
///
/// # Errors
///
/// Returns [`InvalidContentId`] unless the value is `b3:` followed by 64
/// hexadecimal characters.
pub fn parse(value: impl Into<String>) -> Result<Self, InvalidContentId> {
let value = value.into().to_ascii_lowercase();
let hash = value.strip_prefix("b3:").ok_or(InvalidContentId)?;
if hash.len() != 64 || !hash.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(InvalidContentId);
}
Ok(Self(value))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InvalidContentId;
impl fmt::Display for InvalidContentId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("content ID must be b3:<64 hex characters>")
}
}
impl std::error::Error for InvalidContentId {}
/// A track can be known by its local database ID, stable content ID, or both.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct TrackKey {
local_id: Option<LocalTrackId>,
content_id: Option<ContentId>,
federation_identity: Option<(String, String)>,
}
impl TrackKey {
#[must_use]
pub const fn local(id: LocalTrackId) -> Self {
Self {
local_id: Some(id),
content_id: None,
federation_identity: None,
}
}
#[must_use]
pub const fn new(local_id: LocalTrackId, content_id: ContentId) -> Self {
Self {
local_id: Some(local_id),
content_id: Some(content_id),
federation_identity: None,
}
}
#[must_use]
pub const fn remote(content_id: ContentId) -> Self {
Self {
local_id: None,
content_id: Some(content_id),
federation_identity: None,
}
}
#[must_use]
pub fn federation(peer_id: String, item_id: String, content_id: Option<ContentId>) -> Self {
Self {
local_id: None,
content_id,
federation_identity: Some((peer_id, item_id)),
}
}
#[must_use]
pub const fn local_id(&self) -> Option<LocalTrackId> {
self.local_id
}
#[must_use]
pub const fn content_id(&self) -> Option<&ContentId> {
self.content_id.as_ref()
}
#[must_use]
pub fn federation_id(&self) -> Option<(&str, &str)> {
self.federation_identity
.as_ref()
.map(|(peer, item)| (peer.as_str(), item.as_str()))
}
#[must_use]
pub fn matches(&self, other: &Self) -> bool {
self.content_id
.as_ref()
.zip(other.content_id.as_ref())
.is_some_and(|(left, right)| left == right)
|| self
.local_id
.zip(other.local_id)
.is_some_and(|(left, right)| left == right)
|| self
.federation_identity
.as_ref()
.zip(other.federation_identity.as_ref())
.is_some_and(|(left, right)| left == right)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Track {
pub key: TrackKey,
pub title: String,
pub artist: String,
pub artists: Vec<ArtistRef>,
pub featured_artists: Vec<ArtistRef>,
pub release: String,
pub release_id: ReleaseKey,
pub duration_seconds: f64,
pub track_number: Option<u32>,
pub disc_number: Option<u32>,
pub cover_uri: Option<String>,
pub audio_format: Option<String>,
pub audio_bitrate_kbps: Option<u32>,
pub audio_sample_rate_hz: Option<u32>,
pub audio_bit_depth: Option<u32>,
pub file_size_bytes: Option<u64>,
/// Whether this content is present in the listener's shared likes.
pub liked: bool,
pub audio_source: AudioSource,
}
impl Track {
/// Returns whether two provider records describe the same logical track.
/// Stable content identity wins, with album metadata as a fallback for
/// catalogs that did not publish a content ID.
#[must_use]
pub fn same_catalog_track(&self, other: &Self) -> bool {
if self.key.matches(&other.key) {
return true;
}
if normalized_catalog_text(&self.title) != normalized_catalog_text(&other.title) {
return false;
}
let left_release = normalized_catalog_text(&self.release);
let right_release = normalized_catalog_text(&other.release);
if !left_release.is_empty() && !right_release.is_empty() && left_release != right_release {
return false;
}
if let (Some(left), Some(right)) = (self.track_number, other.track_number) {
return left == right
&& self.disc_number.unwrap_or(1) == other.disc_number.unwrap_or(1);
}
self.duration_seconds > 0.0
&& other.duration_seconds > 0.0
&& (self.duration_seconds - other.duration_seconds).abs() < 2.0
}
}
fn normalized_catalog_text(value: &str) -> String {
value.trim().to_lowercase()
}
/// Playback location resolved by a catalog provider. Local files go directly
/// to the audio engine; federated sources are streamed or materialized first
/// and then handed to the same engine.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AudioSource {
LocalFile(PathBuf),
Federation {
peer_id: String,
content_id: ContentId,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct Release {
pub key: ReleaseKey,
pub source: CatalogSource,
pub title: String,
pub artists: Vec<ArtistRef>,
pub featured_artists: Vec<ArtistRef>,
pub release_type: String,
pub year: Option<i32>,
pub artwork: Artwork,
pub tracks: Vec<Track>,
}
impl Release {
#[must_use]
pub fn artist_line(&self) -> String {
self.artists
.iter()
.map(|artist| artist.name.as_str())
.collect::<Vec<_>>()
.join(", ")
}
#[must_use]
pub fn is_album(&self) -> bool {
self.release_type.eq_ignore_ascii_case("album")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct QueueItemId(u64);
impl QueueItemId {
#[must_use]
pub const fn new(value: u64) -> Self {
Self(value)
}
#[must_use]
pub const fn get(self) -> u64 {
self.0
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct QueueItem {
pub id: QueueItemId,
pub track: Track,
}
/// Logical queue shared with the other Furumi players.
///
/// `play_next_end` is the exclusive end of the stable FIFO block created by
/// successive "play next" commands.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Queue {
items: Vec<QueueItem>,
current: Option<usize>,
play_next_end: Option<usize>,
next_item_id: u64,
}
impl Queue {
#[must_use]
pub fn items(&self) -> &[QueueItem] {
&self.items
}
#[must_use]
pub const fn current_index(&self) -> Option<usize> {
self.current
}
#[must_use]
pub fn current(&self) -> Option<&QueueItem> {
self.current.and_then(|index| self.items.get(index))
}
/// Selects a concrete queue occurrence without rebuilding the queue.
pub fn select_item(&mut self, id: QueueItemId) -> Option<&QueueItem> {
self.current = self.items.iter().position(|item| item.id == id);
self.play_next_end = None;
self.current()
}
pub fn replace_context(&mut self, tracks: Vec<Track>, start: usize) {
let mut items = Vec::with_capacity(tracks.len());
for track in tracks {
items.push(self.new_item(track));
}
self.items = items;
self.current = (!self.items.is_empty()).then(|| start.min(self.items.len() - 1));
self.play_next_end = None;
}
pub fn add_to_end(&mut self, tracks: impl IntoIterator<Item = Track>) {
let items: Vec<_> = tracks
.into_iter()
.map(|track| self.new_item(track))
.collect();
self.items.extend(items);
}
pub fn add_next(&mut self, tracks: impl IntoIterator<Item = Track>) {
let items: Vec<_> = tracks
.into_iter()
.map(|track| self.new_item(track))
.collect();
if items.is_empty() {
return;
}
let insertion = self.current.map_or(0, |current| {
self.play_next_end
.filter(|end| *end > current && *end <= self.items.len())
.unwrap_or((current + 1).min(self.items.len()))
});
let count = items.len();
self.items.splice(insertion..insertion, items);
if self.current.is_some_and(|current| insertion <= current) {
self.current = self.current.map(|current| current + count);
}
self.play_next_end = Some(insertion + count);
}
pub fn replace_matching_track(&mut self, replacement: &Track) {
for item in &mut self.items {
if item.track.key.matches(&replacement.key) {
item.track = replacement.clone();
}
}
}
pub fn replace_track(&mut self, key: &TrackKey, replacement: &Track) {
for item in &mut self.items {
if item.track.key.matches(key) {
item.track = replacement.clone();
}
}
}
pub fn advance(&mut self) -> Option<&QueueItem> {
let next = match self.current {
None if !self.items.is_empty() => 0,
Some(current) if current + 1 < self.items.len() => current + 1,
_ => return None,
};
self.current = Some(next);
self.normalize_play_next_block();
self.current()
}
pub fn previous(&mut self) -> Option<&QueueItem> {
let previous = self.current?.saturating_sub(1);
self.current = Some(previous);
self.normalize_play_next_block();
self.current()
}
fn new_item(&mut self, track: Track) -> QueueItem {
self.next_item_id = self.next_item_id.saturating_add(1);
QueueItem {
id: QueueItemId::new(self.next_item_id),
track,
}
}
fn normalize_play_next_block(&mut self) {
if self.play_next_end.is_some_and(|end| {
self.current.is_none_or(|current| end <= current) || end > self.items.len()
}) {
self.play_next_end = None;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn track(id: i64) -> Track {
Track {
key: TrackKey::local(LocalTrackId::new(id)),
title: id.to_string(),
artist: "artist".into(),
artists: vec![ArtistRef {
key: ArtistKey::local(ArtistId::new(1)),
name: "artist".into(),
}],
featured_artists: Vec::new(),
release: "release".into(),
release_id: ReleaseKey::local(ReleaseId::new(1)),
duration_seconds: 180.0,
track_number: Some(u32::try_from(id).unwrap()),
disc_number: Some(1),
cover_uri: None,
audio_format: Some("FLAC".into()),
audio_bitrate_kbps: Some(900),
audio_sample_rate_hz: Some(44_100),
audio_bit_depth: Some(16),
file_size_bytes: None,
liked: false,
audio_source: AudioSource::LocalFile(PathBuf::from(format!("track-{id}.flac"))),
}
}
#[test]
fn sequential_play_next_additions_form_a_fifo_block() {
let mut queue = Queue::default();
queue.replace_context((10..=13).map(track).collect(), 0);
queue.add_next([track(1)]);
queue.add_next([track(2)]);
queue.add_next([track(3)]);
let ids: Vec<_> = queue
.items()
.iter()
.map(|item| item.track.key.local_id().unwrap().get())
.collect();
assert_eq!(ids, vec![10, 1, 2, 3, 11, 12, 13]);
}
#[test]
fn replacing_context_keeps_order_and_starts_at_selected_track() {
let mut queue = Queue::default();
queue.replace_context((10..=13).map(track).collect(), 2);
let ids: Vec<_> = queue
.items()
.iter()
.map(|item| item.track.key.local_id().unwrap().get())
.collect();
assert_eq!(ids, vec![10, 11, 12, 13]);
assert_eq!(queue.current_index(), Some(2));
assert_eq!(
queue.current().unwrap().track.key.local_id().unwrap().get(),
12
);
}
#[test]
fn catalog_track_matching_falls_back_to_album_position() {
let local = track(1);
let content_id = ContentId::parse(format!("b3:{}", "a".repeat(64))).unwrap();
let mut federated = local.clone();
federated.key = TrackKey::federation(
"peer".into(),
"remote-item".into(),
Some(content_id.clone()),
);
federated.audio_source = AudioSource::Federation {
peer_id: "peer".into(),
content_id,
};
assert!(local.same_catalog_track(&federated));
federated.track_number = Some(2);
assert!(!local.same_catalog_track(&federated));
}
#[test]
fn content_id_matches_furumi_wire_format() {
let id = ContentId::parse(format!("B3:{}", "A".repeat(64))).unwrap();
assert_eq!(id.as_str(), format!("b3:{}", "a".repeat(64)));
assert!(ContentId::parse("track-1").is_err());
}
#[test]
fn catalog_keys_keep_local_and_federated_entities_distinct() {
let local = ReleaseKey::local(ReleaseId::new(7));
let remote = ReleaseKey::Federation {
peer_id: "peer-a".into(),
id: "7".into(),
};
assert_ne!(local, remote);
let content = ContentId::parse(format!("b3:{}", "a".repeat(64))).unwrap();
let first = TrackKey::federation("peer-a".into(), "item-1".into(), Some(content.clone()));
let same_audio_elsewhere =
TrackKey::federation("peer-b".into(), "item-9".into(), Some(content));
let other = TrackKey::federation("peer-a".into(), "item-2".into(), None);
assert!(first.matches(&same_audio_elsewhere));
assert!(!first.matches(&other));
}
}
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "furumi-platform-desktop"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
rfd.workspace = true
souvlaki.workspace = true
[target.'cfg(target_os="macos")'.dependencies]
core-foundation.workspace = true
[target.'cfg(windows)'.dependencies]
windows-sys.workspace = true
[lints.rust]
unsafe_code = "allow"
[lints.clippy]
all = "warn"
pedantic = "warn"
+18
View File
@@ -0,0 +1,18 @@
//! Narrow adapters for operating-system desktop services.
use std::path::{Path, PathBuf};
mod media;
pub use media::{MediaCommand, MediaSession};
/// Opens the platform-native directory picker.
///
/// The dialog is blocking and must be invoked away from the Slint event loop.
#[must_use]
pub fn choose_library_directory(initial: Option<&Path>) -> Option<PathBuf> {
let mut dialog = rfd::FileDialog::new().set_title("Choose Furumi library folder");
if let Some(path) = initial {
dialog = dialog.set_directory(path);
}
dialog.pick_folder()
}
+185
View File
@@ -0,0 +1,185 @@
//! Cross-platform OS media session backed by souvlaki.
use std::time::{Duration, Instant};
use souvlaki::{
MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, MediaPosition, PlatformConfig,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MediaCommand {
Toggle,
Play,
Pause,
Next,
Previous,
Stop,
}
pub struct MediaSession {
controls: MediaControls,
metadata: Option<(String, String, String, u64)>,
playback: Option<(bool, bool)>,
last_position_update: Option<Instant>,
}
impl MediaSession {
pub fn new(on_command: impl Fn(MediaCommand) + Send + 'static) -> Option<Self> {
let dbus_name = platform_dbus_name();
let mut controls = MediaControls::new(PlatformConfig {
display_name: "Furumi Desktop",
dbus_name: &dbus_name,
hwnd: platform_window(),
})
.ok()?;
controls
.attach(move |event| {
let command = match event {
MediaControlEvent::Toggle => MediaCommand::Toggle,
MediaControlEvent::Play => MediaCommand::Play,
MediaControlEvent::Pause => MediaCommand::Pause,
MediaControlEvent::Next => MediaCommand::Next,
MediaControlEvent::Previous => MediaCommand::Previous,
MediaControlEvent::Stop => MediaCommand::Stop,
_ => return,
};
on_command(command);
})
.ok()?;
Some(Self {
controls,
metadata: None,
playback: None,
last_position_update: None,
})
}
pub fn update_metadata(
&mut self,
title: &str,
artist: &str,
album: &str,
duration_seconds: f64,
) {
let duration_ms = duration_millis(duration_seconds);
let next = (
title.to_owned(),
artist.to_owned(),
album.to_owned(),
duration_ms,
);
if self.metadata.as_ref() == Some(&next) {
return;
}
let _ = self.controls.set_metadata(MediaMetadata {
title: Some(title),
artist: Some(artist),
album: Some(album),
duration: (duration_ms > 0).then(|| Duration::from_millis(duration_ms)),
cover_url: None,
});
self.metadata = Some(next);
}
pub fn update_playback(&mut self, playing: bool, paused: bool, position_seconds: f64) {
let next = (playing, paused);
let state_changed = self.playback != Some(next);
if !state_changed
&& self
.last_position_update
.is_some_and(|last| last.elapsed() < Duration::from_secs(2))
{
return;
}
let progress = Some(MediaPosition(Duration::from_millis(duration_millis(
position_seconds,
))));
let state = if !playing {
MediaPlayback::Stopped
} else if paused {
MediaPlayback::Paused { progress }
} else {
MediaPlayback::Playing { progress }
};
let _ = self.controls.set_playback(state);
self.playback = Some(next);
self.last_position_update = Some(Instant::now());
}
}
impl Drop for MediaSession {
fn drop(&mut self) {
let _ = self.controls.detach();
}
}
fn duration_millis(seconds: f64) -> u64 {
if seconds.is_finite() && seconds > 0.0 {
u64::try_from(Duration::from_secs_f64(seconds).as_millis()).unwrap_or(u64::MAX)
} else {
0
}
}
fn platform_dbus_name() -> String {
#[cfg(all(
unix,
not(any(target_os = "macos", target_os = "ios", target_os = "android"))
))]
{
return format!("cy.hexor.furumi.desktop.instance{}", std::process::id());
}
#[allow(unreachable_code)]
"cy.hexor.furumi.desktop".into()
}
#[cfg(not(windows))]
fn platform_window() -> Option<*mut std::ffi::c_void> {
None
}
#[cfg(windows)]
fn platform_window() -> Option<*mut std::ffi::c_void> {
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW;
use windows_sys::Win32::UI::WindowsAndMessaging::{
CreateWindowExW, DefWindowProcW, RegisterClassW, WNDCLASSW,
};
unsafe {
let class_name: Vec<u16> = "furumi_desktop_media\0".encode_utf16().collect();
let instance = GetModuleHandleW(std::ptr::null());
let mut class: WNDCLASSW = core::mem::zeroed();
class.lpfnWndProc = Some(DefWindowProcW);
class.hInstance = instance;
class.lpszClassName = class_name.as_ptr();
if RegisterClassW(&class) == 0 {
return None;
}
let window = CreateWindowExW(
0,
class_name.as_ptr(),
class_name.as_ptr(),
0,
0,
0,
0,
0,
std::ptr::null_mut(),
std::ptr::null_mut(),
instance,
std::ptr::null(),
);
(!window.is_null()).then_some(window.cast())
}
}
#[cfg(test)]
mod tests {
use super::duration_millis;
#[test]
fn invalid_media_durations_are_safe() {
assert_eq!(duration_millis(f64::NAN), 0);
assert_eq!(duration_millis(-1.0), 0);
assert_eq!(duration_millis(1.25), 1_250);
}
}
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "furumi-ui"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
build = "build.rs"
[dependencies]
furumi-application.workspace = true
furumi-backend.workspace = true
furumi-backend-api.workspace = true
furumi-domain.workspace = true
furumi-platform-desktop.workspace = true
image.workspace = true
slint.workspace = true
tokio.workspace = true
[build-dependencies]
slint-build.workspace = true
[lints]
workspace = true
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#aeb4c2" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h10M4 10h10M4 14h7"/><path d="M17 14v7M13.5 17.5h7"/></svg>

After

Width:  |  Height:  |  Size: 228 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#b8bdca" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>

After

Width:  |  Height:  |  Size: 186 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#b8bdca" stroke-width="2" stroke-linecap="round"><path d="m6 6 12 12M18 6 6 18"/></svg>

After

Width:  |  Height:  |  Size: 168 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#b8bdca" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="2.5" y="4" width="13" height="10" rx="1.8"/>
<path d="M6.5 18h5M9 14v4"/>
<rect x="17.2" y="8" width="4.3" height="9.5" rx="1.2"/>
<path d="M18.8 15.5h1"/>
</svg>

After

Width:  |  Height:  |  Size: 359 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#0b1713" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M5.6 8.5a7.5 7.5 0 000 7M18.4 8.5a7.5 7.5 0 010 7"/>
<path d="M2.8 5.8a11 11 0 000 12.4M21.2 5.8a11 11 0 010 12.4"/>
</svg>

After

Width:  |  Height:  |  Size: 325 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#b8bdca" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>

After

Width:  |  Height:  |  Size: 185 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#aeb4c2" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1.1-1.1a5.5 5.5 0 0 0-7.8 7.8l1.1 1.1L12 21l7.8-7.5 1.1-1.1a5.5 5.5 0 0 0-.1-7.8z"/></svg>

After

Width:  |  Height:  |  Size: 293 B

+1
View File
@@ -0,0 +1 @@
<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 11.5 12 4l9 7.5"/><path d="M5.5 10v10h13V10M9.5 20v-6h5v6"/></svg>

After

Width:  |  Height:  |  Size: 234 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#aeb4c2" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 11v6M12 7.5h.01"/></svg>

After

Width:  |  Height:  |  Size: 200 B

+1
View File
@@ -0,0 +1 @@
<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="M4 4h3v16H4zM10 4h3v16h-3zM16 5l3-.8L22 19l-3 .8z"/></svg>

After

Width:  |  Height:  |  Size: 223 B

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="g" x1="8" y1="56" x2="56" y2="8" gradientUnits="userSpaceOnUse">
<stop stop-color="#6d5dfb"/>
<stop offset="1" stop-color="#49d7a5"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="18" fill="url(#g)"/>
<path d="M25 43V19l22-4v23" fill="none" stroke="white" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="19" cy="44" r="7" fill="white"/>
<circle cx="41" cy="39" r="7" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 553 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#aeb4c2"><circle cx="5" cy="12" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="19" cy="12" r="1.7"/></svg>

After

Width:  |  Height:  |  Size: 180 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#d8dbe5"><path d="M16 5h2v14h-2zM5 5.4v13.2L15 12z"/></svg>

After

Width:  |  Height:  |  Size: 126 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="7.25" stroke="#a7adbb" stroke-width="1.5"/>
<circle cx="12" cy="12" r="2.25" fill="#a7adbb"/>
<circle cx="12" cy="12" r="0.75" fill="#292d38"/>
<path d="M8.1 8.9a5 5 0 0 1 2.1-1.45" stroke="#d3d6de" stroke-width="1.25" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 359 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#101218"><path d="M7 5h4v14H7zM13 5h4v14h-4z"/></svg>

After

Width:  |  Height:  |  Size: 120 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#aeb4c2" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h10M4 10h7M4 14h5"/><path d="m13 14 5 3-5 3z"/><path d="M20 13v8"/></svg>

After

Width:  |  Height:  |  Size: 242 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#101218"><path d="m8 5 11 7-11 7z"/></svg>

After

Width:  |  Height:  |  Size: 109 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#aeb4c2" stroke-width="1.8" stroke-linecap="round"><path d="M4 6h11M4 11h11M4 16h7M18 14v7M14.5 17.5h7"/></svg>

After

Width:  |  Height:  |  Size: 192 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#b8bdca" stroke-width="2" stroke-linecap="round">
<path d="M12 5v14M5 12h14"/>
</svg>

After

Width:  |  Height:  |  Size: 191 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#d8dbe5"><path d="M6 5h2v14H6zM19 5.4v13.2L9 12z"/></svg>

After

Width:  |  Height:  |  Size: 124 B

+1
View File
@@ -0,0 +1 @@
<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="M4 6h16M4 11h16M4 16h9"/><path d="m17 15 4 3-4 3z" fill="#b8bdca" stroke="none"/></svg>

After

Width:  |  Height:  |  Size: 252 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#b8bdca" stroke-width="1.8" stroke-linecap="round"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m15.5 15.5 5 5"/></svg>

After

Width:  |  Height:  |  Size: 201 B

+1
View File
@@ -0,0 +1 @@
<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="M4 6h5M15 6h5M4 12h9M19 12h1M4 18h2M12 18h8"/><circle cx="12" cy="6" r="3"/><circle cx="16" cy="12" r="3"/><circle cx="9" cy="18" r="3"/></svg>

After

Width:  |  Height:  |  Size: 308 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#aeb4c2" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.6 10.5 6.8-4M8.6 13.5l6.8 4"/></svg>

After

Width:  |  Height:  |  Size: 295 B

+1
View File
@@ -0,0 +1 @@
<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="M4 10v4h4l5 4V6l-5 4z"/><path d="M16 9a4 4 0 0 1 0 6M18.5 6.5a8 8 0 0 1 0 11"/></svg>

After

Width:  |  Height:  |  Size: 250 B

+3
View File
@@ -0,0 +1,3 @@
fn main() {
slint_build::compile("ui/app.slint").expect("failed to compile Slint UI");
}
+728
View File
@@ -0,0 +1,728 @@
//! Slint presentation and the adapter to the application/backend state flow.
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use std::thread;
use furumi_application::{
AppEvent, AppState, Effect, Screen, UiAction, duration_label, reduce_action, reduce_event,
};
use furumi_backend::BackendHandle;
use furumi_backend_api::{
BackendCommand, DevicePlaybackRole, DevicePresence, DeviceTrust, FederationOperation,
PlaybackStatus, RemoteData,
};
use furumi_domain::{
Artist, ArtistKey, ArtistRef, AudioSource, CatalogSource, ContentId, LocalTrackId, QueueItemId,
Release, ReleaseKey, Track, TrackKey,
};
use furumi_platform_desktop::{MediaCommand, MediaSession};
use slint::{
ComponentHandle, Image, ModelRc, Rgba8Pixel, SharedPixelBuffer, SharedString, VecModel,
};
slint::include_modules!();
mod render;
use render::{
breadcrumb_screen, contributor_lines, find_artist_key, find_release_key, model,
parse_track_key, release_artist_credits, render, render_catalog, render_current_track,
render_playback, render_queue, render_search, render_shell, render_track_info,
selected_release, track_context,
};
thread_local! {
/// Decoded images belong to the Slint UI thread. Keeping them here makes
/// every state projection after the first load a memory-only operation.
static ARTWORK_CACHE: RefCell<HashMap<PathBuf, Option<Image>>> = RefCell::new(HashMap::new());
static MEDIA_SESSION: RefCell<Option<MediaSession>> = const { RefCell::new(None) };
}
/// Runs the desktop presentation until its main window closes.
///
/// # Errors
///
/// Returns a Slint platform error when the window or event loop cannot start.
pub fn run(backend: &BackendHandle) -> Result<(), slint::PlatformError> {
let window = AppWindow::new()?;
let state = Arc::new(Mutex::new(AppState::default()));
let media_backend = backend.clone();
MEDIA_SESSION.with_borrow_mut(|session| {
*session = MediaSession::new(move |command| {
let backend_command = match command {
MediaCommand::Toggle => BackendCommand::TogglePlayback,
MediaCommand::Play => BackendCommand::Play,
MediaCommand::Pause => BackendCommand::Pause,
MediaCommand::Next => BackendCommand::Next,
MediaCommand::Previous => BackendCommand::Previous,
MediaCommand::Stop => BackendCommand::Stop,
};
let _ = media_backend.try_send(backend_command);
});
});
bind_callbacks(&window, &state, backend);
subscribe_to_backend(&window, &state, backend);
with_state(&state, |state| render(&window, state));
if let Err(error) = backend.try_send(BackendCommand::Initialize) {
dispatch_event(
&window,
&state,
AppEvent::CommandRejected(error.to_string()),
);
}
let result = window.run();
MEDIA_SESSION.with_borrow_mut(Option::take);
let _ = backend.try_send(BackendCommand::Shutdown);
result
}
fn bind_callbacks(window: &AppWindow, state: &Arc<Mutex<AppState>>, backend: &BackendHandle) {
bind_catalog_callbacks(window, state, backend);
window.on_navigate({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |target| {
let screen = match target.as_str() {
"search" => Screen::Search,
"library" => Screen::Library,
_ => Screen::Home,
};
dispatch_action(&window, &state, &backend, UiAction::Navigate(screen));
}
});
window.on_toggle_queue({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move || dispatch_action(&window, &state, &backend, UiAction::ToggleQueue)
});
window.on_toggle_settings({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move || dispatch_action(&window, &state, &backend, UiAction::ToggleSettings)
});
bind_player_callbacks(window, state, backend);
window.on_search_changed({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |query| {
dispatch_action(
&window,
&state,
&backend,
UiAction::SearchChanged(query.to_string()),
);
}
});
window.on_track_action({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |action, key| {
let Some(key) = parse_track_key(&key) else {
return;
};
let action = match action.as_str() {
"like" => UiAction::ToggleLike(key),
"play-next" => UiAction::AddNext(vec![key]),
"add-end" => UiAction::AddToEnd(vec![key]),
"information" => UiAction::ShowTrackInfo(key),
"playlist" => UiAction::ShowPlaylistPicker(key),
// The remaining presentation actions terminate here until
// their backend capabilities are introduced.
_ => return,
};
dispatch_action(&window, &state, &backend, action);
}
});
bind_playlist_callbacks(window, state, backend);
bind_device_callbacks(window, state, backend);
bind_settings_callbacks(window, state, backend);
window.on_dismiss_error({
let window = window.as_weak();
let state = Arc::clone(state);
move || {
let Some(window) = window.upgrade() else {
return;
};
with_state(&state, |state| {
let _ = reduce_action(state, UiAction::DismissError);
render(&window, state);
});
}
});
window.on_close_track_info({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move || dispatch_action(&window, &state, &backend, UiAction::CloseTrackInfo)
});
}
fn bind_playlist_callbacks(
window: &AppWindow,
state: &Arc<Mutex<AppState>>,
backend: &BackendHandle,
) {
window.on_open_playlist({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |id| {
if let Ok(id) = id.parse::<i64>() {
dispatch_action(
&window,
&state,
&backend,
UiAction::Navigate(Screen::Playlist(id)),
);
}
}
});
window.on_create_playlist({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |title| {
dispatch_action(
&window,
&state,
&backend,
UiAction::CreatePlaylist(title.to_string()),
);
}
});
window.on_add_track_to_playlist({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |playlist_id| {
let Ok(playlist_id) = playlist_id.parse::<i64>() else {
return;
};
let track = with_state(&state, |state| state.frontend.playlist_picker_track.clone());
if let Some(track) = track {
dispatch_action(
&window,
&state,
&backend,
UiAction::AddToPlaylist {
playlist_id,
tracks: vec![track],
},
);
}
}
});
window.on_close_playlist_picker({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move || dispatch_action(&window, &state, &backend, UiAction::ClosePlaylistPicker)
});
window.on_play_queue_item({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |id| {
if let Ok(id) = id.parse::<u64>() {
dispatch_action(
&window,
&state,
&backend,
UiAction::PlayQueueItem(QueueItemId::new(id)),
);
}
}
});
}
fn bind_catalog_callbacks(
window: &AppWindow,
state: &Arc<Mutex<AppState>>,
backend: &BackendHandle,
) {
window.on_layout_detail_contributors({
let window = window.as_weak();
let state = Arc::clone(state);
move |width| {
let lines = with_state(&state, |state| {
let Some(release) = selected_release(state) else {
return Vec::new();
};
let preferred = match &state.frontend.screen {
Screen::Release(_, preferred) => preferred.as_ref(),
_ => None,
};
let (_, contributors) = release_artist_credits(&release, preferred);
contributor_lines(&contributors, width)
});
if let Some(window) = window.upgrade() {
window.set_detail_contributor_lines(model(lines));
}
}
});
window.on_go_back({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move || dispatch_action(&window, &state, &backend, UiAction::Back)
});
window.on_go_forward({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move || dispatch_action(&window, &state, &backend, UiAction::Forward)
});
window.on_navigate_breadcrumb({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |target| {
let screen = with_state(&state, |state| breadcrumb_screen(state, &target));
if let Some(screen) = screen {
dispatch_action(&window, &state, &backend, UiAction::Navigate(screen));
}
}
});
window.on_open_artist({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |key| {
let artist = with_state(&state, |state| find_artist_key(state, &key));
if let Some(key) = artist {
dispatch_action(
&window,
&state,
&backend,
UiAction::Navigate(Screen::Artist(key)),
);
}
}
});
window.on_open_release({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |key| {
let (release, preferred_artist) = with_state(&state, |state| {
let preferred_artist = match &state.frontend.screen {
Screen::Artist(key) => Some(key.clone()),
_ => None,
};
(find_release_key(state, &key), preferred_artist)
});
if let Some(key) = release {
dispatch_action(
&window,
&state,
&backend,
UiAction::Navigate(Screen::Release(key, preferred_artist)),
);
}
}
});
}
fn bind_player_callbacks(
window: &AppWindow,
state: &Arc<Mutex<AppState>>,
backend: &BackendHandle,
) {
window.on_toggle_playback({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move || dispatch_action(&window, &state, &backend, UiAction::TogglePlayback)
});
window.on_next({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move || dispatch_action(&window, &state, &backend, UiAction::Next)
});
window.on_previous({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move || dispatch_action(&window, &state, &backend, UiAction::Previous)
});
window.on_seek({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |fraction| {
let duration = with_state(&state, |state| state.backend.playback.duration_seconds);
dispatch_action(
&window,
&state,
&backend,
UiAction::Seek(f64::from(fraction) * duration),
);
}
});
window.on_set_volume({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |volume| {
dispatch_action(&window, &state, &backend, UiAction::SetVolume(volume));
}
});
window.on_play_release({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |key| {
let release_id = with_state(&state, |state| find_release_key(state, &key));
if let Some(release_id) = release_id {
dispatch_action(
&window,
&state,
&backend,
UiAction::PlayRelease {
release_id,
start: 0,
},
);
}
}
});
window.on_play_track({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |key| {
if let Some(key) = parse_track_key(&key) {
dispatch_action(&window, &state, &backend, UiAction::PlayTrack(key));
}
}
});
window.on_play_track_context({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |context, selected| {
let Some(selected) = parse_track_key(&selected) else {
return;
};
let tracks = with_state(&state, |state| track_context(state, context.as_str()));
if tracks.iter().any(|track| track.matches(&selected)) {
dispatch_action(
&window,
&state,
&backend,
UiAction::PlayContext { tracks, selected },
);
}
}
});
}
fn bind_settings_callbacks(
window: &AppWindow,
state: &Arc<Mutex<AppState>>,
backend: &BackendHandle,
) {
window.on_network_id_changed({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |value| {
dispatch_action(
&window,
&state,
&backend,
UiAction::NetworkIdChanged(value.to_string()),
);
}
});
window.on_device_name_changed({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |value| {
dispatch_action(
&window,
&state,
&backend,
UiAction::DeviceNameChanged(value.to_string()),
);
}
});
window.on_library_path_changed({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |value| {
dispatch_action(
&window,
&state,
&backend,
UiAction::LibraryPathChanged(value.to_string()),
);
}
});
bind_library_picker(window, state, backend);
window.on_federation_changed({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |enabled| {
dispatch_action(
&window,
&state,
&backend,
UiAction::FederationChanged(enabled),
);
}
});
window.on_save_federated_on_listen_changed({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |enabled| {
dispatch_action(
&window,
&state,
&backend,
UiAction::SaveFederatedOnListenChanged(enabled),
);
}
});
window.on_language_changed({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |language| {
dispatch_action(
&window,
&state,
&backend,
UiAction::LanguageChanged(language.to_string()),
);
}
});
}
fn bind_library_picker(window: &AppWindow, state: &Arc<Mutex<AppState>>, backend: &BackendHandle) {
window.on_choose_library_path({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move || {
let initial = with_state(&state, |state| {
PathBuf::from(&state.backend.settings.library_path)
});
let task_window = window.clone();
let task_state = Arc::clone(&state);
let task_backend = backend.clone();
let result = thread::Builder::new()
.name("furumi-folder-picker".into())
.spawn(move || {
let selected =
furumi_platform_desktop::choose_library_directory(Some(&initial));
let Some(selected) = selected else {
return;
};
let selected = selected.to_string_lossy().into_owned();
let _ = task_window.upgrade_in_event_loop(move |window| {
dispatch_action(
&window.as_weak(),
&task_state,
&task_backend,
UiAction::LibraryPathChanged(selected),
);
});
});
if let Err(error) = result
&& let Some(window) = window.upgrade()
{
dispatch_event(
&window,
&state,
AppEvent::CommandRejected(format!("folder picker: {error}")),
);
}
}
});
}
fn bind_device_callbacks(
window: &AppWindow,
state: &Arc<Mutex<AppState>>,
backend: &BackendHandle,
) {
window.on_create_device_invite({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move || dispatch_action(&window, &state, &backend, UiAction::CreateDeviceInvite)
});
window.on_connect_device({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |invite| {
dispatch_action(
&window,
&state,
&backend,
UiAction::ConnectDevice(invite.to_string()),
);
}
});
window.on_answer_pairing({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |request_id, accept, use_requester_group| {
dispatch_action(
&window,
&state,
&backend,
UiAction::AnswerDevicePairing {
request_id: request_id.to_string(),
accept,
use_requester_group,
},
);
}
});
window.on_select_device({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |device_id| {
dispatch_action(
&window,
&state,
&backend,
UiAction::SelectPlaybackDevice(device_id.to_string()),
);
}
});
}
fn subscribe_to_backend(window: &AppWindow, state: &Arc<Mutex<AppState>>, backend: &BackendHandle) {
let mut snapshots = backend.subscribe();
let bridge_window = window.as_weak();
let bridge_state = Arc::clone(state);
let result = thread::Builder::new()
.name("furumi-ui-state-bridge".into())
.spawn(move || {
let runtime = match tokio::runtime::Builder::new_current_thread().build() {
Ok(runtime) => runtime,
Err(error) => {
report_background_error(
&bridge_window,
bridge_state,
format!("UI state bridge: {error}"),
);
return;
}
};
runtime.block_on(async move {
while snapshots.changed().await.is_ok() {
let snapshot = snapshots.borrow_and_update().clone();
let state = Arc::clone(&bridge_state);
let result = bridge_window.upgrade_in_event_loop(move |window| {
dispatch_event(
&window,
&state,
AppEvent::BackendSnapshot(Box::new(snapshot)),
);
});
if result.is_err() {
break;
}
}
});
});
if let Err(error) = result {
dispatch_event(
window,
state,
AppEvent::CommandRejected(format!("UI state bridge: {error}")),
);
}
}
fn report_background_error(
window: &slint::Weak<AppWindow>,
state: Arc<Mutex<AppState>>,
message: String,
) {
let window = window.clone();
let _ = window.upgrade_in_event_loop(move |window| {
dispatch_event(&window, &state, AppEvent::CommandRejected(message));
});
}
fn dispatch_action(
window: &slint::Weak<AppWindow>,
state: &Arc<Mutex<AppState>>,
backend: &BackendHandle,
action: UiAction,
) {
let Some(window) = window.upgrade() else {
return;
};
let effects = with_state(state, |state| {
let effects = reduce_action(state, action);
render(&window, state);
effects
});
for Effect::Send(command) in effects {
if let Err(error) = backend.try_send(command) {
dispatch_event(&window, state, AppEvent::CommandRejected(error.to_string()));
}
}
}
fn dispatch_event(window: &AppWindow, state: &Arc<Mutex<AppState>>, event: AppEvent) {
with_state(state, |state| {
let previous = state.backend.clone();
reduce_event(state, event);
let shell_changed = previous.federation_activity != state.backend.federation_activity
|| previous.federation_debug != state.backend.federation_debug
|| previous.connected_devices != state.backend.connected_devices
|| previous.settings != state.backend.settings
|| previous.playback_error != state.backend.playback_error
|| previous.settings_error != state.backend.settings_error;
let catalog_changed = previous.library != state.backend.library
|| previous.search != state.backend.search
|| previous.queue != state.backend.queue;
let queue_changed = previous.queue != state.backend.queue;
if shell_changed {
render_shell(window, state);
}
if catalog_changed {
render_catalog(window, state);
render_search(window, state);
render_track_info(window, state);
}
if queue_changed {
render_queue(window, state);
render_current_track(window, state);
}
render_playback(window, state);
});
}
fn with_state<T>(state: &Arc<Mutex<AppState>>, operation: impl FnOnce(&mut AppState) -> T) -> T {
let mut guard = state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
operation(&mut guard)
}
File diff suppressed because it is too large Load Diff
+822
View File
@@ -0,0 +1,822 @@
import { Button, CheckBox, ComboBox, LineEdit, ScrollView, Slider } from "std-widgets.slint";
import { ArtistLinkLineView, ArtistLinkView, ArtistView, BreadcrumbView, DeviceView, PairingView, PlaylistView, QueueView, ReleaseView, TrackView, VersionView } from "models.slint";
import { AlbumGrid, ArtistGrid, ArtistLinksRow, BuildInfoCard, DeviceRow, FederationBadge, IconButton, InfoField, LinkText, MarqueeText, NavButton, PlaylistNavButton, SearchArtistGrid, SearchField, SearchReleaseGrid, StaticArtistList, TrackInfoArtists, TrackList } from "components.slint";
export component AppWindow inherits Window {
title: "Furumi Desktop";
preferred-width: 1180px;
preferred-height: 760px;
min-width: 920px;
min-height: 620px;
background: #090b10;
default-font-family: "sans-serif";
in property <string> app-name: "Furumi";
in property <string> home-label: "Home";
in property <string> search-label: "Search";
in property <string> library-label: "Your library";
in property <string> queue-label: "Queue";
in property <string> recent-label: "Recently played";
in property <string> featured-label: "Made for listening";
in property <string> search-placeholder: "Artists, albums or tracks";
in-out property <string> search-query;
in property <string> active-screen: "home";
in property <bool> queue-open;
in property <bool> settings-open;
in property <string> network-id;
in property <string> device-name;
in property <string> library-path;
in property <bool> federation-enabled;
in property <bool> save-federated-on-listen;
in property <string> selected-language: "English";
in property <[string]> available-languages;
in property <[ReleaseView]> releases;
in property <[ArtistView]> artists;
in property <[ReleaseView]> artist-albums;
in property <[ReleaseView]> artist-other-releases;
in property <[TrackView]> artist-featured-tracks;
in property <[TrackView]> detail-tracks;
in property <string> detail-title;
in property <string> detail-subtitle;
in property <[ArtistLinkView]> detail-main-artists;
in property <[ArtistLinkLineView]> detail-contributor-lines;
in property <string> detail-release-type;
in property <image> detail-artwork;
in property <bool> detail-has-artwork;
in property <[TrackView]> tracks;
in property <[QueueView]> queue-items;
in property <[PlaylistView]> playlists;
in property <[TrackView]> playlist-tracks;
in property <string> playlist-title;
in property <bool> playlist-picker-open;
in property <[DeviceView]> connected-devices;
in property <[PairingView]> pending-pairings;
in property <string> device-role-label;
in property <bool> device-is-active;
in property <string> active-device-label;
in property <string> device-group-label;
in property <string> device-invite;
in property <string> device-status;
in property <bool> device-busy;
in property <[TrackView]> search-results;
in property <[ArtistView]> search-artists;
in property <[ReleaseView]> search-releases;
in property <bool> federation-status-busy;
in property <string> federation-status-text: "Federation ready";
in property <string> federation-debug-node: "Stopped";
in property <string> federation-debug-peers: "0 connected · 0 known";
in property <string> federation-debug-dht: "n/a";
in property <string> federation-debug-published: "0 items";
in property <string> federation-debug-endpoint;
in property <[VersionView]> software-versions;
in property <[VersionView]> protocol-versions;
in property <[BreadcrumbView]> breadcrumbs;
in property <bool> can-go-back;
in property <bool> can-go-forward;
in property <bool> track-info-open;
in property <string> track-info-title;
in property <[ArtistLinkView]> track-info-artist-links;
in property <string> track-info-release;
in property <string> track-info-duration;
in property <string> track-info-format;
in property <string> track-info-quality;
in property <string> track-info-source;
in property <string> track-info-content-id;
in property <string> track-info-path;
in property <image> track-info-artwork;
in property <bool> track-info-has-artwork;
in property <int> detail-federation-state;
in property <bool> has-track;
in property <bool> playing;
in property <string> current-title;
in property <[ArtistLinkView]> current-artists;
in property <string> current-metadata;
in property <string> current-release;
in property <string> current-release-key;
in property <image> current-artwork;
in property <bool> current-has-artwork;
in property <string> elapsed;
in property <string> duration;
in property <float> progress;
in property <float> volume;
in property <string> error-message;
callback navigate(string);
callback go-back;
callback go-forward;
callback navigate-breadcrumb(string);
callback open-artist(string);
callback open-release(string);
callback layout-detail-contributors(float);
callback toggle-queue;
callback toggle-settings;
callback toggle-playback;
callback next;
callback previous;
callback seek(float);
callback set-volume(float);
callback play-release(string);
callback play-track(string);
callback play-track-context(string, string);
callback play-queue-item(string);
callback track-action(string, string);
callback open-playlist(string);
callback create-playlist(string);
callback add-track-to-playlist(string);
callback close-playlist-picker;
callback create-device-invite;
callback connect-device(string);
callback answer-pairing(string, bool, bool);
callback select-device(string);
callback search-changed(string);
callback network-id-changed(string);
callback device-name-changed(string);
callback library-path-changed(string);
callback choose-library-path;
callback federation-changed(bool);
callback save-federated-on-listen-changed(bool);
callback language-changed(string);
callback dismiss-error;
callback close-track-info;
Rectangle {
background: #090b10;
HorizontalLayout {
x: 0px; y: 0px; width: parent.width; height: parent.height - 90px;
spacing: 0px;
sidebar := Rectangle {
width: 218px;
background: #101218;
border-color: #232630;
border-width: 0px;
VerticalLayout {
padding: 16px;
spacing: 7px;
HorizontalLayout {
height: 52px;
spacing: 11px;
Rectangle {
width: 36px;
background: transparent;
Image { source: @image-url("../assets/logo.svg"); width: 36px; height: 36px; y: (parent.height - self.height) / 2; }
}
Text { text: root.app-name; color: #f5f6fa; font-size: 18px; font-weight: 800; vertical-alignment: center; }
}
NavButton { label: root.home-label; icon-source: @image-url("../assets/home.svg"); active: root.active-screen == "home"; clicked => root.navigate("home"); }
NavButton { label: root.search-label; icon-source: @image-url("../assets/search.svg"); active: root.active-screen == "search"; clicked => root.navigate("search"); }
NavButton { label: root.library-label; icon-source: @image-url("../assets/library.svg"); active: root.active-screen == "library"; clicked => root.navigate("library"); }
Rectangle { height: 12px; background: transparent; }
HorizontalLayout {
height: 28px;
Text { horizontal-stretch: 1; text: "PLAYLISTS"; color: #656b7b; font-size: 10px; font-weight: 700; letter-spacing: 1.2px; vertical-alignment: center; }
Rectangle {
width: 28px; height: 28px; border-radius: 6px;
background: add-playlist-touch.has-hover ? #282c38 : transparent;
Image { source: @image-url("../assets/plus.svg"); width: 16px; height: 16px; x: 6px; y: 6px; }
add-playlist-touch := TouchArea { clicked => new-playlist-popup.show(); }
}
}
ScrollView {
vertical-stretch: 1;
min-height: 0px;
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout {
spacing: 2px;
for playlist in root.playlists: PlaylistNavButton {
item: playlist;
active: root.active-screen == "playlist" && root.playlist-title == playlist.title;
clicked(id) => root.open-playlist(id);
}
}
}
NavButton { label: "Settings"; icon-source: @image-url("../assets/settings.svg"); active: root.settings-open; clicked => root.toggle-settings(); }
Rectangle {
height: 58px; border-radius: 9px; background: #171a22;
HorizontalLayout {
padding: 10px; spacing: 10px;
Rectangle { width: 34px; height: 34px; border-radius: 17px; background: #353a49; Text { text: "F"; color: #fff; font-weight: 700; horizontal-alignment: center; vertical-alignment: center; } }
VerticalLayout { alignment: center; Text { text: "Local library"; color: #e9eaf0; font-size: 12px; font-weight: 600; } Text { text: "Desktop player"; color: #777d8d; font-size: 10px; } }
}
}
}
new-playlist-popup := PopupWindow {
x: 16px; y: 250px; width: 250px; height: 132px;
close-policy: close-on-click-outside;
Rectangle {
border-radius: 10px; background: #20242e; border-width: 1px; border-color: #3a3f4d;
VerticalLayout {
padding: 14px; spacing: 10px;
Text { text: "New playlist"; color: #f5f6fa; font-size: 14px; font-weight: 700; }
new-playlist-name := LineEdit { placeholder-text: "Playlist name"; }
Button { text: "Create"; clicked => { root.create-playlist(new-playlist-name.text); new-playlist-name.text = ""; new-playlist-popup.close(); } }
}
}
}
}
content := Rectangle {
horizontal-stretch: 1;
background: #0c0e14;
VerticalLayout {
padding-left: 26px;
padding-right: 26px;
padding-top: 18px;
padding-bottom: 16px;
spacing: 17px;
HorizontalLayout {
height: 42px;
IconButton { enabled: root.can-go-back; icon-source: @image-url("../assets/back.svg"); clicked => root.go-back(); }
IconButton { enabled: root.can-go-forward; icon-source: @image-url("../assets/forward.svg"); clicked => root.go-forward(); }
Rectangle { horizontal-stretch: 1; background: transparent; }
SearchField {
width: 280px;
text <=> root.search-query;
placeholder-text: root.search-placeholder;
edited(value) => root.search-changed(value);
}
}
HorizontalLayout {
height: 26px;
Rectangle {
horizontal-stretch: 1; background: transparent; clip: true;
HorizontalLayout {
spacing: 0px;
alignment: start;
for crumb in root.breadcrumbs: Text {
horizontal-stretch: 0;
text: crumb.label;
color: crumb.target == "" ? #aeb4c2 : crumb-touch.has-hover ? #f1f2f6 : #777e8f;
font-size: 13px; font-weight: crumb.target == "" ? 650 : 500; vertical-alignment: center;
crumb-touch := TouchArea { enabled: crumb.target != ""; clicked => root.navigate-breadcrumb(crumb.target); }
}
}
}
Rectangle {
height: 24px;
width: 280px;
border-radius: 12px;
background: root.federation-status-busy ? #20352f : #191d26;
border-color: root.federation-status-busy ? #3d7865 : #303541;
border-width: 1px;
HorizontalLayout {
padding-left: 10px; padding-right: 10px; spacing: 6px;
Text { text: root.federation-status-busy ? "◌" : "●"; color: root.federation-status-busy ? #55dbaa : #697080; font-size: 10px; vertical-alignment: center; }
status-label := Text { text: root.federation-status-text; color: #9da3b2; font-size: 10px; overflow: elide; vertical-alignment: center; }
}
}
}
if root.active-screen == "home": ScrollView {
viewport-width: self.width;
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout {
width: parent.width;
spacing: 17px;
Text { text: "Good evening"; color: #f5f6fa; font-size: 28px; font-weight: 800; }
Text { text: "Artists"; color: #f5f6fa; font-size: 18px; font-weight: 750; }
ArtistGrid { artists: root.artists; open(key) => root.open-artist(key); }
Rectangle { height: 8px; background: transparent; }
Text { text: root.recent-label; color: #f5f6fa; font-size: 18px; font-weight: 750; }
TrackList { tracks: root.tracks; play(key) => root.play-track-context("recent", key); action(action, key) => root.track-action(action, key); open-artist(key) => root.open-artist(key); open-release(key) => root.open-release(key); }
}
}
if root.active-screen == "library": ScrollView {
viewport-width: self.width;
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout {
width: parent.width; spacing: 17px;
Text { text: root.library-label; color: #f5f6fa; font-size: 28px; font-weight: 800; }
AlbumGrid { releases: root.releases; play(key) => root.play-release(key); open(key) => root.open-release(key); open-artist(key) => root.open-artist(key); }
}
}
if root.active-screen == "playlist": ScrollView {
viewport-width: self.width;
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout {
width: parent.width; spacing: 18px; alignment: start;
Text { height: 46px; vertical-stretch: 0; text: root.playlist-title; color: #f5f6fa; font-size: 30px; font-weight: 800; vertical-alignment: center; }
Text { height: 18px; vertical-stretch: 0; text: root.playlist-tracks.length + (root.playlist-tracks.length == 1 ? " track" : " tracks"); color: #858b9c; font-size: 12px; vertical-alignment: center; }
TrackList { tracks: root.playlist-tracks; show-artwork: true; play(key) => root.play-track-context("playlist", key); action(action, key) => root.track-action(action, key); open-artist(key) => root.open-artist(key); open-release(key) => root.open-release(key); }
}
}
if root.active-screen == "artist": ScrollView {
viewport-width: self.width;
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout {
width: parent.width; spacing: 20px; alignment: start;
HorizontalLayout {
height: 188px; spacing: 22px;
Rectangle {
width: 188px; height: 188px; border-radius: 30px; background: #292d38; clip: true;
Image { source: @image-url("../assets/note.svg"); width: 54px; height: 54px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; }
if root.detail-has-artwork: Image { source: root.detail-artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; }
}
VerticalLayout { alignment: end; spacing: 8px; Text { text: "ARTIST"; color: #9298a8; font-size: 11px; font-weight: 700; } Text { text: root.detail-title; color: #f5f6fa; font-size: 34px; font-weight: 800; overflow: elide; } Text { text: root.detail-subtitle; color: #858b9c; font-size: 13px; } }
}
if root.artist-albums.length > 0: Text { height: 28px; vertical-stretch: 0; text: "Albums"; color: #f5f6fa; font-size: 20px; font-weight: 750; vertical-alignment: center; }
if root.artist-albums.length > 0: AlbumGrid { releases: root.artist-albums; show-artists: false; play(key) => root.play-release(key); open(key) => root.open-release(key); open-artist(key) => root.open-artist(key); }
if root.artist-other-releases.length > 0: Text { height: 28px; vertical-stretch: 0; text: "Singles, EPs and other releases"; color: #f5f6fa; font-size: 20px; font-weight: 750; vertical-alignment: center; }
if root.artist-other-releases.length > 0: AlbumGrid { releases: root.artist-other-releases; show-artists: false; show-release-types: true; play(key) => root.play-release(key); open(key) => root.open-release(key); open-artist(key) => root.open-artist(key); }
if root.artist-featured-tracks.length > 0: Text { height: 28px; vertical-stretch: 0; text: "Featured on"; color: #f5f6fa; font-size: 20px; font-weight: 750; vertical-alignment: center; }
if root.artist-featured-tracks.length > 0: TrackList { tracks: root.artist-featured-tracks; show-artwork: true; play(key) => root.play-track-context("artist-featured", key); action(action, key) => root.track-action(action, key); open-artist(key) => root.open-artist(key); open-release(key) => root.open-release(key); }
}
}
if root.active-screen == "release": ScrollView {
viewport-width: self.width;
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout {
width: parent.width; spacing: 20px;
HorizontalLayout {
height: 188px; spacing: 22px;
Rectangle {
width: 188px; height: 188px; border-radius: 9px; background: #292d38; clip: true;
Image { source: @image-url("../assets/note.svg"); width: 54px; height: 54px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; }
if root.detail-has-artwork: Image { source: root.detail-artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; }
}
Rectangle {
horizontal-stretch: 1;
height: 188px;
background: transparent;
VerticalLayout {
x: 0px; y: 68px; width: parent.width; height: 111px;
spacing: 2px; alignment: start;
HorizontalLayout {
height: 18px; spacing: 8px;
Text { text: root.detail-release-type; color: #9298a8; font-size: 11px; font-weight: 700; vertical-alignment: center; }
if root.detail-federation-state > 0: FederationBadge { partial: root.detail-federation-state == 2; }
Rectangle { horizontal-stretch: 1; background: transparent; }
}
Text { width: parent.width; height: 43px; text: root.detail-title; color: #f5f6fa; font-size: 34px; font-weight: 800; overflow: elide; vertical-alignment: center; }
ArtistLinksRow { width: parent.width; height: 20px; artists: root.detail-main-artists; text-size: 13px; open-artist(key) => root.open-artist(key); }
}
}
}
if root.detail-contributor-lines.length > 0: StaticArtistList {
width: parent.width / 2;
lines: root.detail-contributor-lines;
heading: "Featuring";
open-artist(key) => root.open-artist(key);
changed width => {
root.layout-detail-contributors(self.width / 1px);
}
}
TrackList { tracks: root.detail-tracks; play(key) => root.play-track-context("release", key); action(action, key) => root.track-action(action, key); open-artist(key) => root.open-artist(key); open-release(key) => root.open-release(key); }
}
}
if root.active-screen == "search": ScrollView {
viewport-width: self.width;
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout {
width: parent.width; spacing: 14px; alignment: start;
HorizontalLayout {
height: 34px;
Text { text: root.search-query == "" ? "Search your music" : "Results for “" + root.search-query + "”"; color: #f5f6fa; font-size: 25px; font-weight: 800; vertical-alignment: center; }
Rectangle { horizontal-stretch: 1; background: transparent; }
}
if root.search-artists.length > 0: Text { height: 24px; text: "Artists"; color: #f5f6fa; font-size: 18px; font-weight: 750; vertical-alignment: center; }
if root.search-artists.length > 0: SearchArtistGrid { artists: root.search-artists; open(key) => root.open-artist(key); }
if root.search-releases.length > 0: Text { height: 24px; text: "Releases"; color: #f5f6fa; font-size: 18px; font-weight: 750; vertical-alignment: center; }
if root.search-releases.length > 0: SearchReleaseGrid { releases: root.search-releases; open(key) => root.open-release(key); }
if root.search-results.length > 0: Text { height: 24px; text: "Tracks"; color: #f5f6fa; font-size: 18px; font-weight: 750; vertical-alignment: center; }
TrackList { tracks: root.search-results; show-artwork: true; play(key) => root.play-track-context("search", key); action(action, key) => root.track-action(action, key); open-artist(key) => root.open-artist(key); open-release(key) => root.open-release(key); }
}
}
}
}
if root.queue-open: queue-panel := Rectangle {
width: 292px;
background: #11141b;
border-color: #252936;
border-width: 1px;
VerticalLayout {
padding: 17px;
spacing: 12px;
HorizontalLayout {
height: 34px;
Text { text: root.queue-label; color: #f5f6fa; font-size: 18px; font-weight: 750; vertical-alignment: center; }
Rectangle { horizontal-stretch: 1; background: transparent; }
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); }
}
}
}
}
}
Rectangle { vertical-stretch: 1; background: transparent; }
}
}
}
player := Rectangle {
x: 0px; y: parent.height - 90px; width: parent.width; height: 90px;
background: #11131a;
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;
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; }
if root.current-has-artwork: Image { source: root.current-artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; }
if root.current-has-artwork: TouchArea { clicked => artwork-popup.show(); }
}
VerticalLayout {
width: 195px;
alignment: center;
Text { width: 195px; text: root.current-title; color: #f3f4f8; font-size: 13px; font-weight: 650; overflow: elide; }
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; }
}
}
VerticalLayout {
horizontal-stretch: 1; spacing: 7px;
HorizontalLayout {
alignment: center; spacing: 8px;
IconButton { icon-source: @image-url("../assets/previous.svg"); clicked => root.previous(); }
Rectangle {
width: 42px; height: 42px; border-radius: 21px; background: #f4f5f8;
if root.playing: Image { source: @image-url("../assets/pause.svg"); width: 19px; height: 19px; x: 11px; y: 11px; }
if !root.playing: Image { source: @image-url("../assets/play.svg"); width: 19px; height: 19px; x: 12px; y: 11px; }
TouchArea { clicked => root.toggle-playback(); }
}
IconButton { icon-source: @image-url("../assets/next.svg"); clicked => root.next(); }
}
HorizontalLayout {
spacing: 9px;
Text { text: root.elapsed; color: #818797; font-size: 10px; width: 34px; horizontal-alignment: right; vertical-alignment: center; }
Slider { horizontal-stretch: 1; height: 18px; minimum: 0; maximum: 1; value: root.progress; changed(value) => root.seek(value); }
Text { text: root.duration; color: #818797; font-size: 10px; width: 34px; vertical-alignment: center; }
}
}
Rectangle {
width: 238px; 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); }
IconButton {
x: 190px; y: (parent.height - self.height) / 2;
icon-source: @image-url("../assets/devices.svg");
icon-color: root.device-is-active ? #e58bd7 : transparent;
clicked => devices-popup.show();
}
devices-popup := PopupWindow {
x: -130px; y: -474px; width: 368px; height: 464px;
close-policy: close-on-click-outside;
Rectangle {
border-radius: 12px; background: #1b1f28; border-width: 1px; border-color: #3a3f4d;
VerticalLayout {
padding: 16px; spacing: 10px;
HorizontalLayout {
height: 36px;
VerticalLayout {
Text { text: "Connected devices"; color: #f5f6fa; font-size: 17px; font-weight: 750; }
Text { text: root.device-role-label + " · " + root.active-device-label; color: #7f8797; font-size: 10px; }
}
Rectangle { horizontal-stretch: 1; background: transparent; }
IconButton { icon-source: @image-url("../assets/close.svg"); clicked => devices-popup.close(); }
}
Text { text: root.device-group-label; color: #697080; font-size: 10px; overflow: elide; }
Rectangle { height: 1px; background: #303541; }
Text { height: 16px; text: "ACTIVE DEVICE"; color: #697080; font-size: 9px; font-weight: 750; letter-spacing: 0.8px; vertical-alignment: center; }
for device in root.connected-devices: DeviceRow {
visible: device.active;
height: device.active ? 48px : 0px;
item: device;
selectable: false;
}
Text { height: 16px; text: "AVAILABLE DEVICES"; color: #697080; font-size: 9px; font-weight: 750; letter-spacing: 0.8px; vertical-alignment: center; }
ScrollView {
vertical-stretch: 1;
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout {
spacing: 4px;
for device in root.connected-devices: DeviceRow {
visible: !device.active;
height: !device.active ? 48px : 0px;
item: device;
selectable: !device.revoked && (device.online || device.is-self);
selected(id) => root.select-device(id);
}
for pairing in root.pending-pairings: Rectangle {
height: 76px; border-radius: 7px; background: #252a35;
VerticalLayout {
padding: 8px; spacing: 5px;
Text { text: pairing.name + " wants to connect"; color: #f0f1f5; font-size: 11px; font-weight: 650; overflow: elide; }
Text { text: pairing.details; color: #7f8797; font-size: 9px; overflow: elide; }
HorizontalLayout {
spacing: 7px;
Button { text: "Accept"; clicked => root.answer-pairing(pairing.request-id, true, pairing.group-conflict); }
Button { text: "Deny"; clicked => root.answer-pairing(pairing.request-id, false, false); }
}
}
}
}
}
Rectangle { height: 1px; background: #303541; }
if root.device-invite != "": Text { text: root.device-invite; color: #9ca4b4; font-size: 9px; wrap: word-wrap; max-height: 36px; overflow: elide; }
HorizontalLayout {
spacing: 8px;
device-link := LineEdit { horizontal-stretch: 1; placeholder-text: "Paste frid://i/… invite"; }
Button { text: "Connect"; enabled: !root.device-busy && device-link.text != ""; clicked => root.connect-device(device-link.text); }
}
HorizontalLayout {
Button { text: root.device-invite == "" ? "Create invite" : "New invite"; enabled: !root.device-busy; clicked => root.create-device-invite(); }
Rectangle { horizontal-stretch: 1; background: transparent; }
Text { text: root.device-status; color: root.device-status == "" ? transparent : #8c93a3; font-size: 9px; vertical-alignment: center; overflow: elide; }
}
}
}
}
}
}
}
}
if root.playlist-picker-open: Rectangle {
background: #05070ab8;
TouchArea { clicked => root.close-playlist-picker(); }
Rectangle {
width: 360px; height: min(430px, parent.height - 80px);
x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2;
border-radius: 12px; background: #1b1f28; border-width: 1px; border-color: #3a3f4d;
TouchArea { }
VerticalLayout {
padding: 18px; spacing: 12px;
HorizontalLayout {
height: 34px;
Text { horizontal-stretch: 1; text: "Add to playlist"; color: #f5f6fa; font-size: 18px; font-weight: 750; vertical-alignment: center; }
IconButton { icon-source: @image-url("../assets/close.svg"); clicked => root.close-playlist-picker(); }
}
Rectangle { height: 1px; background: #303541; }
ScrollView {
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout {
spacing: 4px;
for playlist in root.playlists: Rectangle {
visible: !playlist.is-likes;
height: !playlist.is-likes ? 48px : 0px; border-radius: 7px; background: picker-touch.has-hover ? #292e3a : transparent;
VerticalLayout {
padding-left: 10px; padding-right: 10px; alignment: center;
Text { text: playlist.title; color: #e8eaf0; font-size: 12px; font-weight: 650; overflow: elide; }
Text { text: playlist.details; color: #757c8c; font-size: 10px; }
}
picker-touch := TouchArea { clicked => root.add-track-to-playlist(playlist.id); }
}
}
}
}
}
}
if root.settings-open: Rectangle {
background: #05070ad9;
TouchArea { clicked => root.toggle-settings(); }
settings-card := Rectangle {
width: min(620px, parent.width - 48px);
height: min(650px, parent.height - 48px);
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
border-radius: 14px;
background: #171a22;
border-color: #303541;
border-width: 1px;
// Consume clicks anywhere inside the card so they do not reach the
// modal backdrop. Interactive controls below remain on top.
TouchArea { }
VerticalLayout {
padding: 24px;
spacing: 14px;
HorizontalLayout {
height: 38px;
VerticalLayout {
Text { text: "Settings"; color: #f5f6fa; font-size: 23px; font-weight: 800; }
Text { text: "Desktop player preferences"; color: #7f8595; font-size: 11px; }
}
Rectangle { horizontal-stretch: 1; background: transparent; }
IconButton { icon-source: @image-url("../assets/close.svg"); clicked => root.toggle-settings(); }
}
Rectangle { height: 1px; background: #2a2e38; }
ScrollView {
vertical-stretch: 1;
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout {
width: parent.width; spacing: 14px; alignment: start;
VerticalLayout {
spacing: 7px;
Text { text: "Network ID"; color: #e8eaf0; font-size: 13px; font-weight: 650; }
Text { text: "Peers with the same value form one logical federation network."; color: #818797; font-size: 11px; }
LineEdit {
text: root.network-id;
placeholder-text: "furumi";
edited => root.network-id-changed(self.text);
}
}
VerticalLayout {
spacing: 7px;
Text { text: "Device name"; color: #e8eaf0; font-size: 13px; font-weight: 650; }
Text { text: "Shown to your other players when connecting devices or transferring playback."; color: #818797; font-size: 11px; }
LineEdit {
text: root.device-name;
placeholder-text: "Furumi on this computer";
edited => root.device-name-changed(self.text);
}
}
VerticalLayout {
spacing: 7px;
Text { text: "Library path"; color: #e8eaf0; font-size: 13px; font-weight: 650; }
Text { text: "Music and downloaded tracks will be stored in this directory."; color: #818797; font-size: 11px; }
HorizontalLayout {
spacing: 8px;
LineEdit {
text: root.library-path;
placeholder-text: "~/Music/Furumi";
horizontal-stretch: 1;
edited => root.library-path-changed(self.text);
}
Button { text: "Choose…"; clicked => root.choose-library-path(); }
}
}
HorizontalLayout {
height: 52px;
VerticalLayout {
Text { text: "Federation"; color: #e8eaf0; font-size: 13px; font-weight: 650; }
Text { text: "Discover and play music shared by peers."; color: #818797; font-size: 11px; }
}
Rectangle { horizontal-stretch: 1; background: transparent; }
CheckBox {
text: "Enabled";
checked: root.federation-enabled;
toggled => root.federation-changed(self.checked);
}
}
Rectangle {
min-height: 104px; max-height: 104px; vertical-stretch: 0;
border-radius: 9px; background: #11151d; border-width: 1px; border-color: #2c3240;
VerticalLayout {
padding: 11px; spacing: 6px;
HorizontalLayout {
height: 18px;
Text { horizontal-stretch: 1; text: "Federation diagnostics"; color: #dfe2ea; font-size: 11px; font-weight: 700; vertical-alignment: center; }
Text { text: root.federation-debug-node; color: root.federation-enabled ? #55dbaa : #777d8d; font-size: 10px; vertical-alignment: center; }
}
HorizontalLayout {
height: 18px; spacing: 14px;
Text { width: 250px; text: "Peers " + root.federation-debug-peers; color: #8f96a6; font-size: 10px; overflow: elide; vertical-alignment: center; }
Text { horizontal-stretch: 1; text: "DHT " + root.federation-debug-dht; color: #8f96a6; font-size: 10px; overflow: elide; vertical-alignment: center; }
}
HorizontalLayout {
height: 18px; spacing: 14px;
Text { width: 250px; text: "Published " + root.federation-debug-published; color: #8f96a6; font-size: 10px; overflow: elide; vertical-alignment: center; }
Text { horizontal-stretch: 1; text: "Endpoint " + root.federation-debug-endpoint; color: #697080; font-size: 9px; overflow: elide; vertical-alignment: center; }
}
}
}
HorizontalLayout {
height: 52px;
VerticalLayout {
Text { text: "Keep federated tracks"; color: #e8eaf0; font-size: 13px; font-weight: 650; }
Text { text: "Import music played from peers and make it available to the federation."; color: #818797; font-size: 11px; }
}
Rectangle { horizontal-stretch: 1; background: transparent; }
CheckBox {
text: "Enabled";
checked: root.save-federated-on-listen;
toggled => root.save-federated-on-listen-changed(self.checked);
}
}
HorizontalLayout {
height: 52px;
VerticalLayout {
Text { text: "Language"; color: #e8eaf0; font-size: 13px; font-weight: 650; }
Text { text: "Interface language."; color: #818797; font-size: 11px; }
}
Rectangle { horizontal-stretch: 1; background: transparent; }
ComboBox {
width: 180px;
model: root.available-languages;
current-value: root.selected-language;
selected(value) => root.language-changed(value);
}
}
BuildInfoCard {
width: parent.width;
software: root.software-versions;
protocols: root.protocol-versions;
}
}
}
}
}
}
if root.track-info-open: Rectangle {
background: #05070ad9;
TouchArea { clicked => root.close-track-info(); }
info-card := Rectangle {
width: min(680px, parent.width - 48px);
height: 430px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
border-radius: 12px;
background: #171a22;
border-color: #3a3f4d;
border-width: 2px;
TouchArea { }
VerticalLayout {
padding: 22px; spacing: 16px;
HorizontalLayout {
height: 38px;
Text { text: "Track information"; color: #f5f6fa; font-size: 21px; font-weight: 800; vertical-alignment: center; }
Rectangle { horizontal-stretch: 1; background: transparent; }
IconButton { icon-source: @image-url("../assets/close.svg"); clicked => root.close-track-info(); }
}
Rectangle { height: 1px; background: #2a2e38; }
HorizontalLayout {
spacing: 22px;
Rectangle {
width: 210px; height: 210px; border-radius: 8px; background: #292d38; clip: true;
Image { source: @image-url("../assets/note.svg"); width: 56px; height: 56px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; }
if root.track-info-has-artwork: Image { source: root.track-info-artwork; image-fit: ImageFit.contain; width: parent.width; height: parent.height; }
}
VerticalLayout {
spacing: 2px;
Text { height: 34px; text: root.track-info-title; color: #f5f6fa; font-size: 20px; font-weight: 750; overflow: elide; vertical-alignment: center; }
TrackInfoArtists { artists: root.track-info-artist-links; open-artist(key) => root.open-artist(key); }
InfoField { label: "Release"; value: root.track-info-release; }
InfoField { label: "Duration"; value: root.track-info-duration; }
InfoField { label: "Format"; value: root.track-info-format; }
InfoField { label: "Audio quality"; value: root.track-info-quality; }
InfoField { label: "Source"; value: root.track-info-source; }
}
}
InfoField { label: "Content ID"; value: root.track-info-content-id; }
InfoField { label: "File / peer"; value: root.track-info-path; }
}
}
}
if root.error-message != "": Rectangle {
x: parent.width - self.width - 18px;
y: 18px;
width: 320px;
height: 62px;
border-radius: 9px;
background: #3b2028;
Text { text: root.error-message; color: #ffd9e0; font-size: 12px; x: 14px; width: parent.width - 48px; vertical-alignment: center; wrap: word-wrap; }
Text { text: "×"; color: #ffd9e0; x: parent.width - 30px; width: 20px; horizontal-alignment: center; vertical-alignment: center; }
TouchArea { clicked => root.dismiss-error(); }
}
artwork-popup := PopupWindow {
x: (root.width - self.width) / 2;
y: (root.height - self.height) / 2;
width: 560px;
height: 560px;
close-policy: close-on-click-outside;
Rectangle {
width: parent.width; height: parent.height;
border-radius: 8px;
background: #171a22;
border-color: #3a3f4d;
border-width: 2px;
clip: true;
Image { x: 8px; y: 8px; source: root.current-artwork; width: parent.width - 16px; height: parent.height - 16px; image-fit: ImageFit.contain; }
}
}
}
+702
View File
@@ -0,0 +1,702 @@
import { ScrollView } from "std-widgets.slint";
import { ArtistLinkLineView, ArtistLinkView, ArtistView, DeviceView, PlaylistView, ReleaseView, TrackView, VersionView } from "models.slint";
export component IconButton inherits Rectangle {
in property <image> icon-source;
in property <brush> icon-color: transparent;
in property <string> tooltip;
in property <bool> enabled: true;
callback clicked;
width: 38px;
height: 38px;
border-radius: 19px;
opacity: root.enabled ? 1 : 0.32;
background: root.enabled && touch.has-hover ? #282c38 : transparent;
Image { source: root.icon-source; colorize: root.icon-color; width: 19px; height: 19px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; }
touch := TouchArea { enabled: root.enabled; clicked => root.clicked(); }
}
export component DeviceRow inherits Rectangle {
in property <DeviceView> item;
in property <bool> selectable: false;
callback selected(string);
height: 48px;
border-radius: 7px;
opacity: item.revoked ? 0.45 : 1;
background: item.active ? #24483d : selectable && touch.has-hover ? #292e3a : transparent;
HorizontalLayout {
padding-left: 10px; padding-right: 10px; spacing: 10px;
Rectangle {
width: 8px; background: transparent;
Rectangle {
width: 8px; height: 8px; border-radius: 4px;
y: (parent.height - self.height) / 2;
background: root.item.online ? #55dbaa : #555c6c;
}
}
VerticalLayout {
horizontal-stretch: 1; alignment: center;
Text { text: root.item.name + (root.item.is-self ? " · this device" : ""); color: #e8eaf0; font-size: 12px; font-weight: 650; overflow: elide; }
Text { text: root.item.details; color: #757c8c; font-size: 10px; overflow: elide; }
}
if root.item.active: Text { text: "ACTIVE"; color: #55dbaa; font-size: 9px; font-weight: 750; vertical-alignment: center; }
}
touch := TouchArea { enabled: root.selectable; clicked => root.selected(root.item.id); }
}
export component FederationBadge inherits Rectangle {
in property <bool> partial: false;
width: 24px;
height: 18px;
border-radius: 5px;
background: root.partial ? @linear-gradient(90deg, #4bd4a1 0%, #4bd4a1 50%, #e1b84b 50%, #e1b84b 100%) : #4bd4a1;
Image {
source: @image-url("../assets/federation.svg");
width: 14px;
height: 14px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
}
export component SearchField inherits Rectangle {
in-out property <string> text <=> input.text;
in property <string> placeholder-text;
callback edited(string);
forward-focus: input;
height: 38px;
border-radius: 19px;
background: #1b1e27;
border-width: 1px;
border-color: input.has-focus ? #697182 : #1b1e27;
clip: true;
TouchArea { clicked => input.focus(); }
Image {
source: @image-url("../assets/search.svg");
x: 14px;
y: (parent.height - self.height) / 2;
width: 17px;
height: 17px;
}
input-viewport := Rectangle {
x: 41px;
y: 1px;
width: parent.width - 54px;
height: parent.height - 2px;
background: transparent;
clip: true;
if input.text == "": Text {
text: root.placeholder-text;
color: #858c9c;
font-size: 12px;
vertical-alignment: center;
overflow: elide;
}
input := TextInput {
private property <length> cursor-margin: 2px;
width: max(parent.width, self.preferred-width);
height: parent.height;
color: #e8eaf0;
font-size: 12px;
vertical-alignment: center;
single-line: true;
input-type: search;
selection-background-color: #52617a;
selection-foreground-color: #ffffff;
edited => root.edited(self.text);
cursor-position-changed(cursor-position) => {
if cursor-position.x + self.x < cursor-margin {
self.x = -cursor-position.x + cursor-margin;
} else if cursor-position.x + self.x > parent.width - cursor-margin - self.text-cursor-width {
self.x = parent.width - cursor-position.x - cursor-margin - self.text-cursor-width;
}
}
}
}
}
export component NavButton inherits Rectangle {
in property <string> label;
in property <image> icon-source;
in property <bool> active;
callback clicked;
height: 44px;
border-radius: 8px;
background: root.active ? #252936 : touch.has-hover ? #1a1d27 : transparent;
HorizontalLayout {
padding-left: 13px;
padding-right: 13px;
spacing: 12px;
Rectangle {
width: 19px;
background: transparent;
Image { source: root.icon-source; width: 19px; height: 19px; y: (parent.height - self.height) / 2; }
}
Text { text: root.label; color: root.active ? #ffffff : #b5bac8; font-size: 14px; font-weight: root.active ? 700 : 500; vertical-alignment: center; }
}
touch := TouchArea { clicked => root.clicked(); }
}
export component PlaylistNavButton inherits Rectangle {
in property <PlaylistView> item;
in property <bool> active;
callback clicked(string);
height: 34px;
border-radius: 6px;
background: root.active ? #252936 : touch.has-hover ? #1a1d27 : transparent;
HorizontalLayout {
padding-left: 10px; padding-right: 8px; spacing: 9px;
Image {
source: root.item.is-likes ? @image-url("../assets/heart.svg") : @image-url("../assets/playlist-add.svg");
width: 15px; height: 15px;
}
Text { horizontal-stretch: 1; text: root.item.title; color: root.active ? #f5f6fa : #a4a9b7; font-size: 12px; overflow: elide; vertical-alignment: center; }
}
touch := TouchArea { clicked => root.clicked(root.item.id); }
}
export component MarqueeText inherits Rectangle {
in property <string> label;
in property <color> text-color: #858b9c;
in property <length> text-size: 11px;
in property <int> text-weight: 400;
private property <length> travel: max(0px, marquee-label.preferred-width - root.width);
private property <bool> scrolling: root.travel > 1px;
// Calling animation-tick() for text that already fits keeps the whole
// window repainting for no visual reason.
private property <duration> phase: root.scrolling ? mod(animation-tick(), 10s) : 0ms;
clip: true;
background: transparent;
marquee-label := Text {
x: !root.scrolling || root.phase < 1500ms ? 0px
: root.phase < 4500ms ? -root.travel * ((root.phase - 1500ms) / 3000ms)
: root.phase < 6000ms ? -root.travel
: root.phase < 9000ms ? -root.travel * (1 - ((root.phase - 6000ms) / 3000ms))
: 0px;
y: 0px;
width: self.preferred-width;
height: root.height;
text: root.label;
color: root.text-color;
font-size: root.text-size;
font-weight: root.text-weight;
vertical-alignment: center;
horizontal-alignment: left;
}
}
export component LinkText inherits Rectangle {
in property <string> label;
in property <color> base-color: #858b9c;
in property <length> text-size: 11px;
in property <int> text-weight: 400;
callback clicked;
height: 17px;
horizontal-stretch: 1;
background: transparent;
MarqueeText {
x: 0px; y: 0px; width: parent.width; height: parent.height;
label: root.label;
text-color: touch.has-hover ? #f1f2f6 : root.base-color;
text-size: root.text-size;
text-weight: root.text-weight;
}
touch := TouchArea { clicked => root.clicked(); }
}
export component InlineArtistLink inherits Text {
in property <string> key;
in property <color> base-color: #858b9c;
callback clicked(string);
color: touch.has-hover ? #f1f2f6 : root.base-color;
font-size: 11px;
vertical-alignment: center;
touch := TouchArea { clicked => root.clicked(root.key); }
}
export component ArtistLinksRow inherits Rectangle {
in property <[ArtistLinkView]> artists;
in property <string> leading-label;
in property <color> row-color: #858b9c;
in property <length> text-size: 11px;
in property <int> start-index: 0;
in property <int> end-index: artists.length;
in property <bool> animate-overflow: true;
callback open-artist(string);
height: 18px;
background: transparent;
clip: true;
private property <length> travel: max(0px, links.preferred-width - root.width);
private property <duration> phase: root.animate-overflow && root.travel > 1px ? mod(animation-tick(), 10s) : 0ms;
links := HorizontalLayout {
x: !root.animate-overflow || root.travel <= 1px || root.phase < 1500ms ? 0px
: root.phase < 4500ms ? -root.travel * ((root.phase - 1500ms) / 3000ms)
: root.phase < 6000ms ? -root.travel
: root.phase < 9000ms ? -root.travel * (1 - ((root.phase - 6000ms) / 3000ms))
: 0px;
y: 0px; width: max(root.width, self.preferred-width); height: root.height;
spacing: 0px; alignment: start;
if root.leading-label != "": Text { text: root.leading-label; color: root.row-color; font-size: root.text-size; vertical-alignment: center; }
for artist[index] in root.artists: HorizontalLayout {
visible: index >= root.start-index && index < root.end-index;
spacing: 0px;
if artist.prefix != "": Text {
text: index == root.start-index && artist.prefix == ", " ? ""
: index == root.start-index && artist.prefix == " feat. " ? "feat. "
: artist.prefix;
color: root.row-color; font-size: root.text-size; vertical-alignment: center;
}
InlineArtistLink {
key: artist.key; text: artist.name; base-color: root.row-color;
font-size: root.text-size; clicked(key) => root.open-artist(key);
}
}
}
}
export component TrackArtistsCell inherits Rectangle {
in property <[ArtistLinkView]> artists;
in property <string> label;
in property <int> line-break;
callback open-artist(string);
clip: true;
background: transparent;
private property <bool> use-two-lines: root.line-break > 0 && measure.preferred-width > root.width * 1.85;
measure := Text { visible: false; text: root.label; font-size: 11px; }
if !root.use-two-lines: ArtistLinksRow {
width: parent.width; height: parent.height;
artists: root.artists;
open-artist(key) => root.open-artist(key);
}
if root.use-two-lines: Rectangle {
x: 0px; y: 4px; width: parent.width; height: parent.height - 8px;
background: transparent; clip: true;
ArtistLinksRow {
x: 0px; y: 0px; width: parent.width; height: parent.height / 2;
artists: root.artists; end-index: root.line-break; animate-overflow: false;
open-artist(key) => root.open-artist(key);
}
ArtistLinksRow {
x: 0px; y: parent.height / 2; width: parent.width; height: parent.height / 2;
artists: root.artists; start-index: root.line-break; animate-overflow: false;
open-artist(key) => root.open-artist(key);
}
}
}
export component StaticArtistList inherits Rectangle {
in property <[ArtistLinkLineView]> lines;
in property <string> heading;
callback open-artist(string);
height: (heading == "" ? 0px : 20px) + lines.length * 19px;
background: transparent;
if root.heading != "": Text {
x: 0px; y: 0px; width: parent.width; height: 18px;
text: root.heading; color: #707687; font-size: 11px; vertical-alignment: center;
}
for line[index] in root.lines: Rectangle {
x: 0px;
y: (root.heading == "" ? 0px : 20px) + index * 19px;
width: parent.width;
height: 19px;
background: transparent;
HorizontalLayout {
spacing: 0px; alignment: start;
for artist in line.artists: HorizontalLayout {
spacing: 0px;
if artist.prefix != "": Text {
text: artist.prefix; color: #858b9c; font-size: 11px; vertical-alignment: center;
}
InlineArtistLink {
key: artist.key; text: artist.name; base-color: #858b9c;
clicked(key) => root.open-artist(key);
}
}
}
}
}
export component TrackInfoArtists inherits Rectangle {
in property <[ArtistLinkView]> artists;
callback open-artist(string);
height: min(62px, max(28px, artists.length * 20px));
background: transparent;
Text { x: 0px; width: 112px; height: 28px; text: "Artists"; color: #707687; font-size: 11px; vertical-alignment: center; }
ScrollView {
x: 124px; width: parent.width - 124px; height: parent.height;
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout {
spacing: 0px;
for artist in root.artists: Rectangle {
height: 20px; background: transparent;
Text {
width: parent.width; height: parent.height;
text: artist.name;
color: artist-touch.has-hover ? #f1f2f6 : #dfe2ea;
font-size: 11px; overflow: elide; vertical-alignment: center;
}
artist-touch := TouchArea { clicked => root.open-artist(artist.key); }
}
}
}
}
export component VersionRows inherits Rectangle {
in property <[VersionView]> items;
height: ceil(items.length / 2) * 21px;
background: transparent;
for item[index] in items: Rectangle {
x: mod(index, 2) * parent.width / 2;
y: floor(index / 2) * 21px;
width: parent.width / 2 - (mod(index, 2) == 0 ? 8px : 0px);
height: 21px;
background: transparent;
Text {
x: 0px; width: parent.width - 82px; height: parent.height;
text: item.name; color: #8f96a6; font-size: 10px;
overflow: elide; vertical-alignment: center;
}
Text {
x: parent.width - 78px; width: 78px; height: parent.height;
text: "v" + item.version; color: #d7dae3; font-size: 10px;
font-weight: 650; overflow: elide; horizontal-alignment: right;
vertical-alignment: center;
}
}
}
export component BuildInfoCard inherits Rectangle {
in property <[VersionView]> software;
in property <[VersionView]> protocols;
min-height: 82px + ceil(software.length / 2) * 21px + ceil(protocols.length / 2) * 21px;
max-height: 82px + ceil(software.length / 2) * 21px + ceil(protocols.length / 2) * 21px;
vertical-stretch: 0;
border-radius: 9px; background: #11151d; border-width: 1px; border-color: #2c3240;
VerticalLayout {
padding: 11px; spacing: 5px;
Text { height: 18px; text: "Build and compatibility"; color: #dfe2ea; font-size: 11px; font-weight: 700; vertical-alignment: center; }
Text { height: 14px; text: "SOFTWARE"; color: #697080; font-size: 9px; font-weight: 750; letter-spacing: 0.7px; vertical-alignment: center; }
VersionRows { items: root.software; }
Text { height: 14px; text: "PROTOCOLS"; color: #697080; font-size: 9px; font-weight: 750; letter-spacing: 0.7px; vertical-alignment: center; }
VersionRows { items: root.protocols; }
}
}
export component AlbumTile inherits Rectangle {
in property <ReleaseView> item;
in property <bool> show-artist: true;
in property <bool> show-release-type: false;
callback play(string);
callback open(string);
callback open-artist(string);
private property <bool> hovered: touch.has-hover || play-touch.has-hover;
width: 154px;
height: 216px;
border-radius: 10px;
background: root.hovered ? #20242e : #171a22;
touch := TouchArea { clicked => root.open(item.key); }
VerticalLayout {
padding: 11px;
spacing: 9px;
cover := Rectangle {
height: 132px;
border-radius: 7px;
clip: true;
background: #292d38;
Image { source: @image-url("../assets/note.svg"); width: 44px; height: 44px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; }
if item.has-artwork: Image { source: item.artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; }
if item.federation-state > 0: FederationBadge { partial: item.federation-state == 2; x: parent.width - self.width - 4px; y: 4px; }
Rectangle {
x: 92px; y: 80px; width: 38px; height: 38px;
border-radius: 19px;
background: play-touch.has-hover ? #62e6b4 : #4bd4a1;
opacity: root.hovered ? 1 : 0;
Image { source: @image-url("../assets/play.svg"); width: 18px; height: 18px; x: 10px; y: 10px; }
play-touch := TouchArea { clicked => root.play(item.key); }
}
}
Text { text: item.title; color: #f4f5f8; font-size: 14px; font-weight: 700; overflow: elide; }
if root.show-artist: LinkText { label: item.artist + " · " + item.year; text-size: 12px; clicked => root.open-artist(item.artist-key); }
if !root.show-artist: Text {
height: 17px;
text: item.year + (root.show-release-type && item.release-type != "" ? " · " + item.release-type : "");
color: #858b9c; font-size: 12px; overflow: elide; vertical-alignment: center;
}
}
}
export component ArtistTile inherits Rectangle {
in property <ArtistView> item;
callback open(string);
width: 154px;
height: 210px;
border-radius: 10px;
background: touch.has-hover ? #20242e : #171a22;
VerticalLayout {
padding: 11px;
spacing: 9px;
Rectangle {
height: 132px;
border-radius: 66px;
clip: true;
background: #292d38;
Image { source: @image-url("../assets/note.svg"); width: 42px; height: 42px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; }
if item.has-artwork: Image { source: item.artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; }
}
Text { text: item.name; color: #f4f5f8; font-size: 14px; font-weight: 700; overflow: elide; }
Text { text: item.details; color: #858b9c; font-size: 11px; overflow: elide; }
}
touch := TouchArea { clicked => root.open(item.key); }
}
export component SearchArtistTile inherits Rectangle {
in property <ArtistView> item;
callback open(string);
width: 120px; height: 150px; border-radius: 8px;
background: touch.has-hover ? #20242e : #171a22;
VerticalLayout {
padding: 9px; spacing: 7px;
Rectangle {
height: 102px; border-radius: 10px; background: #292d38; clip: true;
Image { source: @image-url("../assets/note.svg"); width: 32px; height: 32px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; }
if item.has-artwork: Image { source: item.artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; }
if item.federated: FederationBadge { x: parent.width - self.width - 4px; y: 4px; }
}
Text { text: item.name; color: #f4f5f8; font-size: 12px; font-weight: 700; overflow: elide; }
}
touch := TouchArea { clicked => root.open(item.key); }
}
export component SearchReleaseTile inherits Rectangle {
in property <ReleaseView> item;
callback open(string);
width: 120px; height: 150px; border-radius: 8px;
background: touch.has-hover ? #20242e : #171a22;
VerticalLayout {
padding: 9px; spacing: 7px;
Rectangle {
height: 102px; border-radius: 5px; background: #292d38; clip: true;
Image { source: @image-url("../assets/note.svg"); width: 32px; height: 32px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; }
if item.has-artwork: Image { source: item.artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; }
if item.federation-state > 0: FederationBadge { partial: item.federation-state == 2; x: parent.width - self.width - 4px; y: 4px; }
}
Text { text: item.title; color: #f4f5f8; font-size: 12px; font-weight: 700; overflow: elide; }
}
touch := TouchArea { clicked => root.open(item.key); }
}
export component SearchArtistGrid inherits Rectangle {
in property <[ArtistView]> artists;
callback open(string);
property <int> columns: max(1, floor((self.width + 12px) / 132px));
height: artists.length > 0 ? ceil(artists.length / columns) * 162px - 12px : 0px;
for artist[index] in artists: SearchArtistTile { x: mod(index, root.columns) * 132px; y: floor(index / root.columns) * 162px; item: artist; open(key) => root.open(key); }
}
export component SearchReleaseGrid inherits Rectangle {
in property <[ReleaseView]> releases;
callback open(string);
property <int> columns: max(1, floor((self.width + 12px) / 132px));
height: releases.length > 0 ? ceil(releases.length / columns) * 162px - 12px : 0px;
for release[index] in releases: SearchReleaseTile { x: mod(index, root.columns) * 132px; y: floor(index / root.columns) * 162px; item: release; open(key) => root.open(key); }
}
export component TrackActionButton inherits Rectangle {
in property <image> icon-source;
in property <bool> active: false;
callback clicked;
width: 28px;
height: 28px;
border-radius: 6px;
background: root.active ? #255845 : touch.has-hover ? #353a47 : transparent;
Image { source: root.icon-source; width: 16px; height: 16px; x: 6px; y: 6px; }
touch := TouchArea { clicked => root.clicked(); }
}
export component TrackMenuItem inherits Rectangle {
in property <image> icon-source;
in property <string> label;
callback clicked;
height: 34px;
border-radius: 5px;
background: touch.has-hover ? #303541 : transparent;
HorizontalLayout {
padding-left: 9px; padding-right: 9px; spacing: 9px;
Rectangle {
width: 16px; background: transparent;
Image { source: root.icon-source; width: 16px; height: 16px; y: (parent.height - self.height) / 2; }
}
Text { text: root.label; color: #d9dce5; font-size: 11px; vertical-alignment: center; }
}
touch := TouchArea { clicked => root.clicked(); }
}
export component InfoField inherits Rectangle {
in property <string> label;
in property <string> value;
height: 28px;
background: transparent;
HorizontalLayout {
spacing: 12px;
Text { width: 112px; text: root.label; color: #707687; font-size: 11px; vertical-alignment: center; }
Text { horizontal-stretch: 1; text: root.value == "" ? "—" : root.value; color: #dfe2ea; font-size: 11px; overflow: elide; vertical-alignment: center; }
}
}
export component TrackRow inherits Rectangle {
in property <TrackView> item;
in property <bool> show-artwork: false;
callback play(string);
callback action(string, string);
callback open-artist(string);
callback open-release(string);
height: 48px;
border-radius: 6px;
background: item.active ? #282d3a : touch.has-hover ? #20242e : transparent;
touch := TouchArea { double-clicked => root.play(item.key); }
HorizontalLayout {
padding-left: 12px;
padding-right: 12px;
spacing: 12px;
Text { text: item.number; color: #777d8d; width: 24px; vertical-alignment: center; horizontal-alignment: center; }
if root.show-artwork: Rectangle {
width: 34px; height: 34px; border-radius: 4px; background: #292d38; clip: true;
Image { source: @image-url("../assets/note.svg"); width: 18px; height: 18px; x: 8px; y: 8px; }
if item.has-artwork: Image { source: item.artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; }
}
Rectangle {
horizontal-stretch: 1;
height: parent.height;
background: transparent;
clip: true;
Rectangle {
x: 0px; width: parent.width * 0.40; height: parent.height; background: transparent; clip: true;
Text {
x: 0px; y: 0px;
// Keep identical text geometry for local and federated
// rows. The badge is an overlay at the trailing edge.
width: parent.width - 34px; height: parent.height;
text: item.title;
color: item.active ? #55dbaa : #f1f2f6;
font-size: 13px; font-weight: 600; overflow: elide;
horizontal-alignment: left;
vertical-alignment: center;
}
if item.federated: FederationBadge {
x: parent.width - self.width - 8px;
y: (parent.height - self.height) / 2;
}
}
Rectangle {
x: parent.width * 0.40; width: parent.width * 0.30; height: parent.height; background: transparent; clip: true;
TrackArtistsCell {
x: 0px; y: 0px; width: parent.width - 12px; height: parent.height;
artists: item.artists; label: item.artists-label;
line-break: item.artist-line-break;
open-artist(key) => root.open-artist(key);
}
}
Rectangle {
x: parent.width * 0.70; width: parent.width * 0.30; height: parent.height; background: transparent; clip: true;
LinkText { x: 0px; y: 0px; width: parent.width - 8px; height: parent.height; label: item.release; base-color: #949aaa; text-size: 12px; clicked => root.open-release(item.release-key); }
}
}
Text { text: item.duration; color: #949aaa; width: 46px; font-size: 12px; horizontal-alignment: right; vertical-alignment: center; }
Rectangle {
width: 124px;
background: transparent;
TrackActionButton { x: 0px; y: (parent.height - self.height) / 2; active: root.item.liked; icon-source: @image-url("../assets/heart.svg"); clicked => root.action("like", root.item.key); }
TrackActionButton { x: 32px; y: (parent.height - self.height) / 2; icon-source: @image-url("../assets/play-next.svg"); clicked => root.action("play-next", root.item.key); }
TrackActionButton { x: 64px; y: (parent.height - self.height) / 2; icon-source: @image-url("../assets/add-end.svg"); clicked => root.action("add-end", root.item.key); }
TrackActionButton { x: 96px; y: (parent.height - self.height) / 2; icon-source: @image-url("../assets/more.svg"); clicked => { menu.show(); } }
}
}
menu := PopupWindow {
x: root.width - self.width - 8px;
y: 42px;
width: 184px;
height: 118px;
close-policy: close-on-click-outside;
Rectangle {
border-radius: 8px;
background: #222631;
border-color: #3a3f4d;
border-width: 1px;
drop-shadow-color: #00000080;
drop-shadow-blur: 14px;
drop-shadow-offset-y: 4px;
VerticalLayout {
padding: 6px; spacing: 1px;
TrackMenuItem { icon-source: @image-url("../assets/info.svg"); label: "Track information"; clicked => { menu.close(); root.action("information", root.item.key); } }
TrackMenuItem { icon-source: @image-url("../assets/share.svg"); label: "Share"; clicked => { menu.close(); root.action("share", root.item.key); } }
TrackMenuItem { icon-source: @image-url("../assets/playlist-add.svg"); label: "Add to playlist"; clicked => { menu.close(); root.action("playlist", root.item.key); } }
}
}
}
}
export component TrackList inherits Rectangle {
in property <[TrackView]> tracks;
in property <bool> show-artwork: false;
callback play(string);
callback action(string, string);
callback open-artist(string);
callback open-release(string);
property <length> content-height: tracks.length > 0 ? tracks.length * 50px - 2px : 0px;
min-height: content-height;
preferred-height: content-height;
max-height: content-height;
vertical-stretch: 0;
background: transparent;
for track[index] in tracks: TrackRow {
x: 0px; y: index * 50px; width: root.width;
item: track;
show-artwork: root.show-artwork;
play(key) => root.play(key);
action(action, key) => root.action(action, key);
open-artist(key) => root.open-artist(key);
open-release(key) => root.open-release(key);
}
}
export component AlbumGrid inherits Rectangle {
in property <[ReleaseView]> releases;
in property <bool> show-artists: true;
in property <bool> show-release-types: false;
callback play(string);
callback open(string);
callback open-artist(string);
property <int> fitting-column-count: max(1, floor((self.width + 13px) / 167px));
property <int> column-count: releases.length > 0 && releases.length < fitting-column-count ? releases.length : fitting-column-count;
property <length> column-gap: column-count > 1 ? min(25px, max(13px, (self.width - column-count * 154px) / (column-count - 1))) : 0px;
height: releases.length > 0 ? ceil(releases.length / column-count) * 229px - 13px : 0px;
background: transparent;
for release[index] in releases: AlbumTile {
x: mod(index, root.column-count) * (154px + root.column-gap);
y: floor(index / root.column-count) * 229px;
item: release;
show-artist: root.show-artists;
show-release-type: root.show-release-types;
play(key) => root.play(key);
open(key) => root.open(key);
open-artist(key) => root.open-artist(key);
}
}
export component ArtistGrid inherits Rectangle {
in property <[ArtistView]> artists;
callback open(string);
property <int> fitting-column-count: max(1, floor((self.width + 13px) / 167px));
property <int> column-count: artists.length > 0 && artists.length < fitting-column-count ? artists.length : fitting-column-count;
property <length> column-gap: column-count > 1 ? min(25px, max(13px, (self.width - column-count * 154px) / (column-count - 1))) : 0px;
height: artists.length > 0 ? ceil(artists.length / column-count) * 223px - 13px : 0px;
background: transparent;
for artist[index] in artists: ArtistTile {
x: mod(index, root.column-count) * (154px + root.column-gap);
y: floor(index / root.column-count) * 223px;
item: artist;
open(key) => root.open(key);
}
}
+90
View File
@@ -0,0 +1,90 @@
export struct ReleaseView {
key: string,
title: string,
artist: string,
artist-key: string,
year: string,
release-type: string,
artwork: image,
has-artwork: bool,
federation-state: int,
}
export struct BreadcrumbView { label: string, target: string }
export struct ArtistView {
key: string,
name: string,
details: string,
artwork: image,
has-artwork: bool,
federated: bool,
}
export struct ArtistLinkView {
key: string,
name: string,
prefix: string,
}
export struct ArtistLinkLineView {
artists: [ArtistLinkView],
}
export struct VersionView {
name: string,
version: string,
}
export struct TrackView {
key: string,
number: string,
title: string,
artists: [ArtistLinkView],
artists-label: string,
artist-line-break: int,
release: string,
release-key: string,
duration: string,
active: bool,
artwork: image,
has-artwork: bool,
federated: bool,
liked: bool,
}
export struct QueueView {
key: string,
title: string,
artist: string,
artist-key: string,
release: string,
release-key: string,
active: bool,
artwork: image,
has-artwork: bool,
}
export struct PlaylistView {
id: string,
title: string,
details: string,
is-likes: bool,
}
export struct DeviceView {
id: string,
name: string,
details: string,
is-self: bool,
online: bool,
active: bool,
revoked: bool,
}
export struct PairingView {
request-id: string,
name: string,
details: string,
group-conflict: bool,
}