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
+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(),
})]
);
}
}