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

Generated
+61
View File
@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1775888245,
"narHash": "sha256-nwASzrRDD1JBEu/o8ekKYEXm/oJW6EMCzCRdrwcLe90=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "13043924aaa7375ce482ebe2494338e058282925",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+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,
+1
View File
@@ -1640,6 +1640,7 @@
"addedTracksToQueue": "{count} Titel zur Warteschlange hinzugefügt",
"failedPreviousTrack": "Zum vorherigen Titel wechseln fehlgeschlagen",
"failedNextTrack": "Zum nächsten Titel wechseln fehlgeschlagen",
"failedToSavePreference": "Einstellung konnte nicht gespeichert werden",
"tracksAddedToPlaylist": "Titel zur Playlist hinzugefügt",
"playlistCreated": "Playlist erstellt",
"offlineModeStarted": "Im Offline-Modus gestartet",
+1
View File
@@ -1670,6 +1670,7 @@
"addedTracksToQueue": "Added {count} tracks to queue",
"failedPreviousTrack": "Failed to go to previous track",
"failedNextTrack": "Failed to go to next track",
"failedToSavePreference": "Failed to save preference",
"tracksAddedToPlaylist": "Track(s) added to playlist",
"playlistCreated": "Playlist created",
"playlistUpdated": "Playlist updated",
+1
View File
@@ -1669,6 +1669,7 @@
"addedTracksToQueue": "{count} pistas agregadas a la cola",
"failedPreviousTrack": "Error al ir a pista anterior",
"failedNextTrack": "Error al ir a siguiente pista",
"failedToSavePreference": "Error al guardar preferencia",
"tracksAddedToPlaylist": "Pista(s) agregada(s) a la playlist",
"playlistCreated": "Playlist creada",
"playlistUpdated": "Playlist actualizada",
+1
View File
@@ -1640,6 +1640,7 @@
"addedTracksToQueue": "{count} titres ajoutés à la file d'attente",
"failedPreviousTrack": "Impossible de revenir au titre précédent",
"failedNextTrack": "Impossible de passer au titre suivant",
"failedToSavePreference": "Échec de l'enregistrement de la préférence",
"tracksAddedToPlaylist": "Titre(s) ajouté(s) à la playlist",
"playlistCreated": "Playlist créée",
"offlineModeStarted": "Démarré en mode hors ligne",
+1
View File
@@ -1663,6 +1663,7 @@
"addedTracksToQueue": "{count} faixas adicionadas à fila",
"failedPreviousTrack": "Falha ao voltar à faixa anterior",
"failedNextTrack": "Falha ao avançar para a próxima faixa",
"failedToSavePreference": "Falha ao salvar preferência",
"tracksAddedToPlaylist": "Faixa(s) adicionada(s) à playlist",
"playlistCreated": "Playlist criada",
"playlistUpdated": "Playlist atualizada",
+8 -1
View File
@@ -9,7 +9,7 @@ import { skipIfRemote } from '$lib/services/commandRouter';
// ============ Types ============
export type AutoplayMode = 'continue' | 'track_only';
export type AutoplayMode = 'continue' | 'track_only' | 'infinite';
export interface PlaybackPreferences {
autoplay_mode: AutoplayMode;
@@ -99,6 +99,13 @@ export function isAutoplayEnabled(): boolean {
return preferences.autoplay_mode === 'continue';
}
/**
* Check if infinite play (radio) is enabled
*/
export function isInfinitePlayEnabled(): boolean {
return preferences.autoplay_mode === 'infinite';
}
/**
* Subscribe to preference changes
*/
+36 -18
View File
@@ -55,7 +55,9 @@
import {
initPlaybackPreferences,
getCachedPreferences,
isAutoplayEnabled
isAutoplayEnabled,
isInfinitePlayEnabled,
setAutoplayMode
} from '$lib/stores/playbackPreferencesStore';
import { initBlacklistStore, isBlacklisted as isArtistBlacklisted } from '$lib/stores/artistBlacklistStore';
import { initCustomArtistImageStore, clearCustomArtistImages } from '$lib/stores/customArtistImageStore';
@@ -2475,7 +2477,30 @@
setIsSkipping(true);
try {
const nextTrackResult = await nextTrack();
let nextTrackResult = await nextTrack();
if (!nextTrackResult && infinitePlayEnabled) {
// Queue ended with infinite play on — fetch radio tracks based on
// the last 5 played + current track, append, then advance.
const recentIds: number[] = [];
if (playerState.currentTrack) recentIds.push(playerState.currentTrack.id);
for (const item of historyTracks.slice(0, 5)) {
const numericId = (item as any).trackId;
if (typeof numericId === 'number') recentIds.push(numericId);
}
if (recentIds.length > 0) {
try {
const radioTracks = await invoke<BackendQueueTrack[]>('v2_create_infinite_radio', {
recentTrackIds: recentIds.slice(0, 5)
});
if (radioTracks && radioTracks.length > 0) {
await invoke('v2_bulk_add_to_queue', { tracks: radioTracks });
nextTrackResult = await nextTrack();
}
} catch (err) {
console.error('Failed to extend queue with infinite radio:', err);
}
}
}
if (nextTrackResult) {
await playQueueTrack(nextTrackResult);
} else {
@@ -2700,15 +2725,16 @@
}
// Toggle infinite play mode (auto-refill queue with similar tracks)
function handleToggleInfinitePlay() {
infinitePlayEnabled = !infinitePlayEnabled;
// Persist to localStorage
async function handleToggleInfinitePlay() {
const next = !infinitePlayEnabled;
try {
setUserItem('qbz-infinite-play', JSON.stringify(infinitePlayEnabled));
} catch {
// Ignore storage errors
await setAutoplayMode(next ? 'infinite' : 'continue');
infinitePlayEnabled = next;
showToast(next ? $t('toast.infinitePlayEnabled') : $t('toast.infinitePlayDisabled'), 'info');
} catch (err) {
console.error('Failed to set autoplay mode:', err);
showToast($t('toast.failedToSavePreference'), 'error');
}
showToast(infinitePlayEnabled ? $t('toast.infinitePlayEnabled') : $t('toast.infinitePlayDisabled'), 'info');
}
// Play a track from history
@@ -4125,15 +4151,7 @@
// Initialize playback context store (local state only, no backend calls)
initPlaybackContextStore();
// Load infinite play preference
try {
const stored = getUserItem('qbz-infinite-play');
if (stored !== null) {
infinitePlayEnabled = JSON.parse(stored);
}
} catch {
// Ignore storage errors
}
infinitePlayEnabled = isInfinitePlayEnabled();
// NOTE: Audio settings are loaded AFTER session is ready (in handleLoginSuccess)
// to avoid "No active session" errors from per-user state