2026-07-25 23:24:49 +03:00
use std ::collections ::{ BTreeMap , HashMap , HashSet };
2026-06-10 16:11:09 +01:00
use std ::sync ::Arc ;
2026-07-17 01:17:54 +03:00
use crate ::app ::input ::LineEdit ;
2026-06-10 16:11:09 +01:00
use crate ::art ::ArtImage ;
use crate ::config ::keymap ::KeyContext ;
2026-07-16 20:29:51 +03:00
use crate ::library ::models ::{
2026-07-25 23:57:03 +03:00
ArtistCard , ArtistDetail , Availability , PlaylistCard , PlaylistDetail , ReleaseDetail ,
2026-07-16 20:29:51 +03:00
SearchResults , TrackItem ,
};
2026-06-10 16:11:09 +01:00
/// Remote data that a view renders: spinner, content, or error.
#[derive(Debug)]
pub enum Loadable < T > {
Loading ,
Ready ( T ),
Failed ( String ),
}
2026-07-23 19:43:47 +03:00
/// Tile geometry for the Library artist grid (kept here so selection math in
2026-06-10 16:11:09 +01:00
/// update() and rendering in ui::global agree). Width × height in cells,
/// including the tile border; the art area inside is 18× 8 cells = 18× 16 px.
pub const TILE_WIDTH : u16 = 20 ;
pub const TILE_HEIGHT : u16 = 12 ;
pub const ART_CELL_WIDTH : u16 = 18 ;
pub const ART_CELL_HEIGHT : u16 = 8 ;
/// Header artwork (artist page, release page): 24× 12 cells = 24× 24 px.
pub const ART_HEADER_WIDTH : u16 = 24 ;
pub const ART_HEADER_HEIGHT : u16 = 12 ;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ViewMode {
#[default]
Tiles ,
Table ,
}
impl ViewMode {
pub fn toggle ( self ) -> ViewMode {
match self {
ViewMode ::Tiles => ViewMode ::Table ,
ViewMode ::Table => ViewMode ::Tiles ,
}
}
}
/// Artist image in the shared art cache.
#[derive(Debug, Clone)]
pub enum ArtState {
Loading ,
Ready ( Arc < ArtImage > ),
Failed ,
}
2026-07-23 19:43:47 +03:00
/// A drill-down view pushed on top of the Library artist grid. Cursors live
2026-06-10 16:11:09 +01:00
/// in the stack entry so going Back restores the previous position.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GlobalView {
/// Linear cursor over top tracks (0..tracks) then releases in display
/// order (tracks..tracks+releases).
2026-06-10 23:30:03 +01:00
Artist {
id : i64 ,
cursor : usize ,
},
Release {
id : i64 ,
cursor : usize ,
},
2026-06-10 16:11:09 +01:00
/// Linear cursor over search results: artists, then releases, then tracks.
2026-06-10 23:30:03 +01:00
Search {
cursor : usize ,
},
2026-07-17 01:17:54 +03:00
/// A federated artist card (data lives in `AppState::fed_artist_view`).
FedArtist {
cursor : usize ,
},
2026-07-17 01:43:09 +03:00
/// One release of the open federated card: row 0 is the
/// download-release button, rows 1..=n are its tracks.
FedRelease {
index : usize ,
cursor : usize ,
},
2026-06-10 16:11:09 +01:00
}
2026-07-23 19:43:47 +03:00
/// The Library tab: the whole server library of artists.
2026-06-10 16:11:09 +01:00
#[derive(Debug)]
pub struct GlobalTab {
pub artists : Vec < ArtistCard > ,
pub total : i64 ,
pub has_more : bool ,
pub next_page : i64 ,
pub loading : bool ,
pub error : Option < String > ,
pub selected : usize ,
pub view : ViewMode ,
2026-07-23 19:43:47 +03:00
pub filters : crate ::config ::settings ::LibraryFilters ,
2026-06-10 16:11:09 +01:00
pub stack : Vec < GlobalView > ,
2026-07-16 20:29:51 +03:00
/// Page size, fixed at the first request — the offset is
2026-06-10 16:52:09 +01:00
/// `(page-1) * limit`, so it must not change between pages.
pub page_limit : Option < i64 > ,
2026-07-16 20:29:51 +03:00
/// A full atomic reload is in flight (after a library change); incoming
/// pages of the old pagination are dropped until it lands.
pub reloading : bool ,
2026-06-10 16:11:09 +01:00
}
impl Default for GlobalTab {
fn default () -> Self {
Self {
artists : Vec ::new (),
total : 0 ,
has_more : true ,
next_page : 1 ,
loading : false ,
error : None ,
selected : 0 ,
view : ViewMode ::default (),
2026-07-23 19:43:47 +03:00
filters : crate ::config ::settings ::LibraryFilters ::default (),
2026-06-10 16:11:09 +01:00
stack : Vec ::new (),
2026-06-10 16:52:09 +01:00
page_limit : None ,
2026-07-16 20:29:51 +03:00
reloading : false ,
2026-06-10 16:11:09 +01:00
}
}
}
2026-07-21 00:12:45 +03:00
fn release_type_groups < T > (
items : & [ T ],
release_type : impl Fn ( & T ) -> & str ,
2026-07-23 14:23:22 +03:00
release_year : impl Fn ( & T ) -> Option < i32 > ,
release_title : impl Fn ( & T ) -> & str ,
2026-07-21 00:12:45 +03:00
) -> Vec < ( & 'static str , Vec < usize > ) > {
2026-06-10 16:11:09 +01:00
const GROUPS : [( & str , & str ); 4 ] = [
( "album" , "Albums" ),
( "single" , "Singles" ),
2026-07-21 00:12:45 +03:00
( "ep" , "EPs" ),
2026-06-10 16:11:09 +01:00
( "compilation" , "Compilations" ),
];
let mut groups : Vec < ( & 'static str , Vec < usize > ) > = Vec ::new ();
for ( kind , label ) in GROUPS {
2026-07-23 14:23:22 +03:00
let mut indices : Vec < usize > = items
2026-06-10 16:11:09 +01:00
. iter ()
. enumerate ()
2026-07-21 00:12:45 +03:00
. filter ( | ( _ , item ) | release_type ( item ). eq_ignore_ascii_case ( kind ))
2026-06-10 16:11:09 +01:00
. map ( | ( i , _ ) | i )
. collect ();
2026-07-23 14:23:22 +03:00
sort_release_indices ( & mut indices , items , & release_year , & release_title );
2026-06-10 16:11:09 +01:00
if ! indices . is_empty () {
groups . push (( label , indices ));
}
}
let known : Vec < usize > = groups . iter (). flat_map ( | ( _ , v ) | v . iter (). copied ()). collect ();
2026-07-23 14:23:22 +03:00
let mut other : Vec < usize > = ( 0 .. items . len ()). filter ( | i | ! known . contains ( i )). collect ();
sort_release_indices ( & mut other , items , & release_year , & release_title );
2026-06-10 16:11:09 +01:00
if ! other . is_empty () {
groups . push (( "Other" , other ));
}
groups
}
2026-07-23 14:23:22 +03:00
fn sort_release_indices < T > (
indices : & mut [ usize ],
items : & [ T ],
release_year : & impl Fn ( & T ) -> Option < i32 > ,
release_title : & impl Fn ( & T ) -> & str ,
) {
indices . sort_by ( |& left , & right | {
release_year ( & items [ right ])
. unwrap_or ( i32 ::MIN )
. cmp ( & release_year ( & items [ left ]). unwrap_or ( i32 ::MIN ))
. then_with ( || release_title ( & items [ left ]). cmp ( release_title ( & items [ right ])))
});
}
2026-07-21 00:12:45 +03:00
pub fn fed_release_groups (
releases : & [ crate ::federation ::FedRelease ],
) -> Vec < ( & 'static str , Vec < usize > ) > {
2026-07-23 14:23:22 +03:00
release_type_groups (
releases ,
| release | & release . release_type ,
| release | release . year ,
| release | & release . title ,
)
2026-07-21 00:12:45 +03:00
}
/// Flattened display order of federated releases (concatenated groups).
pub fn fed_release_display_order ( releases : & [ crate ::federation ::FedRelease ]) -> Vec < usize > {
fed_release_groups ( releases )
. into_iter ()
. flat_map ( | ( _ , indices ) | indices )
. collect ()
}
/// Visual tile-grid rows of the federated releases section.
pub fn fed_release_rows (
releases : & [ crate ::federation ::FedRelease ],
columns : usize ,
) -> Vec < Vec < usize >> {
grouped_release_rows ( fed_release_groups ( releases ), columns )
}
2026-07-25 23:57:03 +03:00
#[derive(Debug, Clone)]
pub struct ArtistReleaseSlot {
pub title : String ,
pub release_type : String ,
pub year : Option < i32 > ,
pub cover_path : Option < String > ,
pub local_index : Option < usize > ,
pub fed_index : Option < usize > ,
pub local_track_count : usize ,
pub total_track_count : usize ,
pub availability : Availability ,
}
pub fn artist_release_groups ( releases : & [ ArtistReleaseSlot ]) -> Vec < ( & 'static str , Vec < usize > ) > {
release_type_groups (
releases ,
| release | & release . release_type ,
| release | release . year ,
| release | & release . title ,
)
}
pub fn artist_release_display_order ( releases : & [ ArtistReleaseSlot ]) -> Vec < usize > {
artist_release_groups ( releases )
. into_iter ()
. flat_map ( | ( _ , indices ) | indices )
. collect ()
}
pub fn artist_release_rows ( releases : & [ ArtistReleaseSlot ], columns : usize ) -> Vec < Vec < usize >> {
grouped_release_rows ( artist_release_groups ( releases ), columns )
}
2026-07-21 00:12:45 +03:00
fn grouped_release_rows (
groups : Vec < ( & 'static str , Vec < usize > ) > ,
columns : usize ,
) -> Vec < Vec < usize >> {
2026-06-10 16:11:09 +01:00
let columns = columns . max ( 1 );
let mut rows = Vec ::new ();
let mut position = 0 ;
2026-07-21 00:12:45 +03:00
for ( _ , group ) in groups {
2026-06-10 16:11:09 +01:00
for chunk in group . chunks ( columns ) {
rows . push (( position .. position + chunk . len ()). collect ());
position += chunk . len ();
}
}
rows
}
2026-07-23 14:23:22 +03:00
#[cfg(test)]
mod tests {
use super ::* ;
2026-07-25 23:57:03 +03:00
fn release ( title : & str , release_type : & str , year : Option < i32 > ) -> ArtistReleaseSlot {
ArtistReleaseSlot {
2026-07-23 14:23:22 +03:00
title : title . to_string (),
release_type : release_type . to_string (),
year ,
cover_path : None ,
2026-07-25 23:57:03 +03:00
local_index : None ,
fed_index : None ,
local_track_count : 1 ,
total_track_count : 1 ,
2026-07-25 22:40:27 +03:00
availability : crate ::library ::models ::Availability ::Local ,
2026-07-23 14:23:22 +03:00
}
}
fn fed_release (
title : & str ,
release_type : & str ,
year : Option < i32 > ,
) -> crate ::federation ::FedRelease {
crate ::federation ::FedRelease {
title : title . to_string (),
release_type : release_type . to_string (),
year ,
.. Default ::default ()
}
}
#[test]
fn release_display_order_is_newest_first_within_each_type () {
let releases = vec! [
2026-07-25 23:57:03 +03:00
release ( "Old Album" , "album" , Some ( 1991 )),
release ( "New Single" , "single" , Some ( 2024 )),
release ( "New Album" , "album" , Some ( 2020 )),
release ( "Undated Album" , "album" , None ),
release ( "Old Single" , "single" , Some ( 1999 )),
2026-07-23 14:23:22 +03:00
];
2026-07-25 23:57:03 +03:00
assert_eq! ( artist_release_display_order ( & releases ), vec! [ 2 , 0 , 3 , 1 , 4 ]);
2026-07-23 14:23:22 +03:00
}
#[test]
fn fed_release_display_order_is_newest_first_within_each_type () {
let releases = vec! [
fed_release ( "Old Album" , "album" , Some ( 1991 )),
fed_release ( "New Single" , "single" , Some ( 2024 )),
fed_release ( "New Album" , "album" , Some ( 2020 )),
fed_release ( "Undated Album" , "album" , None ),
fed_release ( "Old Single" , "single" , Some ( 1999 )),
];
assert_eq! ( fed_release_display_order ( & releases ), vec! [ 2 , 0 , 3 , 1 , 4 ]);
}
2026-07-25 23:57:03 +03:00
#[test]
fn artist_merged_releases_marks_partially_local_federated_release () {
let local_id = "b3:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" ;
let remote_id = "b3:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" ;
let detail = ArtistDetail {
id : 7 ,
name : "Artist" . to_string (),
image_path : None ,
total_track_count : 1 ,
total_play_count : 0 ,
top_tracks : Vec ::new (),
releases : vec ! [ crate ::library ::models ::ReleaseCard {
id : 11 ,
title : "Album" . to_string (),
release_type : "album" . to_string (),
year : Some ( 2024 ),
cover_path : None ,
track_count : 1 ,
availability : Availability ::Local ,
}],
featured_tracks : Vec ::new (),
};
let mut state = AppState ::default ();
state . global . filters . source_mode = crate ::config ::settings ::LibrarySourceMode ::My ;
state . local_content_ids . insert ( local_id . to_string ());
state . artist_fed_views . insert (
detail . id ,
Loadable ::Ready ( crate ::federation ::FedArtistCard {
name : detail . name . clone (),
own_owner : None ,
peers : 1 ,
owners : vec ! [ "peer" . to_string ()],
image_path : None ,
releases : vec ! [ crate ::federation ::FedRelease {
title : "Album" . to_string (),
release_type : "album" . to_string (),
year : Some ( 2024 ),
tracks : vec ! [
crate ::federation ::FedCardTrack {
title : "Local" . to_string (),
content_id : Some ( local_id . to_string ()),
.. Default ::default ()
},
crate ::federation ::FedCardTrack {
title : "Remote" . to_string (),
content_id : Some ( remote_id . to_string ()),
.. Default ::default ()
},
],
.. Default ::default ()
}],
appears_on : Vec ::new (),
}),
);
let merged = artist_merged_releases ( & state , detail . id , & detail );
assert_eq! ( merged . len (), 1 );
assert_eq! ( merged [ 0 ]. local_track_count , 1 );
assert_eq! ( merged [ 0 ]. total_track_count , 2 );
assert_eq! ( merged [ 0 ]. availability , Availability ::Mixed );
assert_eq! ( merged [ 0 ]. local_index , Some ( 0 ));
assert_eq! ( merged [ 0 ]. fed_index , Some ( 0 ));
}
#[test]
fn artist_merged_releases_ignores_federation_in_local_filter () {
let detail = ArtistDetail {
id : 7 ,
name : "Artist" . to_string (),
image_path : None ,
total_track_count : 1 ,
total_play_count : 0 ,
top_tracks : Vec ::new (),
releases : vec ! [ crate ::library ::models ::ReleaseCard {
id : 11 ,
title : "Album" . to_string (),
release_type : "album" . to_string (),
year : Some ( 2024 ),
cover_path : None ,
track_count : 1 ,
availability : Availability ::Local ,
}],
featured_tracks : Vec ::new (),
};
let mut state = AppState ::default ();
2026-08-02 00:40:50 +01:00
state . global . filters . source_mode = crate ::config ::settings ::LibrarySourceMode ::Local ;
2026-07-25 23:57:03 +03:00
state . artist_fed_views . insert (
detail . id ,
Loadable ::Ready ( crate ::federation ::FedArtistCard {
name : detail . name . clone (),
peers : 1 ,
releases : vec ! [ crate ::federation ::FedRelease {
title : "Album" . to_string (),
release_type : "album" . to_string (),
tracks : vec ! [ crate ::federation ::FedCardTrack {
title : "Remote" . to_string (),
content_id : Some (
"b3:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
. to_string (),
),
.. Default ::default ()
}],
.. Default ::default ()
}],
.. Default ::default ()
}),
);
let merged = artist_merged_releases ( & state , detail . id , & detail );
assert_eq! ( merged . len (), 1 );
assert_eq! ( merged [ 0 ]. total_track_count , 1 );
assert_eq! ( merged [ 0 ]. availability , Availability ::Local );
assert_eq! ( merged [ 0 ]. fed_index , None );
}
#[test]
fn artist_cursor_anchor_restores_release_after_federation_enrichment () {
let detail = ArtistDetail {
id : 7 ,
name : "Artist" . to_string (),
image_path : None ,
total_track_count : 1 ,
total_play_count : 0 ,
top_tracks : Vec ::new (),
releases : vec ! [ crate ::library ::models ::ReleaseCard {
id : 11 ,
title : "Old Local" . to_string (),
release_type : "album" . to_string (),
year : Some ( 2001 ),
cover_path : None ,
track_count : 1 ,
availability : Availability ::Local ,
}],
featured_tracks : Vec ::new (),
};
let mut state = AppState ::default ();
state . global . filters . source_mode = crate ::config ::settings ::LibrarySourceMode ::My ;
let artist_id = detail . id ;
let artist_name = detail . name . clone ();
state
. artist_views
. insert ( artist_id , Loadable ::Ready ( detail ));
state . global . stack . push ( GlobalView ::Artist {
id : artist_id ,
cursor : 0 ,
});
let anchor = artist_cursor_anchor ( & state , artist_id );
state . artist_fed_views . insert (
artist_id ,
Loadable ::Ready ( crate ::federation ::FedArtistCard {
name : artist_name ,
peers : 1 ,
releases : vec ! [ crate ::federation ::FedRelease {
title : "New Remote" . to_string (),
release_type : "album" . to_string (),
year : Some ( 2024 ),
owners : vec ! [ "peer" . to_string ()],
.. Default ::default ()
}],
.. Default ::default ()
}),
);
restore_artist_cursor_anchor ( & mut state , artist_id , anchor );
assert! ( matches! (
state . global . stack . last (),
Some ( GlobalView ::Artist { cursor : 1 , .. })
));
}
2026-07-23 14:23:22 +03:00
}
2026-07-16 20:29:51 +03:00
/// The virtual Likes playlist id (`kind == "likes"`).
pub use crate ::library ::LIKES_PLAYLIST_ID ;
2026-06-10 16:11:09 +01:00
#[derive(Debug, Clone, Copy)]
pub struct OpenedPlaylist {
pub id : i64 ,
pub cursor : usize ,
}
/// The Playlists tab. The server list includes the virtual "Likes"
/// playlist (id = -1), rendered with a ♥ marker.
#[derive(Debug, Default)]
pub struct PlaylistsTab {
pub list : Option < Loadable < Vec < PlaylistCard >>> ,
pub selected : usize ,
pub opened : Option < OpenedPlaylist > ,
}
2026-06-10 16:52:09 +01:00
/// The Queue tab's own cursor — independent from the playing position, so
/// the user can browse and pick tracks while something else plays.
#[derive(Debug, Default)]
pub struct QueueTab {
pub cursor : usize ,
}
2026-06-15 12:28:34 +01:00
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TrackSelectionScope {
ArtistTop ( i64 ),
ArtistFeatured ( i64 ),
Release ( i64 ),
Playlist ( i64 ),
Queue ,
2026-08-10 20:51:21 +01:00
/// The unified local + federated similar-track result list.
SimilaritySearch ,
2026-07-17 01:43:09 +03:00
/// The federated section of the search results (its tracks).
FedSearch ,
/// The tracklist of the open federated release view.
FedRelease ( usize ),
2026-07-21 00:12:45 +03:00
/// The appears-on track list of the open federated artist card.
FedAppearsOn ,
2026-06-15 12:28:34 +01:00
}
/// Vim-like Shift-V selection for line-oriented track lists. The selected
/// range is always contiguous: anchor is where visual mode started, cursor is
/// extended by normal navigation.
#[derive(Debug, Clone, Default)]
pub struct TrackSelection {
pub scope : Option < TrackSelectionScope > ,
pub anchor : usize ,
pub cursor : usize ,
}
impl TrackSelection {
pub fn is_active ( & self ) -> bool {
self . scope . is_some ()
}
pub fn is_active_for ( & self , scope : & TrackSelectionScope ) -> bool {
self . scope . as_ref () == Some ( scope )
}
pub fn start ( & mut self , scope : TrackSelectionScope , cursor : usize ) {
self . scope = Some ( scope );
self . anchor = cursor ;
self . cursor = cursor ;
}
pub fn clear ( & mut self ) {
self . scope = None ;
self . anchor = 0 ;
self . cursor = 0 ;
}
pub fn set_cursor ( & mut self , scope : TrackSelectionScope , cursor : usize ) {
if self . scope . as_ref () == Some ( & scope ) {
self . cursor = cursor ;
}
}
pub fn contains ( & self , scope : & TrackSelectionScope , index : usize ) -> bool {
if self . scope . as_ref () != Some ( scope ) {
return false ;
}
let start = self . anchor . min ( self . cursor );
let end = self . anchor . max ( self . cursor );
( start ..= end ). contains ( & index )
}
pub fn indices ( & self , scope : & TrackSelectionScope , len : usize ) -> Option < Vec < usize >> {
if len == 0 || self . scope . as_ref () != Some ( scope ) {
return None ;
}
let start = self . anchor . min ( self . cursor ). min ( len - 1 );
let end = self . anchor . max ( self . cursor ). min ( len - 1 );
Some (( start ..= end ). collect ())
}
}
2026-06-10 16:23:20 +01:00
/// Severity steps for the Logs tab filter, cycled with the view-toggle key.
pub const LOG_LEVELS : [ tracing ::Level ; 5 ] = [
tracing ::Level ::ERROR ,
tracing ::Level ::WARN ,
tracing ::Level ::INFO ,
tracing ::Level ::DEBUG ,
tracing ::Level ::TRACE ,
];
/// The Logs tab: a live view over the in-memory ring buffer.
#[derive(Debug)]
pub struct LogsTab {
/// Index into LOG_LEVELS; entries more verbose than this are hidden.
pub level_index : usize ,
/// Stick to the newest entries as they arrive.
pub follow : bool ,
2026-06-10 23:30:03 +01:00
/// Cursor anchored to a specific entry's seq; appends never move it.
/// None = newest (follow mode).
pub selected_seq : Option < u64 > ,
2026-06-10 16:23:20 +01:00
}
impl Default for LogsTab {
fn default () -> Self {
Self {
level_index : 2 ,
follow : true ,
2026-06-10 23:30:03 +01:00
selected_seq : None ,
2026-06-10 16:23:20 +01:00
}
}
}
2026-07-16 20:29:51 +03:00
/// What an open edit form writes to when saved.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EditTarget {
Track ( i64 ),
Release ( i64 ),
Artist ( i64 ),
Playlist ( i64 ),
2026-06-10 23:30:03 +01:00
}
2026-07-16 20:29:51 +03:00
/// What a confirmed delete removes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeleteTarget {
Track ( i64 ),
Release ( i64 ),
Artist ( i64 ),
Playlist ( i64 ),
}
2026-06-10 23:30:03 +01:00
2026-07-16 20:29:51 +03:00
/// One text field of an edit form.
#[derive(Debug, Clone)]
pub struct EditField {
pub label : & 'static str ,
2026-07-17 01:17:54 +03:00
pub value : LineEdit ,
2026-07-16 20:29:51 +03:00
}
2026-06-10 23:30:03 +01:00
2026-07-16 20:29:51 +03:00
impl EditField {
pub fn new ( label : & 'static str , value : impl Into < String > ) -> Self {
Self {
label ,
2026-07-17 01:17:54 +03:00
value : LineEdit ::new ( value ),
2026-07-16 20:29:51 +03:00
}
2026-06-10 23:30:03 +01:00
}
}
2026-07-24 03:35:42 +03:00
/// What an add-to-playlist flow adds: local library tracks directly (including
/// already-materialized federation placeholders), or federated search/card
/// tracks that are downloaded into the library first.
2026-07-17 01:43:09 +03:00
#[derive(Debug, Clone)]
pub enum PlaylistAddTarget {
Local ( Vec < TrackItem > ),
Fed ( Vec < crate ::federation ::FedTrack > ),
}
impl PlaylistAddTarget {
/// Short description for popup titles.
pub fn label ( & self ) -> String {
match self {
PlaylistAddTarget ::Local ( tracks ) if tracks . len () == 1 => tracks [ 0 ]. title . clone (),
PlaylistAddTarget ::Fed ( tracks ) if tracks . len () == 1 => {
format! ( " {} (federation)" , tracks [ 0 ]. title )
}
PlaylistAddTarget ::Local ( tracks ) => format! ( " {} tracks" , tracks . len ()),
PlaylistAddTarget ::Fed ( tracks ) => format! ( " {} tracks (federation)" , tracks . len ()),
}
}
}
2026-06-10 23:30:03 +01:00
/// Modal dialog over the main screen.
#[derive(Debug)]
pub enum Popup {
2026-07-23 17:35:11 +03:00
/// Pick one of the playlists (last row = "create new"); the target is
2026-07-17 01:43:09 +03:00
/// added on Enter (federated tracks are downloaded first).
AddToPlaylist {
target : PlaylistAddTarget ,
cursor : usize ,
},
/// Name input for a new playlist; when `for_target` is set, it is
/// added to the playlist right after creation.
2026-06-10 23:30:03 +01:00
NewPlaylist {
2026-07-17 01:43:09 +03:00
for_target : Option < PlaylistAddTarget > ,
2026-07-17 01:17:54 +03:00
input : LineEdit ,
2026-06-10 23:30:03 +01:00
busy : bool ,
},
2026-07-16 20:29:51 +03:00
/// Metadata edit form for a track, release, artist or playlist.
Edit {
target : EditTarget ,
title : String ,
fields : Vec < EditField > ,
focus : usize ,
error : Option < String > ,
},
/// Delete confirmation; Enter/y deletes, Esc/n cancels.
ConfirmDelete { target : DeleteTarget , label : String },
2026-08-10 00:22:20 +01:00
/// Enabling network similarity reveals an embedding query to peers.
SimilarityPrivacyConsent { enable_federation : bool },
/// Derived data is safe to recreate but potentially expensive.
ConfirmClearEmbeddings ,
2026-07-23 19:43:47 +03:00
/// Library-home filters. Cursor is kept for the next filters added here.
LibraryFilters { cursor : usize },
2026-06-15 12:28:34 +01:00
/// Track metadata viewer; left/right switch between selected tracks.
TrackInfo {
tracks : Vec < TrackItem > ,
cursor : usize ,
scroll : usize ,
},
2026-07-23 16:45:30 +03:00
/// Artist picker over the info popup ('a' with several artists):
/// Enter jumps to the chosen artist's page, Esc returns to the info.
TrackArtists {
tracks : Vec < TrackItem > ,
cursor : usize ,
scroll : usize ,
selected : usize ,
},
2026-06-10 23:30:03 +01:00
/// Full, wrapped view of one log entry (Enter on the Logs tab).
LogDetail ( crate ::config ::logging ::LogEntry ),
2026-07-16 20:29:51 +03:00
/// One-line text entry on the Federation tab (network id, peer ticket).
FedInput {
field : FedInputField ,
2026-07-17 01:17:54 +03:00
input : LineEdit ,
2026-07-16 20:29:51 +03:00
},
/// Wrapped read-only text (this peer's connection ticket).
FedText { title : String , text : String },
2026-07-26 00:45:53 +03:00
/// Wrapped text that can be copied to the system clipboard.
FedCopyText {
title : String ,
text : String ,
help : String ,
2026-08-02 00:40:50 +01:00
cursor : usize ,
2026-07-26 00:45:53 +03:00
},
2026-08-02 00:40:50 +01:00
/// A copy-friendly terminal screen containing exactly one logical line.
/// The app loop temporarily leaves the alternate screen while this is
/// active so terminal selection does not acquire TUI borders or hard
/// line breaks.
PlainText { text : String },
/// The destination is already write-tested; Enter/y migrates managed
/// files, while n changes only the destination for future downloads.
ConfirmMusicDirectory { path : std ::path ::PathBuf },
2026-07-24 00:54:27 +03:00
/// Incoming trusted-device pairing request.
DevicePairing {
request_id : String ,
device_id : String ,
name : String ,
client_version : String ,
2026-07-24 02:21:24 +03:00
requester_group_id : Option < String > ,
requester_group_active_devices : usize ,
2026-07-24 00:54:27 +03:00
},
/// Confirmation before revoking a trusted device.
ConfirmDeviceRevoke { device_id : String , name : String },
2026-07-26 01:41:16 +03:00
/// Confirmation before this device leaves the trusted-device group.
ConfirmDeviceLeave ,
2026-07-24 04:11:01 +03:00
/// Connected playback devices and their current role/status.
ConnectedDevices { cursor : usize },
2026-07-27 23:37:46 +01:00
/// Qualified listening history from every trusted device.
ListenHistory { cursor : usize },
2026-07-25 02:10:20 +03:00
/// Full federation, transport and device status details.
2026-07-26 00:45:53 +03:00
FederationStatusDetails {
focus : StatusDetailFocus ,
status_cursor : usize ,
devices_scroll : usize ,
logs_scroll : usize ,
},
/// Full text opened from the full federation status dashboard.
FederationStatusText {
parent : FederationStatusPopupState ,
title : String ,
text : String ,
scroll : usize ,
},
/// Full connection log opened from the full federation status dashboard.
FederationStatusLog {
parent : FederationStatusPopupState ,
scroll : usize ,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FederationStatusPopupState {
pub focus : StatusDetailFocus ,
pub status_cursor : usize ,
pub devices_scroll : usize ,
pub logs_scroll : usize ,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatusDetailFocus {
Status ,
Devices ,
Logs ,
}
impl From < FederationStatusPopupState > for Popup {
fn from ( value : FederationStatusPopupState ) -> Self {
Popup ::FederationStatusDetails {
focus : value . focus ,
status_cursor : value . status_cursor ,
devices_scroll : value . devices_scroll ,
logs_scroll : value . logs_scroll ,
}
}
}
impl StatusDetailFocus {
pub fn next ( self ) -> Self {
match self {
StatusDetailFocus ::Status => StatusDetailFocus ::Devices ,
StatusDetailFocus ::Devices => StatusDetailFocus ::Logs ,
StatusDetailFocus ::Logs => StatusDetailFocus ::Status ,
}
}
pub fn previous ( self ) -> Self {
match self {
StatusDetailFocus ::Status => StatusDetailFocus ::Logs ,
StatusDetailFocus ::Devices => StatusDetailFocus ::Status ,
StatusDetailFocus ::Logs => StatusDetailFocus ::Devices ,
}
}
2026-06-10 23:30:03 +01:00
}
2026-07-16 20:29:51 +03:00
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FedInputField {
2026-08-02 00:40:50 +01:00
MusicDirectory ,
2026-08-10 00:22:20 +01:00
SimilarityWorkers ,
2026-08-10 20:51:21 +01:00
SimilarityMinimumScore ,
SimilarityMaxTracksPerArtist ,
2026-07-16 20:29:51 +03:00
NetworkId ,
ConnectTicket ,
2026-07-24 00:54:27 +03:00
DeviceName ,
ConnectInvite ,
2026-07-28 18:35:59 +01:00
JamInvite ,
2026-07-16 20:29:51 +03:00
}
impl FedInputField {
pub fn title ( self ) -> & 'static str {
match self {
2026-08-02 00:40:50 +01:00
FedInputField ::MusicDirectory => "Music save directory" ,
2026-08-10 00:22:20 +01:00
FedInputField ::SimilarityWorkers => "Similarity background workers" ,
2026-08-10 20:51:21 +01:00
FedInputField ::SimilarityMinimumScore => "Minimum similarity score" ,
FedInputField ::SimilarityMaxTracksPerArtist => "Tracks per artist" ,
2026-07-16 20:29:51 +03:00
FedInputField ::NetworkId => "Network ID" ,
FedInputField ::ConnectTicket => "Connect to peer (paste ticket)" ,
2026-07-24 00:54:27 +03:00
FedInputField ::DeviceName => "Device name" ,
FedInputField ::ConnectInvite => "Connect device (paste frid://i invite)" ,
2026-07-28 18:35:59 +01:00
FedInputField ::JamInvite => "Join Jam (paste frid://j invite)" ,
2026-07-16 20:29:51 +03:00
}
}
2026-07-26 00:45:53 +03:00
pub fn help ( self ) -> & 'static str {
match self {
2026-08-02 00:40:50 +01:00
FedInputField ::MusicDirectory => {
"Federated tracks saved to your library use this directory. The directory is checked for write access before anything changes."
}
2026-08-10 00:22:20 +01:00
FedInputField ::SimilarityWorkers => {
"Enter the maximum number of tracks processed in parallel, from 1 to 16. The change takes effect immediately."
}
2026-08-10 20:51:21 +01:00
FedInputField ::SimilarityMinimumScore => {
"Enter the minimum cosine similarity from 0.00 to 1.00. Lower values show broader matches; higher values hide weak matches. Embeddings are not recalculated."
}
FedInputField ::SimilarityMaxTracksPerArtist => {
"Enter how many tracks by one primary artist may appear in similarity results, from 1 to 50. Embeddings are not recalculated."
}
2026-07-26 00:45:53 +03:00
FedInputField ::NetworkId => {
"A unique network id. It must match exactly on every client that should see and connect to the same peers."
}
FedInputField ::ConnectTicket => {
"Paste a connection ticket generated by another client to connect to that peer directly."
}
FedInputField ::DeviceName => {
"A friendly nickname for this device, used only to make connected-device management easier."
}
FedInputField ::ConnectInvite => {
"Paste a frid:// invite generated by another client to add this device to its sync group."
}
2026-07-28 18:35:59 +01:00
FedInputField ::JamInvite => {
"Paste a frid://j capability to control the host player for this Jam only."
}
2026-07-26 00:45:53 +03:00
}
}
2026-07-16 20:29:51 +03:00
}
2026-07-23 18:48:58 +03:00
/// Rows of the federation block inside Settings, in display order.
2026-07-16 20:29:51 +03:00
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FedRow {
Toggle ,
NetworkId ,
SaveOnListen ,
SyncNow ,
ShowTicket ,
Connect ,
}
2026-08-10 00:22:20 +01:00
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SimilarityRow {
Toggle ,
Model ,
Profile ,
2026-08-10 20:51:21 +01:00
MinimumScore ,
MaxTracksPerArtist ,
2026-08-10 00:22:20 +01:00
Workers ,
Clear ,
}
impl SimilarityRow {
2026-08-10 20:51:21 +01:00
pub const ALL : [ SimilarityRow ; 7 ] = [
2026-08-10 00:22:20 +01:00
SimilarityRow ::Toggle ,
SimilarityRow ::Model ,
SimilarityRow ::Profile ,
2026-08-10 20:51:21 +01:00
SimilarityRow ::MinimumScore ,
SimilarityRow ::MaxTracksPerArtist ,
2026-08-10 00:22:20 +01:00
SimilarityRow ::Workers ,
SimilarityRow ::Clear ,
];
}
2026-07-16 20:29:51 +03:00
impl FedRow {
pub const ALL : [ FedRow ; 6 ] = [
FedRow ::Toggle ,
FedRow ::NetworkId ,
FedRow ::SaveOnListen ,
FedRow ::SyncNow ,
FedRow ::ShowTicket ,
FedRow ::Connect ,
];
}
2026-07-23 18:48:58 +03:00
/// Rows of the Settings tab, in display order. Federation rows are fixed;
/// visualization script rows are derived from the scripts currently found in
/// the config directory.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingsRow {
2026-08-02 00:40:50 +01:00
MusicDirectory ,
2026-08-10 00:22:20 +01:00
Similarity ( SimilarityRow ),
2026-07-23 18:48:58 +03:00
Federation ( FedRow ),
2026-07-25 02:10:20 +03:00
StatusDetails ,
2026-07-24 00:54:27 +03:00
DeviceName ,
DeviceInvite ,
DeviceConnect ,
DeviceSyncNow ,
2026-07-26 01:41:16 +03:00
DeviceLeaveGroup ,
2026-07-24 00:54:27 +03:00
Device ( usize ),
2026-07-23 18:48:58 +03:00
VisualizationClock ,
VisualizationScript ( usize ),
VisualizationNew ,
VisualizationEdit ,
}
2026-07-24 04:11:01 +03:00
pub const DEVICE_ONLINE_TTL_MS : i64 = 45_000 ;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DevicePresenceSection {
Online ,
Offline ,
Revoked ,
}
impl DevicePresenceSection {
pub fn title ( self ) -> & 'static str {
match self {
DevicePresenceSection ::Online => "Online devices" ,
DevicePresenceSection ::Offline => "Offline devices" ,
DevicePresenceSection ::Revoked => "Revoked devices" ,
}
}
fn sort_rank ( self ) -> u8 {
match self {
DevicePresenceSection ::Online => 0 ,
DevicePresenceSection ::Offline => 1 ,
DevicePresenceSection ::Revoked => 2 ,
}
}
}
pub fn unix_time_ms () -> i64 {
std ::time ::SystemTime ::now ()
. duration_since ( std ::time ::UNIX_EPOCH )
. map ( | d | d . as_millis () as i64 )
. unwrap_or ( 0 )
}
pub fn device_display_name ( device : & crate ::devices ::DeviceStatusRow ) -> String {
if device . name . trim (). is_empty () {
device . device_id . chars (). take ( 10 ). collect ()
} else {
device . name . clone ()
}
}
pub fn device_status_active ( state : & AppState , device_id : & str ) -> bool {
state
. device_playback
. active_device_id
. as_deref ()
. is_some_and ( | active | active == device_id )
|| state
. device_playback
. remote
. get ( device_id )
. is_some_and ( | snapshot | snapshot . active )
}
pub fn device_status_online (
state : & AppState ,
device : & crate ::devices ::DeviceStatusRow ,
now_ms : i64 ,
) -> bool {
device . is_self
|| device . device_id == state . device_playback . self_device_id
|| device
. last_seen_ms
. is_some_and ( | seen | now_ms . saturating_sub ( seen ) <= DEVICE_ONLINE_TTL_MS )
|| state
. device_playback
. remote
. get ( & device . device_id )
. is_some_and ( | snapshot | {
now_ms . saturating_sub ( snapshot . updated_at_ms ) <= DEVICE_ONLINE_TTL_MS
})
}
pub fn device_presence_section (
state : & AppState ,
device : & crate ::devices ::DeviceStatusRow ,
now_ms : i64 ,
) -> DevicePresenceSection {
if device . revoked {
DevicePresenceSection ::Revoked
} else if device_status_online ( state , device , now_ms ) {
DevicePresenceSection ::Online
} else {
DevicePresenceSection ::Offline
}
}
pub fn device_status_order ( state : & AppState ) -> Vec < usize > {
let Some ( status ) = & state . federation . devices else {
return Vec ::new ();
};
let now = unix_time_ms ();
let mut indices : Vec < usize > = status
. devices
. iter ()
. enumerate ()
. filter_map ( | ( index , device ) | ( ! device . revoked ). then_some ( index ))
. collect ();
indices . sort_by ( | left , right | {
let left_device = & status . devices [ * left ];
let right_device = & status . devices [ * right ];
let left_section = device_presence_section ( state , left_device , now ). sort_rank ();
let right_section = device_presence_section ( state , right_device , now ). sort_rank ();
(
left_section ,
! device_status_active ( state , & left_device . device_id ),
! left_device . is_self ,
device_display_name ( left_device ). to_ascii_lowercase (),
left_device . device_id . as_str (),
)
. cmp ( & (
right_section ,
! device_status_active ( state , & right_device . device_id ),
! right_device . is_self ,
device_display_name ( right_device ). to_ascii_lowercase (),
right_device . device_id . as_str (),
))
});
indices
}
2026-07-23 18:48:58 +03:00
pub fn settings_rows ( state : & AppState ) -> Vec < SettingsRow > {
2026-08-02 00:40:50 +01:00
let mut rows = vec! [ SettingsRow ::MusicDirectory ];
2026-08-10 00:22:20 +01:00
rows . extend ( SimilarityRow ::ALL . into_iter (). map ( SettingsRow ::Similarity ));
2026-07-23 18:48:58 +03:00
rows . extend ( FedRow ::ALL . into_iter (). map ( SettingsRow ::Federation ));
2026-07-24 00:54:27 +03:00
rows . push ( SettingsRow ::DeviceName );
rows . push ( SettingsRow ::DeviceInvite );
rows . push ( SettingsRow ::DeviceConnect );
rows . push ( SettingsRow ::DeviceSyncNow );
2026-07-26 01:41:16 +03:00
rows . push ( SettingsRow ::DeviceLeaveGroup );
2026-07-24 04:11:01 +03:00
rows . extend (
device_status_order ( state )
. into_iter ()
. map ( SettingsRow ::Device ),
);
2026-07-23 18:48:58 +03:00
rows . push ( SettingsRow ::VisualizationClock );
rows . extend (
state
. visualizer
. scripts
. iter ()
. enumerate ()
. map ( | ( index , _ ) | SettingsRow ::VisualizationScript ( index )),
);
rows . push ( SettingsRow ::VisualizationNew );
if ! state . visualizer . scripts . is_empty () {
rows . push ( SettingsRow ::VisualizationEdit );
}
2026-07-25 02:10:20 +03:00
rows . push ( SettingsRow ::StatusDetails );
2026-07-23 18:48:58 +03:00
rows
}
/// Federation settings mirror + the latest status snapshot for Settings.
2026-07-16 20:29:51 +03:00
#[derive(Debug, Default)]
pub struct FederationTab {
pub settings : crate ::federation ::FedSettings ,
pub status : Option < crate ::federation ::FedStatus > ,
2026-07-24 00:54:27 +03:00
pub devices : Option < crate ::devices ::DeviceSyncStatus > ,
2026-07-26 00:45:53 +03:00
pub publishing : bool ,
pub device_syncing : bool ,
2026-07-16 20:29:51 +03:00
}
2026-08-10 00:22:20 +01:00
#[derive(Debug, Default)]
pub struct SimilarityTab {
pub settings : crate ::config ::settings ::SimilaritySettings ,
pub status : crate ::similarity ::SimilarityStatus ,
}
2026-07-16 20:29:51 +03:00
/// Playlists eligible as add-targets (the virtual Likes playlist is managed
/// through likes, not direct adds).
2026-06-10 23:30:03 +01:00
pub fn addable_playlists ( state : & AppState ) -> Vec < ( i64 , String ) > {
match & state . playlists . list {
Some ( Loadable ::Ready ( list )) => list
. iter ()
2026-07-16 20:29:51 +03:00
. filter ( | p | p . kind != "likes" )
2026-06-10 23:30:03 +01:00
. map ( | p | ( p . id , p . title . clone ()))
. collect (),
_ => Vec ::new (),
}
}
2026-06-10 16:11:09 +01:00
/// Command line (`:`), vim-style. Lives on the Main screen status bar.
#[derive(Debug, Default)]
pub struct Cmdline {
pub active : bool ,
2026-07-17 01:17:54 +03:00
pub input : LineEdit ,
2026-06-10 16:11:09 +01:00
/// A live command (search) applied effects during this session; Esc
/// undoes them, Enter keeps them.
pub live : bool ,
2026-08-02 00:40:50 +01:00
/// Commands committed during this process, oldest first.
pub history : Vec < String > ,
/// Entry currently recalled with Up/Down. `None` is the editable draft
/// after the newest history entry.
history_index : Option < usize > ,
history_draft : String ,
}
impl Cmdline {
const HISTORY_LIMIT : usize = 100 ;
pub fn begin_history_navigation ( & mut self ) {
self . history_index = None ;
self . history_draft . clear ();
}
pub fn remember ( & mut self , value : & str ) {
let value = value . trim ();
if value . is_empty () {
return ;
}
if self . history . last (). is_none_or ( | last | last != value ) {
self . history . push ( value . to_string ());
if self . history . len () > Self ::HISTORY_LIMIT {
self . history . remove ( 0 );
}
}
self . begin_history_navigation ();
}
pub fn history_previous ( & mut self ) -> bool {
if self . history . is_empty () {
return false ;
}
let index = match self . history_index {
Some ( index ) => index . saturating_sub ( 1 ),
None => {
self . history_draft = self . input . as_str (). to_string ();
self . history . len () - 1
}
};
self . history_index = Some ( index );
self . input = LineEdit ::new ( self . history [ index ]. clone ());
true
}
pub fn history_next ( & mut self ) -> bool {
let Some ( index ) = self . history_index else {
return false ;
};
if index + 1 < self . history . len () {
self . history_index = Some ( index + 1 );
self . input = LineEdit ::new ( self . history [ index + 1 ]. clone ());
} else {
self . history_index = None ;
self . input = LineEdit ::new ( std ::mem ::take ( & mut self . history_draft ));
}
true
}
}
#[cfg(test)]
mod cmdline_history_tests {
use super ::Cmdline ;
use crate ::app ::input ::LineEdit ;
#[test]
fn history_walks_oldest_and_restores_the_draft () {
let mut cmdline = Cmdline ::default ();
cmdline . remember ( "volume 20" );
cmdline . remember ( "/ambient" );
cmdline . input = LineEdit ::new ( "unfinished" );
assert! ( cmdline . history_previous ());
assert_eq! ( cmdline . input . as_str (), "/ambient" );
assert! ( cmdline . history_previous ());
assert_eq! ( cmdline . input . as_str (), "volume 20" );
assert! ( cmdline . history_previous ());
assert_eq! ( cmdline . input . as_str (), "volume 20" );
assert! ( cmdline . history_next ());
assert_eq! ( cmdline . input . as_str (), "/ambient" );
assert! ( cmdline . history_next ());
assert_eq! ( cmdline . input . as_str (), "unfinished" );
}
#[test]
fn history_deduplicates_consecutive_commands () {
let mut cmdline = Cmdline ::default ();
cmdline . remember ( "q" );
cmdline . remember ( " q " );
assert_eq! ( cmdline . history , vec! [ "q" ]);
}
2026-06-10 16:11:09 +01:00
}
/// Live search state driven by the `:/query` command.
2026-08-10 20:51:21 +01:00
#[derive(Debug, Clone)]
pub enum SimilaritySearchHit {
Local {
track : TrackItem ,
score : f32 ,
embedding_signature : [ u8 ; music_dht ::similarity ::SIMILARITY_SIGNATURE_BYTES ],
},
Federated {
track : crate ::federation ::FedTrack ,
score : f32 ,
embedding_signature : Option < [ u8 ; music_dht ::similarity ::SIMILARITY_SIGNATURE_BYTES ] > ,
},
}
impl SimilaritySearchHit {
pub fn score ( & self ) -> f32 {
match self {
Self ::Local { score , .. } | Self ::Federated { score , .. } => * score ,
}
}
pub fn embedding_signature (
& self ,
) -> Option < [ u8 ; music_dht ::similarity ::SIMILARITY_SIGNATURE_BYTES ] > {
match self {
Self ::Local {
embedding_signature ,
..
} => Some ( * embedding_signature ),
Self ::Federated {
embedding_signature ,
..
} => * embedding_signature ,
}
}
pub fn track_item ( & self ) -> TrackItem {
match self {
Self ::Local { track , .. } => track . clone (),
Self ::Federated { track , .. } => crate ::federation ::pending_track ( track ),
}
}
pub fn federated_track ( & self ) -> Option <& crate ::federation ::FedTrack > {
match self {
Self ::Federated { track , .. } => Some ( track ),
Self ::Local { .. } => None ,
}
}
pub fn primary_artist_key ( & self ) -> String {
match self {
Self ::Local { track , .. } => track
. artists
. first ()
. map ( | artist | music_dht ::normalize_name ( & artist . name ))
. unwrap_or_default (),
Self ::Federated { track , .. } => track
. artist_names
. first ()
. map ( | artist | music_dht ::normalize_name ( artist ))
. unwrap_or_default (),
}
}
pub fn content_key ( & self ) -> String {
match self {
Self ::Local { track , .. } => track
. content_id
. clone ()
. unwrap_or_else ( || format! ( "local: {} " , track . id )),
Self ::Federated { track , .. } => track
. content_id
. clone ()
. unwrap_or_else ( || format! ( "remote: {} : {} " , track . owner , track . item_id )),
}
}
}
2026-06-10 16:11:09 +01:00
#[derive(Debug, Default)]
pub struct SearchState {
pub query : String ,
pub loading : bool ,
pub results : Option < SearchResults > ,
2026-07-16 20:29:51 +03:00
/// Tracks found on the federated network (empty while federation is
/// off); rendered as a separate, marked section.
pub fed_tracks : Vec < crate ::federation ::FedTrack > ,
2026-07-17 01:17:54 +03:00
/// Artists a federated card can be opened for — from artist records and
/// from the artist names of matching tracks.
pub fed_artists : Vec < crate ::federation ::FedArtistHit > ,
2026-07-16 20:29:51 +03:00
pub fed_loading : bool ,
2026-08-10 00:22:20 +01:00
/// Present only for a track-seeded search; text-search refreshes must not
/// replace this page with a title query.
pub similarity_source : Option < i64 > ,
2026-08-10 20:51:21 +01:00
/// The source is pinned at row zero; candidates below it are globally
/// ranked across the local library and federation.
pub similarity_source_track : Option < TrackItem > ,
pub similarity_tracks : Vec < SimilaritySearchHit > ,
pub similarity_stats : Option < crate ::federation ::SimilaritySearchStats > ,
pub similarity_error : Option < String > ,
}
impl SearchState {
pub fn similarity_len ( & self ) -> usize {
usize ::from ( self . similarity_source_track . is_some ()) + self . similarity_tracks . len ()
}
pub fn similarity_track ( & self , index : usize ) -> Option < TrackItem > {
if index == 0 {
return self . similarity_source_track . clone ();
}
self . similarity_tracks
. get ( index . checked_sub ( 1 ) ? )
. map ( SimilaritySearchHit ::track_item )
}
pub fn similarity_fed_track ( & self , index : usize ) -> Option <& crate ::federation ::FedTrack > {
self . similarity_tracks
. get ( index . checked_sub ( 1 ) ? ) ?
. federated_track ()
}
pub fn similarity_key ( & self , index : usize ) -> Option < String > {
if index == 0 {
return self
. similarity_source_track
. as_ref ()
. map ( | track | format! ( "source: {} " , track . id ));
}
self . similarity_tracks
. get ( index . checked_sub ( 1 ) ? )
. map ( SimilaritySearchHit ::content_key )
}
pub fn similarity_index_for_key ( & self , key : & str ) -> Option < usize > {
if self
. similarity_source_track
. as_ref ()
. is_some_and ( | track | key == format! ( "source: {} " , track . id ))
{
return Some ( 0 );
}
self . similarity_tracks
. iter ()
. position ( | hit | hit . content_key () == key )
. map ( | index | index + 1 )
}
2026-06-10 16:11:09 +01:00
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Tab {
#[default]
Global ,
Playlists ,
Queue ,
2026-07-16 20:29:51 +03:00
Federation ,
2026-06-10 16:23:20 +01:00
Logs ,
2026-06-10 16:11:09 +01:00
}
impl Tab {
2026-07-16 20:29:51 +03:00
pub const ALL : [ Tab ; 5 ] = [
Tab ::Global ,
Tab ::Playlists ,
Tab ::Queue ,
Tab ::Federation ,
Tab ::Logs ,
];
2026-06-10 16:11:09 +01:00
pub fn title ( self ) -> & 'static str {
match self {
2026-07-23 19:43:47 +03:00
Tab ::Global => "Library" ,
2026-06-10 16:11:09 +01:00
Tab ::Playlists => "Playlists" ,
Tab ::Queue => "Queue" ,
2026-07-23 18:48:58 +03:00
Tab ::Federation => "Settings" ,
2026-06-10 16:23:20 +01:00
Tab ::Logs => "Logs" ,
2026-06-10 16:11:09 +01:00
}
}
pub fn index ( self ) -> usize {
Self ::ALL . iter (). position ( | t | * t == self ). unwrap ()
}
pub fn from_index ( index : usize ) -> Option < Tab > {
Self ::ALL . get ( index ). copied ()
}
pub fn next ( self ) -> Tab {
Self ::ALL [( self . index () + 1 ) % Self ::ALL . len ()]
}
pub fn prev ( self ) -> Tab {
Self ::ALL [( self . index () + Self ::ALL . len () - 1 ) % Self ::ALL . len ()]
}
pub fn key_context ( self ) -> KeyContext {
match self {
Tab ::Global => KeyContext ::Library ,
Tab ::Playlists => KeyContext ::Playlists ,
Tab ::Queue => KeyContext ::Queue ,
2026-07-16 20:29:51 +03:00
Tab ::Federation => KeyContext ::Federation ,
2026-06-10 16:23:20 +01:00
Tab ::Logs => KeyContext ::Logs ,
2026-06-10 16:11:09 +01:00
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RepeatMode {
#[default]
Off ,
One ,
All ,
}
impl RepeatMode {
pub fn label ( self ) -> & 'static str {
match self {
RepeatMode ::Off => "off" ,
RepeatMode ::One => "one" ,
RepeatMode ::All => "all" ,
}
}
pub fn next ( self ) -> RepeatMode {
match self {
RepeatMode ::Off => RepeatMode ::All ,
RepeatMode ::All => RepeatMode ::One ,
RepeatMode ::One => RepeatMode ::Off ,
}
}
}
/// Playback state mirrored for the UI: the queue, the loaded track and the
/// position polled from the audio thread on every tick.
#[derive(Debug)]
pub struct PlayerBar {
pub queue : Vec < TrackItem > ,
pub queue_pos : usize ,
2026-08-02 00:40:50 +01:00
/// Exclusive end of the contiguous "play next" block built by
/// sequential `a` actions. New `a` additions are inserted here.
pub play_next_end : Option < usize > ,
2026-06-10 16:11:09 +01:00
pub current : Option < TrackItem > ,
/// A track is loaded (playing or paused); false = stopped.
pub playing : bool ,
pub paused : bool ,
pub position_secs : f64 ,
2026-07-23 18:48:58 +03:00
pub audio_analysis : crate ::player ::AudioAnalysisSnapshot ,
2026-06-10 16:11:09 +01:00
/// Epoch seconds when the current track started (for history reports).
pub track_started_at : Option < i64 > ,
2026-07-27 23:15:57 +01:00
/// Stable id reused for every report of the current playback session.
pub listen_id : Option < String > ,
2026-06-10 16:11:09 +01:00
/// Queue index already enqueued in the audio thread for gapless play.
pub prefetched_pos : Option < usize > ,
pub volume : u8 ,
pub shuffle : bool ,
2026-07-25 01:23:29 +03:00
/// Stable track keys in pre-shuffle order; restores the queue when
/// shuffle is turned off.
pub original_order : Option < Vec < String >> ,
2026-06-10 16:11:09 +01:00
pub repeat : RepeatMode ,
}
impl Default for PlayerBar {
fn default () -> Self {
Self {
queue : Vec ::new (),
queue_pos : 0 ,
2026-08-02 00:40:50 +01:00
play_next_end : None ,
2026-06-10 16:11:09 +01:00
current : None ,
playing : false ,
paused : false ,
position_secs : 0.0 ,
2026-07-23 18:48:58 +03:00
audio_analysis : crate ::player ::AudioAnalysisSnapshot ::default (),
2026-06-10 16:11:09 +01:00
track_started_at : None ,
2026-07-27 23:15:57 +01:00
listen_id : None ,
2026-06-10 16:11:09 +01:00
prefetched_pos : None ,
2026-06-10 16:52:09 +01:00
original_order : None ,
2026-06-10 16:11:09 +01:00
volume : 80 ,
shuffle : false ,
repeat : RepeatMode ::Off ,
}
}
}
2026-07-24 04:11:01 +03:00
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DevicePlaybackRole {
#[default]
Active ,
Control ,
2026-07-28 18:35:59 +01:00
Jam ,
2026-07-24 04:11:01 +03:00
}
impl DevicePlaybackRole {
pub fn label ( self ) -> & 'static str {
match self {
DevicePlaybackRole ::Active => "active" ,
DevicePlaybackRole ::Control => "control" ,
2026-07-28 18:35:59 +01:00
DevicePlaybackRole ::Jam => "jam" ,
2026-07-24 04:11:01 +03:00
}
}
}
#[derive(Debug, Default)]
pub struct DevicePlaybackState {
pub role : DevicePlaybackRole ,
pub self_device_id : String ,
pub self_device_name : String ,
pub active_device_id : Option < String > ,
pub active_device_name : Option < String > ,
pub online_devices : usize ,
2026-07-24 04:33:50 +03:00
pub local_idle_since_ms : Option < i64 > ,
2026-07-24 04:11:01 +03:00
pub remote : BTreeMap < String , crate ::devices ::PlaybackSnapshot > ,
pub last_remote_snapshot : Option < crate ::devices ::PlaybackSnapshot > ,
2026-07-28 18:35:59 +01:00
pub jam_host : bool ,
2026-07-28 22:59:35 +01:00
/// A freshly started TUI owns playback by protocol. The first active
/// snapshot discovered during startup is imported and handed off here.
pub startup_takeover_pending : bool ,
2026-07-24 04:11:01 +03:00
}
impl DevicePlaybackState {
pub fn is_control ( & self ) -> bool {
self . role == DevicePlaybackRole ::Control
2026-07-28 18:35:59 +01:00
|| ( self . role == DevicePlaybackRole ::Jam && ! self . jam_host )
}
2026-07-28 18:41:08 +01:00
pub fn is_personal_control ( & self ) -> bool {
self . role == DevicePlaybackRole ::Control
}
2026-07-28 18:35:59 +01:00
pub fn is_audio_owner ( & self ) -> bool {
self . role == DevicePlaybackRole ::Active
|| ( self . role == DevicePlaybackRole ::Jam && self . jam_host )
2026-07-24 04:11:01 +03:00
}
pub fn active_label ( & self ) -> String {
self . active_device_name
. clone ()
. or_else ( || self . active_device_id . clone ())
. unwrap_or_else ( || "this device" . to_string ())
}
}
2026-06-10 16:11:09 +01:00
/// Single source of truth for the UI. Mutated only by `update()` and the
/// event handlers in the main loop; views render from `&AppState`.
#[derive(Debug, Default)]
pub struct AppState {
pub active_tab : Tab ,
pub should_quit : bool ,
2026-07-25 23:24:49 +03:00
pub shutting_down : bool ,
2026-06-10 16:11:09 +01:00
/// Double-press quit confirmation: set by the first Quit press, expires
/// after a short window (any other action also cancels it).
pub quit_armed_until : Option < std ::time ::Instant > ,
pub help_visible : bool ,
pub pending_keys : Option < String > ,
pub status_message : Option < String > ,
2026-07-25 01:23:29 +03:00
pub spinner_frame : usize ,
2026-07-23 18:48:58 +03:00
pub settings_cursor : usize ,
2026-08-02 00:40:50 +01:00
/// Root for music permanently downloaded from federation peers.
pub music_dir : std ::path ::PathBuf ,
pub music_dir_changing : bool ,
2026-06-10 16:11:09 +01:00
pub player : PlayerBar ,
2026-07-24 04:11:01 +03:00
pub device_playback : DevicePlaybackState ,
2026-07-28 18:35:59 +01:00
pub jam : crate ::jam ::JamStatus ,
2026-07-23 18:48:58 +03:00
pub visualizer : crate ::visualizer ::VisualizerState ,
2026-06-10 16:11:09 +01:00
pub global : GlobalTab ,
pub artist_views : HashMap < i64 , Loadable < ArtistDetail >> ,
2026-07-25 23:57:03 +03:00
/// Federated card data that enriches a local artist page in-place.
pub artist_fed_views : HashMap < i64 , Loadable < crate ::federation ::FedArtistCard >> ,
2026-06-10 16:11:09 +01:00
pub release_views : HashMap < i64 , Loadable < ReleaseDetail >> ,
pub playlists : PlaylistsTab ,
pub playlist_views : HashMap < i64 , Loadable < PlaylistDetail >> ,
2026-07-25 01:23:29 +03:00
/// Liked local-library content ids, for the ♥ markers everywhere tracks are shown.
2026-07-25 23:24:49 +03:00
pub likes : HashSet < String > ,
2026-07-24 02:21:24 +03:00
/// Liked federated tracks (DHT item ids and content ids) — likes that
/// reference peers' tracks without downloading them.
2026-07-25 23:24:49 +03:00
pub fed_likes : HashSet < String > ,
/// Content ids that currently have a local playable file.
pub local_content_ids : HashSet < String > ,
2026-06-10 16:11:09 +01:00
pub likes_loaded : bool ,
2026-07-25 23:24:49 +03:00
pub local_content_ids_loaded : bool ,
2026-07-26 01:41:16 +03:00
pub local_library_stats : Option < Loadable < crate ::library ::LocalLibraryStats >> ,
2026-07-27 23:37:46 +01:00
pub listen_history : Option < Loadable < Vec < crate ::library ::ListenHistoryEntry >>> ,
2026-06-10 16:23:20 +01:00
pub logs : LogsTab ,
2026-06-10 16:52:09 +01:00
pub queue_tab : QueueTab ,
2026-07-16 20:29:51 +03:00
pub federation : FederationTab ,
2026-08-10 00:22:20 +01:00
pub similarity : SimilarityTab ,
2026-07-17 01:17:54 +03:00
/// The one federated artist card being viewed (name + loading state);
/// opening another card replaces it.
pub fed_artist_view : Option < ( String , Loadable < crate ::federation ::FedArtistCard > ) > ,
2026-06-15 12:28:34 +01:00
pub track_selection : TrackSelection ,
2026-06-10 17:01:40 +01:00
/// Shift-J jump in flight: focus this (release, track) once the release
/// view finishes loading.
pub pending_release_focus : Option < ( i64 , i64 ) > ,
/// Where a Shift-J jump came from (tab, stack depth of the pushed
/// view): Esc from that view returns to the origin tab instead of
/// unwinding the Global stack.
pub jump_origin : Option < ( Tab , usize ) > ,
2026-06-10 23:30:03 +01:00
pub popup : Option < Popup > ,
2026-06-10 16:11:09 +01:00
pub cmdline : Cmdline ,
pub search : SearchState ,
/// Shared image cache keyed by `art::cache_key(url, w, h)`; reused by
/// every view that shows artwork.
pub art : HashMap < String , ArtState > ,
}
2026-07-24 02:21:24 +03:00
impl AppState {
2026-07-25 01:23:29 +03:00
pub fn advance_spinner ( & mut self ) {
self . spinner_frame = self . spinner_frame . wrapping_add ( 1 );
}
pub fn spinner ( & self ) -> & 'static str {
const FRAMES : & [ & str ] = & [ "⠋" , "⠙" , "⠹" , "⠸" , "⠼" , "⠴" , "⠦" , "⠧" , "⠇" , "⠏" ];
FRAMES [ self . spinner_frame % FRAMES . len ()]
}
2026-07-24 02:21:24 +03:00
pub fn connected_devices_enabled ( & self ) -> bool {
self . federation . settings . enabled && ! self . federation . settings . network_id . trim (). is_empty ()
}
pub fn fed_track_liked ( & self , fed : & crate ::federation ::FedTrack ) -> bool {
self . fed_likes . contains ( & fed . item_id )
|| fed
. content_id
. as_deref ()
. and_then ( music_dht ::normalize_content_id )
2026-07-25 01:23:29 +03:00
. is_some_and ( | content_id | {
self . likes . contains ( & content_id ) || self . fed_likes . contains ( & content_id )
})
2026-07-24 02:21:24 +03:00
}
pub fn fed_card_track_liked ( & self , track : & crate ::federation ::FedCardTrack ) -> bool {
track
. content_id
. as_deref ()
. and_then ( music_dht ::normalize_content_id )
2026-07-25 01:23:29 +03:00
. is_some_and ( | content_id | {
self . likes . contains ( & content_id ) || self . fed_likes . contains ( & content_id )
})
2026-07-24 02:21:24 +03:00
|| track
. sources
. iter ()
. any ( | ( _ , item_id ) | self . fed_likes . contains ( item_id ))
}
2026-07-25 23:24:49 +03:00
pub fn content_id_local ( & self , content_id : & str ) -> bool {
music_dht ::normalize_content_id ( content_id )
. is_some_and ( | content_id | self . local_content_ids . contains ( & content_id ))
}
pub fn fed_track_local ( & self , track : & crate ::federation ::FedTrack ) -> bool {
track
. content_id
. as_deref ()
. is_some_and ( | content_id | self . content_id_local ( content_id ))
}
pub fn fed_card_track_local ( & self , track : & crate ::federation ::FedCardTrack ) -> bool {
track
. content_id
. as_deref ()
. is_some_and ( | content_id | self . content_id_local ( content_id ))
}
pub fn track_content_local ( & self , track : & TrackItem ) -> bool {
track_content_id ( track )
. is_some_and ( | content_id | self . local_content_ids . contains ( & content_id ))
}
2026-07-25 01:23:29 +03:00
pub fn track_liked ( & self , track : & TrackItem ) -> bool {
if let Some ( content_id ) = track_content_id ( track ) {
return self . likes . contains ( & content_id ) || self . fed_likes . contains ( & content_id );
}
track
. fed
. as_ref ()
. is_some_and ( | fed | self . fed_track_liked ( fed ))
}
}
2026-07-25 23:57:03 +03:00
pub fn artist_fed_card ( state : & AppState , id : i64 ) -> Option <& crate ::federation ::FedArtistCard > {
match state . artist_fed_views . get ( & id ) {
Some ( Loadable ::Ready ( card )) => Some ( card ),
_ => None ,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ArtistCursorAnchor {
TopTrack ( i64 ),
Release {
local_id : Option < i64 > ,
title_key : String ,
},
FeaturedTrack ( i64 ),
}
pub fn artist_cursor_anchor ( state : & AppState , id : i64 ) -> Option < ArtistCursorAnchor > {
let Some ( GlobalView ::Artist {
id : current_id ,
cursor ,
}) = state . global . stack . last ()
else {
return None ;
};
if * current_id != id {
return None ;
}
let Some ( Loadable ::Ready ( detail )) = state . artist_views . get ( & id ) else {
return None ;
};
if * cursor < detail . top_tracks . len () {
return detail
. top_tracks
. get ( * cursor )
. map ( | track | ArtistCursorAnchor ::TopTrack ( track . id ));
}
let releases = artist_merged_releases ( state , id , detail );
let release_order = artist_release_display_order ( & releases );
let release_position = cursor . checked_sub ( detail . top_tracks . len ()) ? ;
if let Some ( & slot_index ) = release_order . get ( release_position ) {
let slot = & releases [ slot_index ];
let local_id = slot
. local_index
. and_then ( | index | detail . releases . get ( index ))
. map ( | release | release . id );
return Some ( ArtistCursorAnchor ::Release {
local_id ,
title_key : release_merge_key ( & slot . title ),
});
}
let featured_position = cursor . checked_sub ( detail . top_tracks . len () + release_order . len ()) ? ;
detail
. featured_tracks
. get ( featured_position )
. map ( | track | ArtistCursorAnchor ::FeaturedTrack ( track . id ))
}
pub fn restore_artist_cursor_anchor (
state : & mut AppState ,
id : i64 ,
anchor : Option < ArtistCursorAnchor > ,
) {
let Some ( anchor ) = anchor else {
return ;
};
let Some ( Loadable ::Ready ( detail )) = state . artist_views . get ( & id ) else {
return ;
};
let releases = artist_merged_releases ( state , id , detail );
let release_order = artist_release_display_order ( & releases );
let next_cursor = match anchor {
ArtistCursorAnchor ::TopTrack ( track_id ) => detail
. top_tracks
. iter ()
. position ( | track | track . id == track_id ),
ArtistCursorAnchor ::Release {
local_id ,
title_key ,
} => release_order
. iter ()
. position ( |& slot_index | {
let slot = & releases [ slot_index ];
if let Some ( wanted_id ) = local_id {
let local_matches = slot
. local_index
. and_then ( | index | detail . releases . get ( index ))
. is_some_and ( | release | release . id == wanted_id );
if local_matches {
return true ;
}
}
release_merge_key ( & slot . title ) == title_key
})
. map ( | position | detail . top_tracks . len () + position ),
ArtistCursorAnchor ::FeaturedTrack ( track_id ) => detail
. featured_tracks
. iter ()
. position ( | track | track . id == track_id )
. map ( | position | detail . top_tracks . len () + release_order . len () + position ),
};
let Some ( next_cursor ) = next_cursor else {
return ;
};
if let Some ( GlobalView ::Artist {
id : current_id ,
cursor ,
}) = state . global . stack . last_mut ()
&& * current_id == id
{
* cursor = next_cursor ;
}
}
pub fn artist_merged_releases (
state : & AppState ,
id : i64 ,
detail : & ArtistDetail ,
) -> Vec < ArtistReleaseSlot > {
let mut slots : Vec < ArtistReleaseSlot > = detail
. releases
. iter ()
. enumerate ()
. map ( | ( index , release ) | ArtistReleaseSlot {
title : release . title . clone (),
release_type : release . release_type . clone (),
year : release . year ,
cover_path : release . cover_path . clone (),
local_index : Some ( index ),
fed_index : None ,
local_track_count : release . track_count . max ( 0 ) as usize ,
total_track_count : release . track_count . max ( 0 ) as usize ,
availability : release . availability ,
})
. collect ();
if ! state . global . filters . source_mode . includes_network () {
return slots ;
}
let mut by_key : HashMap < String , usize > = slots
. iter ()
. enumerate ()
. filter_map ( | ( slot_index , release ) | {
let key = release_merge_key ( & release . title );
( ! key . is_empty ()). then_some (( key , slot_index ))
})
. collect ();
let Some ( card ) = artist_fed_card ( state , id ) else {
return slots ;
};
for ( fed_index , release ) in card . releases . iter (). enumerate () {
let key = release_merge_key ( & release . title );
let existing = by_key . get ( & key ). copied ();
let local_from_fed = release
. tracks
. iter ()
. filter ( | track | state . fed_card_track_local ( track ))
. count ();
let fed_total = release . tracks . len ();
let local_count = existing
. and_then ( | slot_index | slots . get ( slot_index ))
. map ( | slot | slot . local_track_count )
. unwrap_or ( 0 )
. max ( local_from_fed );
let availability =
fed_release_slot_availability ( local_count , fed_total , existing . is_some ());
match existing {
Some ( slot_index ) => {
let slot = & mut slots [ slot_index ];
slot . fed_index = Some ( fed_index );
slot . release_type = prefer_text ( & slot . release_type , & release . release_type );
slot . year = slot . year . or ( release . year );
if slot . cover_path . is_none () {
slot . cover_path = release . cover_path . clone ();
}
slot . total_track_count = slot . total_track_count . max ( fed_total );
slot . local_track_count = slot . local_track_count . max ( local_from_fed );
slot . availability = availability ;
}
None => {
by_key . insert ( key , slots . len ());
slots . push ( ArtistReleaseSlot {
title : release . title . clone (),
release_type : release . release_type . clone (),
year : release . year ,
cover_path : release . cover_path . clone (),
local_index : None ,
fed_index : Some ( fed_index ),
local_track_count : local_from_fed ,
total_track_count : fed_total ,
availability ,
});
}
}
}
slots
}
pub fn fed_card_track_visible ( state : & AppState , track : & crate ::federation ::FedCardTrack ) -> bool {
state . global . filters . source_mode . includes_network () || state . fed_card_track_local ( track )
}
pub fn fed_release_visible_track_indices (
state : & AppState ,
release : & crate ::federation ::FedRelease ,
) -> Vec < usize > {
release
. tracks
. iter ()
. enumerate ()
. filter_map ( | ( index , track ) | fed_card_track_visible ( state , track ). then_some ( index ))
. collect ()
}
pub fn fed_release_visible ( state : & AppState , release : & crate ::federation ::FedRelease ) -> bool {
state . global . filters . source_mode . includes_network ()
|| release
. tracks
. iter ()
. any ( | track | state . fed_card_track_local ( track ))
}
pub fn fed_appearance_visible (
state : & AppState ,
appearance : & crate ::federation ::FedAppearsOn ,
) -> bool {
fed_card_track_visible ( state , & appearance . track )
}
pub fn fed_artist_visible_release_indices (
state : & AppState ,
card : & crate ::federation ::FedArtistCard ,
) -> Vec < usize > {
card . releases
. iter ()
. enumerate ()
. filter_map ( | ( index , release ) | fed_release_visible ( state , release ). then_some ( index ))
. collect ()
}
pub fn fed_artist_visible_appearance_indices (
state : & AppState ,
card : & crate ::federation ::FedArtistCard ,
) -> Vec < usize > {
card . appears_on
. iter ()
. enumerate ()
. filter_map ( | ( index , appearance ) | {
fed_appearance_visible ( state , appearance ). then_some ( index )
})
. collect ()
}
fn release_merge_key ( title : & str ) -> String {
music_dht ::normalize_name ( title )
}
fn prefer_text ( left : & str , right : & str ) -> String {
if left . trim (). is_empty () {
right . to_string ()
} else {
left . to_string ()
}
}
fn fed_release_slot_availability (
local : usize ,
total : usize ,
has_local_release : bool ,
) -> Availability {
if total == 0 {
return if has_local_release {
Availability ::Local
} else {
Availability ::Remote
};
}
if local == 0 {
Availability ::Remote
} else if local >= total {
Availability ::Local
} else {
Availability ::Mixed
}
}
2026-07-25 01:23:29 +03:00
pub fn track_content_id ( track : & TrackItem ) -> Option < String > {
track
. content_id
. as_deref ()
. and_then ( music_dht ::normalize_content_id )
. or_else ( || {
track
. fed
. as_ref ()
. and_then ( | fed | fed . content_id . as_deref ())
. and_then ( music_dht ::normalize_content_id )
})
}
pub fn track_key ( track : & TrackItem ) -> String {
if let Some ( content_id ) = track_content_id ( track ) {
return format! ( "content: {content_id} " );
}
if let Some ( fed ) = & track . fed {
return format! ( "fed: {} : {} " , fed . owner , fed . item_id );
}
format! ( "local: {} " , track . id )
2026-07-24 02:21:24 +03:00
}