fix(queue): restore infinite play (V2 reimplementation)

Issue #319: Infinite Play toggle had no backend wiring after the V2
migration — toggle only wrote to localStorage and the queue-end handler
ignored it, so playback stopped at the last track.

Reimplemented in V2 (no legacy wrappers):

- New `v2_create_infinite_radio` command that takes the last few played
  track IDs, uses the most recent track's artist as the radio seed via
  `RadioPoolBuilder::create_artist_radio` (Qobuz `/radio/artist`
  endpoint), and returns 50 V2QueueTrack ready to append. Does NOT
  mutate the queue or playback context — caller decides how to use them.

- `playbackPreferencesStore`: expose `'infinite'` in `AutoplayMode`
  type and add `isInfinitePlayEnabled()` helper.

- `+page.svelte`:
  - `handleToggleInfinitePlay` now syncs to backend via
    `setAutoplayMode('infinite' | 'continue')` so the choice persists
    in `playback_preferences.db` across restarts (was localStorage only).
  - On startup load infinite play from cached preferences (was
    localStorage).
  - When `nextTrack()` returns null AND infinite play is on: collect
    the current track + last 5 history tracks, call
    `v2_create_infinite_radio`, append the result via
    `v2_bulk_add_to_queue`, then advance — playback continues without
    interruption.

i18n: add `toast.failedToSavePreference` to all 5 locales.

cc
This commit is contained in:
vicrodh committed 2026-04-15 21:20:04 -06:00
1 parent 3d0df4afba
commit 33d65681c4
10 files changed
+236 -19

No files matched your search

+125
View File
@@ -1636,6 +1636,131 @@ pub async fn v2_create_qobuz_track_radio(
Ok(context_id)
}
/// Create an infinite radio session based on recent tracks.
///
/// V2 reimplementation of the legacy `create_infinite_radio` command.
/// Uses the most recent track's artist as the radio seed (via Qobuz `/radio/artist`
/// through `RadioPoolBuilder`) and returns 50 generated tracks WITHOUT touching
/// the queue or playback context. The frontend appends them to the existing
/// queue so the user's current listening session is preserved.
///
/// Called when the queue is about to end and `AutoplayMode::InfiniteRadio` is on.
#[tauri::command]
pub async fn v2_create_infinite_radio(
recent_track_ids: Vec<u64>,
state: State<'_, AppState>,
blacklist_state: State<'_, BlacklistState>,
runtime: State<'_, RuntimeManagerState>,
) -> Result<Vec<super::queue::V2QueueTrack>, String> {
runtime
.manager()
.check_requirements(CommandRequirement::RequiresCoreBridgeAuth)
.await
.map_err(|e| e.to_string())?;
if recent_track_ids.is_empty() {
return Err("No recent tracks provided for infinite radio".to_string());
}
log::info!(
"[V2 Radio] Creating infinite radio from {} recent tracks: {:?}",
recent_track_ids.len(),
recent_track_ids
);
let client = state.client.read().await.clone();
let primary_track = client
.get_track(recent_track_ids[0])
.await
.map_err(|e| format!("Failed to fetch primary track: {}", e))?;
let artist_id = primary_track
.performer
.as_ref()
.map(|p| p.id)
.ok_or("Primary track has no artist")?;
let session_id = tokio::task::spawn_blocking({
let client = client.clone();
move || -> Result<String, String> {
let radio_db = crate::radio_engine::db::RadioDb::open_default()?;
let builder = crate::radio_engine::RadioPoolBuilder::new(
&radio_db,
&client,
crate::radio_engine::BuildRadioOptions::default(),
);
let rt = tokio::runtime::Handle::current();
let session = rt.block_on(builder.create_artist_radio(artist_id))?;
Ok(session.id)
}
})
.await
.map_err(|e| format!("Infinite radio task failed: {}", e))??;
log::info!("[V2 Radio] Infinite radio session created: {}", session_id);
let track_ids = tokio::task::spawn_blocking({
let session_id = session_id.clone();
move || -> Result<Vec<u64>, String> {
let radio_db = crate::radio_engine::db::RadioDb::open_default()?;
let radio_engine = crate::radio_engine::RadioEngine::new(radio_db);
let mut ids = Vec::new();
for _ in 0..60 {
match radio_engine.next_track(&session_id) {
Ok(radio_track) => ids.push(radio_track.track_id),
Err(_) => break,
}
}
Ok(ids.into_iter().take(50).collect())
}
})
.await
.map_err(|e| format!("Track generation task failed: {}", e))??;
let client = state.client.read().await;
let mut tracks: Vec<super::queue::V2QueueTrack> = Vec::new();
let recent_ids_set: std::collections::HashSet<u64> =
recent_track_ids.iter().copied().collect();
for next_track_id in track_ids {
if recent_ids_set.contains(&next_track_id) {
continue;
}
if let Ok(track) = client.get_track(next_track_id).await {
if let Some(ref performer) = track.performer {
if blacklist_state.is_blacklisted(performer.id) {
continue;
}
}
let core: CoreQueueTrack = track_to_queue_track_from_api(&track);
tracks.push(super::queue::V2QueueTrack {
id: core.id,
title: core.title,
artist: core.artist,
album: core.album,
duration_secs: core.duration_secs,
artwork_url: core.artwork_url,
hires: core.hires,
bit_depth: core.bit_depth,
sample_rate: core.sample_rate,
is_local: core.is_local,
album_id: core.album_id,
artist_id: core.artist_id,
streamable: core.streamable,
source: core.source,
parental_warning: core.parental_warning,
});
}
}
if tracks.is_empty() {
return Err("Failed to generate any infinite radio tracks".to_string());
}
log::info!("[V2 Radio] Returning {} infinite radio tracks", tracks.len());
Ok(tracks)
}
fn track_to_queue_track_from_api(track: &crate::api::Track) -> CoreQueueTrack {
let artwork_url = track
.album
+1
View File
@@ -1462,6 +1462,7 @@ pub fn run() {
commands_v2::v2_create_album_radio,
commands_v2::v2_create_qobuz_artist_radio,
commands_v2::v2_create_qobuz_track_radio,
commands_v2::v2_create_infinite_radio,
commands_v2::v2_delete_playlist_folder,
commands_v2::v2_reorder_playlist_folders,
commands_v2::v2_move_playlist_to_folder,