Many UI fixes

This commit is contained in:
Ultradesu
2026-07-17 15:29:10 +03:00
parent 70261a3bf4
commit 151e2cf337
11 changed files with 889 additions and 515 deletions
+11 -3
View File
@@ -56,6 +56,12 @@ pub enum AppEvent {
track_id: i64,
liked: bool,
},
/// Liked federated item ids for the ♥ markers.
FedLikesLoaded(Result<Vec<String>, String>),
FedLikeToggled {
item_id: String,
liked: bool,
},
/// A release fetched for queueing (a / shift-a on a release).
EnqueueTracks {
tracks: Vec<TrackItem>,
@@ -107,9 +113,11 @@ pub enum AppEvent {
release: Option<String>,
path: String,
},
/// A federated track finished downloading and is ready to play.
FedPlayReady {
result: Result<crate::federation::FedPlayable, String>,
/// A pending federated track finished downloading (or failed); the
/// queue swaps the placeholder for the resolved track.
FedTrackResolved {
placeholder_id: i64,
result: Result<Box<crate::federation::FedPlayable>, String>,
},
/// This peer's connection ticket, requested from the Federation tab.
FedTicket(Result<String, String>),
+170 -32
View File
@@ -36,6 +36,8 @@ pub struct Runtime {
pub federation: Arc<crate::federation::Federation>,
/// When the last Federation-tab status snapshot was requested.
pub fed_status_at: Option<std::time::Instant>,
/// Placeholder ids of federated tracks being downloaded right now.
pub fed_resolving: std::sync::Mutex<std::collections::HashSet<i64>>,
/// Caps concurrent artwork loads so they never starve the disk.
pub art_semaphore: Arc<tokio::sync::Semaphore>,
/// Monotonic sequence for live search; stale responses are dropped.
@@ -82,6 +84,7 @@ pub async fn run(
library,
federation,
fed_status_at: None,
fed_resolving: std::sync::Mutex::new(std::collections::HashSet::new()),
art_semaphore: Arc::new(tokio::sync::Semaphore::new(4)),
search_seq: Arc::new(std::sync::atomic::AtomicU64::new(0)),
player: player::spawn(move |event| {
@@ -185,6 +188,8 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) {
tokio::task::spawn_blocking(move || {
let result = library.likes().map_err(err_string);
let _ = tx.send(AppEvent::LikesLoaded(result));
let result = library.fed_like_ids().map_err(err_string);
let _ = tx.send(AppEvent::FedLikesLoaded(result));
});
}
@@ -378,10 +383,12 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
}
});
}
Effect::ToggleLikes { track_ids } => {
// Ephemeral federated tracks (negative ids) are not in the DB.
Effect::ToggleLikes {
track_ids,
fed_tracks,
} => {
let track_ids: Vec<i64> = track_ids.into_iter().filter(|id| *id >= 0).collect();
if track_ids.is_empty() {
if track_ids.is_empty() && fed_tracks.is_empty() {
return;
}
let library = Arc::clone(&runtime.library);
@@ -400,6 +407,22 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
}
}
}
for fed in fed_tracks {
match library.toggle_fed_like(&fed) {
Ok(liked) => {
let _ = tx.send(AppEvent::FedLikeToggled {
item_id: fed.item_id.clone(),
liked,
});
}
Err(err) => {
tracing::warn!(%err, title = %fed.title, "federated like toggle failed");
let _ =
tx.send(AppEvent::StatusMessage(format!("like failed: {err:#}")));
break;
}
}
}
});
}
Effect::RemoveFromPlaylist {
@@ -478,17 +501,6 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
});
}
Effect::FedDownload { tracks } => fed_download_spawn(runtime, tracks, None),
Effect::FedPlay(fed_track) => {
let fed = Arc::clone(&runtime.federation);
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let result = fed
.prepare_playback(&fed_track)
.await
.map_err(|err| format!("{err:#}"));
let _ = tx.send(AppEvent::FedPlayReady { result });
});
}
Effect::RemoveQueueIndices {
restart_paused,
stop,
@@ -550,6 +562,14 @@ fn start_current_audio(
state.status_message = Some(format!("{}{}", track.title, track.artist_line()));
runtime.player_start_pending = true;
if track.is_fed_pending() {
// A federated track that is not on disk yet: silence the previous
// audio, download it and resume through FedTrackResolved.
runtime.player.stop();
state.status_message = Some(format!("federation: fetching \"{}\"", track.title));
spawn_fed_resolve(runtime, &track);
return;
}
let controller = runtime.player.clone();
let volume = player::amplitude(state.player.volume);
let tx = runtime.event_tx.clone();
@@ -606,6 +626,12 @@ fn maybe_prefetch_next(state: &mut AppState, runtime: &Runtime) {
let Some(next) = player.queue.get(next_pos).cloned() else {
return;
};
if next.is_fed_pending() {
// Download the upcoming federated track ahead of time; the gapless
// enqueue happens on a later tick once it resolved to a file.
spawn_fed_resolve(runtime, &next);
return;
}
state.player.prefetched_pos = Some(next_pos);
tracing::debug!(title = %next.title, "prefetching next track");
let controller = runtime.player.clone();
@@ -667,6 +693,38 @@ pub(crate) fn fed_connect(runtime: &Runtime, ticket: String) {
});
}
/// Downloads one pending federated track (into the cache, or the library
/// when save-on-listen is enabled) and reports back with the placeholder id
/// so the queue can swap the resolved track in.
fn spawn_fed_resolve(runtime: &Runtime, track: &crate::library::models::TrackItem) {
let Some(fed_track) = track.fed.clone() else {
return;
};
{
let mut resolving = runtime
.fed_resolving
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if !resolving.insert(track.id) {
return;
}
}
let placeholder_id = track.id;
let fed = Arc::clone(&runtime.federation);
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let result = fed
.prepare_playback(&fed_track)
.await
.map(Box::new)
.map_err(|err| format!("{err:#}"));
let _ = tx.send(AppEvent::FedTrackResolved {
placeholder_id,
result,
});
});
}
/// Downloads federated tracks into the library one by one (with progress in
/// the status bar) and optionally links them to a playlist afterwards.
pub(crate) fn fed_download_spawn(
@@ -893,8 +951,16 @@ fn on_library_changed(state: &mut AppState, runtime: &mut Runtime) {
// until then.
state.likes_loaded = false;
// Fresh copies of whatever sits in the queue.
let ids: Vec<i64> = state.player.queue.iter().map(|track| track.id).collect();
// Fresh copies of whatever sits in the queue. Federated placeholders
// and ephemeral tracks (negative ids) are not library rows and keep
// their in-memory copies.
let ids: Vec<i64> = state
.player
.queue
.iter()
.map(|track| track.id)
.filter(|id| *id >= 0)
.collect();
if !ids.is_empty() {
let library = Arc::clone(&runtime.library);
let tx = runtime.event_tx.clone();
@@ -956,9 +1022,12 @@ fn apply_queue_refresh(
.player
.queue
.iter()
.any(|track| !by_id.contains_key(&track.id));
.any(|track| track.id >= 0 && !by_id.contains_key(&track.id));
if had_missing {
state.player.queue.retain(|track| by_id.contains_key(&track.id));
state
.player
.queue
.retain(|track| track.id < 0 || by_id.contains_key(&track.id));
state.player.prefetched_pos = None;
}
if state.player.queue.is_empty() {
@@ -1018,22 +1087,72 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
Err(message) => tracing::warn!(%message, "federated search failed"),
}
}
AppEvent::FedPlayReady { result } => match result {
Ok(playable) => {
if playable.imported {
// Save-on-listen imported the file; refresh the library
// views through the standard change path.
let _ = runtime.event_tx.send(AppEvent::LibraryChanged {
message: Some(format!("saved \"{}\" to the library", playable.track.title)),
});
AppEvent::FedTrackResolved {
placeholder_id,
result,
} => {
runtime
.fed_resolving
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(&placeholder_id);
match result {
Ok(playable) => {
if playable.imported {
// Save-on-listen imported the file; refresh the
// library views through the standard change path.
let _ = runtime.event_tx.send(AppEvent::LibraryChanged {
message: Some(format!(
"saved \"{}\" to the library",
playable.track.title
)),
});
}
let resolved = playable.track.clone();
// Swap the placeholder for the real track everywhere it
// sits in the queue.
for slot in &mut state.player.queue {
if slot.id == placeholder_id {
*slot = resolved.clone();
}
}
let waiting = state
.player
.current
.as_ref()
.is_some_and(|current| current.id == placeholder_id);
if waiting {
// Playback was parked on this track; start it now.
let paused = state.player.paused;
start_current_audio(state, runtime, 0.0, paused);
push_media_metadata(state, runtime);
push_media_update(state, runtime, true);
}
}
Err(message) => {
state.status_message = Some(format!("federation: {message}"));
let waiting = state
.player
.current
.as_ref()
.is_some_and(|current| current.id == placeholder_id);
if waiting {
// Skip the failed track instead of stalling the queue.
if state.player.queue_pos + 1 < state.player.queue.len() {
state.player.queue_pos += 1;
start_current_audio(state, runtime, 0.0, state.player.paused);
push_media_metadata(state, runtime);
push_media_update(state, runtime, true);
} else {
runtime.player_start_pending = false;
state.player.playing = false;
state.player.current = None;
runtime.player.stop();
}
}
}
state.player.queue = vec![playable.track];
state.player.queue_pos = 0;
update::on_new_queue(state);
perform_effect(state, runtime, Effect::PlayCurrent);
}
Err(message) => state.status_message = Some(format!("federation: {message}")),
},
}
AppEvent::FedArtistLoaded { name, result } => {
if let Some((current, data)) = &mut state.fed_artist_view
&& *current == name
@@ -1237,6 +1356,25 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
}
Err(message) => tracing::warn!(%message, "likes load failed"),
},
AppEvent::FedLikesLoaded(result) => match result {
Ok(ids) => state.fed_likes = ids.into_iter().collect(),
Err(message) => tracing::warn!(%message, "federated likes load failed"),
},
AppEvent::FedLikeToggled { item_id, liked } => {
if liked {
state.fed_likes.insert(item_id);
} else {
state.fed_likes.remove(&item_id);
}
// The virtual Likes playlist is stale now; refetch on next open.
state.playlist_views.remove(&state::LIKES_PLAYLIST_ID);
state.playlists.list = None;
state.status_message = Some(if liked {
"♥ liked (federation)".to_string()
} else {
"like removed".to_string()
});
}
AppEvent::LikeToggled { track_id, liked } => {
if liked {
state.likes.insert(track_id);
+7
View File
@@ -627,6 +627,9 @@ pub struct AppState {
pub playlist_views: HashMap<i64, Loadable<PlaylistDetail>>,
/// Liked track ids, for the ♥ markers everywhere tracks are shown.
pub likes: std::collections::HashSet<i64>,
/// Liked federated tracks (DHT item ids) — likes that reference peers'
/// tracks without downloading them.
pub fed_likes: std::collections::HashSet<String>,
pub likes_loaded: bool,
pub logs: LogsTab,
pub queue_tab: QueueTab,
@@ -634,6 +637,10 @@ pub struct AppState {
/// 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>)>,
/// The "search this artist in the federation" button of the open local
/// artist view has the focus (reached by pressing Up from the first
/// row, like the download button on a federated release).
pub artist_fed_button: bool,
pub track_selection: TrackSelection,
/// Shift-J jump in flight: focus this (release, track) once the release
/// view finishes loading.
+144 -40
View File
@@ -30,6 +30,7 @@ pub enum Effect {
},
ToggleLikes {
track_ids: Vec<i64>,
fed_tracks: Vec<crate::federation::FedTrack>,
},
/// Remove tracks from a stored playlist in the library.
RemoveFromPlaylist {
@@ -47,8 +48,6 @@ pub enum Effect {
FedSyncNow,
/// Fetch this peer's ticket and show it in a popup.
FedShowTicket,
/// Download (or resolve) a federated track and play it.
FedPlay(crate::federation::FedTrack),
/// Assemble the federated artist card (fan-out to the owning peers).
FedOpenArtist(String),
/// Download federated tracks into the local library, one by one.
@@ -225,32 +224,52 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
None
}
Action::ToggleLike => {
let tracks = selected_tracks(state);
let track_ids: Vec<i64> = if tracks.is_empty() {
state
let mut tracks = selected_tracks(state);
if tracks.is_empty() {
tracks = state
.player
.current
.as_ref()
.map(|track| vec![track.id])
.unwrap_or_default()
} else {
tracks.into_iter().map(|track| track.id).collect()
};
if track_ids.is_empty() {
.map(|track| vec![track.clone()])
.unwrap_or_default();
}
// Local library rows are liked by id; federated tracks (pending
// or cached) are liked as references into the federation.
let mut track_ids: Vec<i64> = Vec::new();
let mut fed_tracks: Vec<crate::federation::FedTrack> = Vec::new();
for track in tracks {
match &track.fed {
Some(fed) => fed_tracks.push(fed.clone()),
None if track.id >= 0 => track_ids.push(track.id),
None => {}
}
}
if track_ids.is_empty() && fed_tracks.is_empty() {
state.status_message = Some("no track selected".into());
None
} else {
let should_like = track_ids.iter().any(|id| !state.likes.contains(id));
let should_like = track_ids.iter().any(|id| !state.likes.contains(id))
|| fed_tracks
.iter()
.any(|fed| !state.fed_likes.contains(&fed.item_id));
let toggles: Vec<i64> = track_ids
.into_iter()
.filter(|id| state.likes.contains(id) != should_like)
.collect();
let fed_toggles: Vec<crate::federation::FedTrack> = fed_tracks
.into_iter()
.filter(|fed| state.fed_likes.contains(&fed.item_id) != should_like)
.collect();
let total = toggles.len() + fed_toggles.len();
state.status_message = Some(if should_like {
format!("liking {} track(s)", toggles.len())
format!("liking {total} track(s)")
} else {
format!("removing like from {} track(s)", toggles.len())
format!("removing like from {total} track(s)")
});
Some(Effect::ToggleLikes { track_ids: toggles })
Some(Effect::ToggleLikes {
track_ids: toggles,
fed_tracks: fed_toggles,
})
}
}
Action::ToggleTrackSelection => {
@@ -503,7 +522,10 @@ fn delete_selected(state: &mut AppState) -> Option<Effect> {
.filter(|id| state.likes.contains(id))
.collect();
state.status_message = Some(format!("removing {} like(s)", liked.len()));
return Some(Effect::ToggleLikes { track_ids: liked });
return Some(Effect::ToggleLikes {
track_ids: liked,
fed_tracks: vec![],
});
}
state.status_message =
Some(format!("removing {} track(s) from playlist", track_ids.len()));
@@ -676,6 +698,9 @@ fn set_track_scope_cursor(state: &mut AppState, scope: &TrackSelectionScope, val
}
fn current_track_list_context(state: &AppState) -> Option<(TrackSelectionScope, usize, usize)> {
if state.artist_fed_button {
return None;
}
match state.active_tab {
Tab::Global => match state.global.stack.last()? {
GlobalView::Artist { id, cursor } => match state.artist_views.get(id)? {
@@ -786,6 +811,17 @@ fn current_track_list(state: &AppState) -> Option<(TrackSelectionScope, usize, &
}
pub fn selected_tracks(state: &AppState) -> Vec<TrackItem> {
if state.artist_fed_button {
return Vec::new();
}
// Federated contexts produce queueable placeholders that behave like
// regular tracks (queue, info, playback-on-demand).
{
let fed = selected_fed_tracks(state);
if !fed.is_empty() {
return fed.iter().map(crate::federation::pending_track).collect();
}
}
let Some((scope, cursor, tracks)) = current_track_list(state) else {
return selected_track(state).into_iter().collect();
};
@@ -915,6 +951,9 @@ fn remove_queue_indices(state: &mut AppState, indices: &[usize]) -> QueueRemoval
/// The track under the cursor in whatever view is showing tracks.
pub fn selected_track(state: &AppState) -> Option<TrackItem> {
if state.artist_fed_button {
return None;
}
match state.active_tab {
Tab::Global => match state.global.stack.last()? {
GlobalView::Artist { id, cursor } => match state.artist_views.get(id)? {
@@ -937,9 +976,31 @@ pub fn selected_track(state: &AppState) -> Option<TrackItem> {
GlobalView::Search { cursor } => {
let results = state.search.results.as_ref()?;
let offset = cursor.checked_sub(results.artists.len() + results.releases.len())?;
results.tracks.get(offset).cloned()
match results.tracks.get(offset) {
Some(track) => Some(track.clone()),
// Below the local tracks: the federated section.
None => {
let fed_offset = offset
.checked_sub(results.tracks.len() + state.search.fed_artists.len())?;
state
.search
.fed_tracks
.get(fed_offset)
.map(crate::federation::pending_track)
}
}
}
GlobalView::FedArtist { .. } => None,
GlobalView::FedRelease { index, cursor } => {
let name = state.fed_artist_view.as_ref().map(|(name, _)| name.clone())?;
let release = fed_card_release(state, *index)?;
release
.tracks
.get(cursor.checked_sub(1)?)
.and_then(|track| fed_track_from_card(&name, release, track))
.as_ref()
.map(crate::federation::pending_track)
}
GlobalView::FedArtist { .. } | GlobalView::FedRelease { .. } => None,
},
Tab::Playlists => {
let opened = state.playlists.opened.as_ref()?;
@@ -1013,6 +1074,10 @@ fn queue_add(state: &mut AppState, next: bool) -> Option<Effect> {
/// track. If the release view is still loading, the focus is applied when
/// it arrives (`pending_release_focus`).
fn open_release_for_track(state: &mut AppState, track: &TrackItem) {
if track.release_id < 0 {
state.status_message = Some("this federated track has no local release".into());
return;
}
let release_id = track.release_id;
let cursor = match state.release_views.get(&release_id) {
Some(Loadable::Ready(detail)) => detail
@@ -1338,6 +1403,17 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) {
state.track_selection.clear();
}
Some(GlobalView::Artist { id, cursor }) => {
if state.artist_fed_button {
// Down leaves the federation-search button; other keys stay.
if dy > 0 {
state.artist_fed_button = false;
}
return;
}
if cursor == 0 && dy < 0 && state.federation.settings.enabled {
state.artist_fed_button = true;
return;
}
let Some(Loadable::Ready(detail)) = state.artist_views.get(&id) else {
return;
};
@@ -1503,6 +1579,7 @@ fn current_view_len(state: &AppState) -> usize {
}
fn jump_selection(state: &mut AppState, first: bool) {
state.artist_fed_button = false;
if state.track_selection.is_active()
&& let Some((scope, _, len)) = current_track_list_context(state)
{
@@ -1634,6 +1711,15 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
}),
None => Outcome::Nothing,
},
Some(GlobalView::Artist { id, cursor: _ }) if state.artist_fed_button => {
match state.artist_views.get(&id) {
Some(Loadable::Ready(detail)) => {
state.artist_fed_button = false;
Outcome::OpenFedArtist(detail.name.clone())
}
_ => Outcome::Nothing,
}
}
Some(GlobalView::Artist { id, cursor }) => match state.artist_views.get(&id) {
Some(Loadable::Ready(detail)) => {
let tracks = detail.top_tracks.len();
@@ -1723,19 +1809,20 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
tracks => Outcome::DownloadFed(tracks),
}
}
Some(release) => {
let name = state
.fed_artist_view
.as_ref()
.map(|(name, _)| name.clone())
.unwrap_or_default();
match release
.tracks
.get(cursor - 1)
.and_then(|track| fed_track_from_card(&name, release, track))
{
Some(fed) => Outcome::PlayFed(fed),
None => Outcome::Nothing,
Some(_) => {
// Play the whole release starting at the selected track,
// exactly like a local release view.
let tracks: Vec<_> = fed_release_tracks(state, index)
.iter()
.map(crate::federation::pending_track)
.collect();
if tracks.is_empty() || cursor > tracks.len() {
Outcome::Nothing
} else {
Outcome::Play {
tracks,
start: cursor - 1,
}
}
}
None => Outcome::Nothing,
@@ -1743,6 +1830,7 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
};
match outcome {
Outcome::Push(view) => {
state.artist_fed_button = false;
state.global.stack.push(view);
None
}
@@ -1752,10 +1840,6 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
on_new_queue(state);
Some(Effect::PlayCurrent)
}
Outcome::PlayFed(fed) => {
state.status_message = Some(format!("federation: fetching \"{}\"", fed.title));
Some(Effect::FedPlay(fed))
}
Outcome::OpenFedArtist(name) => {
state.fed_artist_view = Some((name.clone(), Loadable::Loading));
state.global.stack.push(GlobalView::FedArtist { cursor: 0 });
@@ -1853,22 +1937,30 @@ enum Outcome {
tracks: Vec<crate::library::models::TrackItem>,
start: usize,
},
PlayFed(crate::federation::FedTrack),
OpenFedArtist(String),
DownloadFed(Vec<crate::federation::FedTrack>),
Nothing,
}
/// Enter inside the federated section of the search results: artists open
/// their card, tracks play.
/// their card, tracks queue up like a local track list.
fn fed_outcome(state: &AppState, fed_index: usize) -> Outcome {
let artists = &state.search.fed_artists;
if fed_index < artists.len() {
return Outcome::OpenFedArtist(artists[fed_index].name.clone());
}
match state.search.fed_tracks.get(fed_index - artists.len()) {
Some(fed) => Outcome::PlayFed(fed.clone()),
None => Outcome::Nothing,
let start = fed_index - artists.len();
if start >= state.search.fed_tracks.len() {
return Outcome::Nothing;
}
Outcome::Play {
tracks: state
.search
.fed_tracks
.iter()
.map(crate::federation::pending_track)
.collect(),
start,
}
}
@@ -1888,6 +1980,9 @@ fn fed_track_from_card(
artist_names: vec![artist.to_string()],
year: release.year,
duration_seconds: track.duration_seconds.map(|d| d.round() as i64),
release_title: Some(release.title.clone()),
track_number: track.track_number,
disc_number: track.disc_number,
})
}
@@ -1966,6 +2061,7 @@ fn shuffle_range(player: &mut super::state::PlayerBar, start: usize) {
/// Esc/Backspace: pop the navigation stack; leaving a search view resets the
/// search so the next `:/` starts clean.
fn go_back(state: &mut AppState) {
state.artist_fed_button = false;
state.track_selection.clear();
match state.active_tab {
Tab::Playlists => {
@@ -1995,6 +2091,7 @@ fn go_back(state: &mut AppState) {
}
fn switch_tab(state: &mut AppState, tab: Tab) {
state.artist_fed_button = false;
state.active_tab = tab;
state.help_visible = false;
state.track_selection.clear();
@@ -2070,6 +2167,7 @@ mod tests {
audio_bit_depth: None,
file_size_bytes: None,
play_count: 0,
fed: None,
}
}
@@ -2294,6 +2392,7 @@ mod tests {
audio_bit_depth: None,
file_size_bytes: None,
play_count: 0,
fed: None,
};
let mut state = AppState::default();
state.player.queue = vec![track(1), track(2)];
@@ -2357,6 +2456,7 @@ mod tests {
audio_bit_depth: None,
file_size_bytes: None,
play_count: 0,
fed: None,
};
let mut state = AppState {
active_tab: Tab::Queue,
@@ -2485,6 +2585,7 @@ mod tests {
update(&mut state, Action::ToggleLike),
Some(Effect::ToggleLikes {
track_ids: vec![2, 3],
fed_tracks: vec![],
})
);
@@ -2493,6 +2594,7 @@ mod tests {
update(&mut state, Action::ToggleLike),
Some(Effect::ToggleLikes {
track_ids: vec![1, 2, 3],
fed_tracks: vec![],
})
);
}
@@ -2519,6 +2621,7 @@ mod tests {
audio_bit_depth: None,
file_size_bytes: None,
play_count: 0,
fed: None,
};
let mut state = AppState::default();
state.player.queue = (1..=8).map(track).collect();
@@ -2564,6 +2667,7 @@ mod tests {
audio_bit_depth: None,
file_size_bytes: None,
play_count: 0,
fed: None,
};
let mut state = AppState {
active_tab: Tab::Queue,