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:
10 files changed
+236
-19
No files matched your search
Generated
+61
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user