feat(session): opt-in resume playback position on session restore (#317)

Adds a separate "Resume playback position" toggle in Settings >
Playback. Default is OFF — opening qbz the next morning lands on a
clean 0:00 of the saved track, which is the common preference. Users
who do want bookmark-style resume turn the toggle on.

Independent of "Restore session on startup" in the UI (top-level
toggle, not a sub-setting). The seek only applies when both are on:
without persistSession there's no saved track to resume to, so the
flag is inert. Keeping the toggles separate matches the Settings UX
elsewhere and lets users flip persistence on without inheriting the
old #317 behavior we never had.

Backend
-------
- New `PlaybackPreferences::resume_playback_position: bool` (default
  false). Migration adds the column to the existing
  `playback_preferences.db` and the default row gets 0; existing
  installs upgrade transparently.
- Setter `set_resume_playback_position(bool)` on both
  `PlaybackPreferencesStore` and the wrapping `State`.
- Tauri command `v2_set_resume_playback_position` registered in
  lib.rs invoke handler. Get-side already reads the new field via
  the existing `v2_get_playback_preferences`.

Frontend
--------
- `playbackPreferencesStore.ts` — new field on the type and cache,
  new `setResumePlaybackPosition()` exported.
- `SettingsView.svelte` — separate toggle row right below
  "Restore session on startup" (matches the existing Settings rhythm).
- `playerStore.ts` — `pendingSessionRestore` extended with optional
  `positionSecs`. When present, after the restored track loads, the
  position is queued through the existing `pendingSeekPosition` /
  `flushPendingSeek` path so the seek runs against an already-loaded
  stream and respects the in-flight guard. Skip the queue when the
  saved position is at or near 0 (the load already starts there).
- `routes/+page.svelte` — at session restore, reads
  `getCachedPreferences().resume_playback_position` and forwards
  `session.current_position_secs` only when the user opted in. The
  field has been persisted in the schema since v1.2.5 — what changes
  is whether we read it back.

i18n
----
- New keys in 5 locales: `settings.playback.resumePlaybackPosition` /
  `…Desc`.
- `persistSessionDesc` updated everywhere — it used to claim the
  feature restored playback position, which was always misleading
  (the code restored the track but paused at 0:00). The text now
  matches what the toggle does on its own: queue + view, paused at
  0:00. Resume-to-position becomes the explicit ask of the new toggle.

Tests
-----
- Backend: 196 tests pass. Frontend: 0 TS errors.

cc
This commit is contained in:
vicrodh committed 2026-04-27 21:29:29 -06:00
1 parent 66f10ebc7a
commit 5bfb748876
12 files changed
+181 -22

No files matched your search

+8
View File
@@ -96,6 +96,14 @@ pub fn v2_set_persist_session(
state.set_persist_session(persist)
}
#[tauri::command]
pub fn v2_set_resume_playback_position(
resume: bool,
state: State<'_, PlaybackPreferencesState>,
) -> Result<(), String> {
state.set_resume_playback_position(resume)
}
#[tauri::command]
pub fn v2_get_playback_preferences(
state: State<'_, PlaybackPreferencesState>,
+68 -5
View File
@@ -50,6 +50,13 @@ pub struct PlaybackPreferences {
pub autoplay_mode: AutoplayMode,
pub show_context_icon: bool,
pub persist_session: bool,
/// Sub-preference of `persist_session`. When true, restoring a
/// session also seeks to `current_position_secs` of the saved
/// track. When false (default), the saved track is shown paused at
/// 0:00 and the user starts the next listen fresh — the common
/// preference for "open app the next morning to a clean slate"
/// (issue #317).
pub resume_playback_position: bool,
}
impl Default for PlaybackPreferences {
@@ -58,6 +65,7 @@ impl Default for PlaybackPreferences {
autoplay_mode: AutoplayMode::ContinueWithinSource,
show_context_icon: false,
persist_session: false,
resume_playback_position: false,
}
}
}
@@ -140,10 +148,31 @@ impl PlaybackPreferencesStore {
info!("[PlaybackPrefs] persist_session migration successful");
}
// Step 3c: Check if resume_playback_position column exists
let resume_position_exists: bool = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('playback_preferences') WHERE name='resume_playback_position'",
[],
|row| {
let count: i32 = row.get(0)?;
Ok(count > 0)
}
)
.unwrap_or(false);
if !resume_position_exists {
info!("[PlaybackPrefs] Migrating: adding resume_playback_position column");
conn.execute(
"ALTER TABLE playback_preferences ADD COLUMN resume_playback_position INTEGER NOT NULL DEFAULT 0",
[]
).map_err(|e| format!("Failed to add resume_playback_position column: {}", e))?;
info!("[PlaybackPrefs] resume_playback_position migration successful");
}
// Step 4: Insert default row if it doesn't exist
conn.execute(
"INSERT OR IGNORE INTO playback_preferences (id, autoplay_mode, show_context_icon, persist_session)
VALUES (1, 'continue', 0, 0)",
"INSERT OR IGNORE INTO playback_preferences (id, autoplay_mode, show_context_icon, persist_session, resume_playback_position)
VALUES (1, 'continue', 0, 0, 0)",
[],
)
.map_err(|e| format!("Failed to insert default preferences: {}", e))?;
@@ -165,16 +194,18 @@ impl PlaybackPreferencesStore {
pub fn get_preferences(&self) -> Result<PlaybackPreferences, String> {
self.conn
.query_row(
"SELECT autoplay_mode, show_context_icon, persist_session FROM playback_preferences WHERE id = 1",
"SELECT autoplay_mode, show_context_icon, persist_session, resume_playback_position FROM playback_preferences WHERE id = 1",
[],
|row| {
let autoplay_str: String = row.get(0)?;
let show_icon: i32 = row.get(1)?;
let persist: i32 = row.get(2)?;
let resume_pos: i32 = row.get(3)?;
Ok(PlaybackPreferences {
autoplay_mode: AutoplayMode::from_db_value(&autoplay_str),
show_context_icon: show_icon != 0,
persist_session: persist != 0,
resume_playback_position: resume_pos != 0,
})
},
)
@@ -211,13 +242,28 @@ impl PlaybackPreferencesStore {
Ok(())
}
pub fn set_resume_playback_position(&self, resume: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE playback_preferences SET resume_playback_position = ?1 WHERE id = 1",
params![if resume { 1 } else { 0 }],
)
.map_err(|e| format!("Failed to set resume playback position: {}", e))?;
Ok(())
}
/// Reset all playback preferences to their default values
pub fn reset_all(&self) -> Result<PlaybackPreferences, String> {
let defaults = PlaybackPreferences::default();
self.conn
.execute(
"UPDATE playback_preferences SET autoplay_mode = ?1, show_context_icon = ?2, persist_session = ?3 WHERE id = 1",
params![defaults.autoplay_mode.to_db_value(), if defaults.show_context_icon { 1 } else { 0 }, if defaults.persist_session { 1 } else { 0 }],
"UPDATE playback_preferences SET autoplay_mode = ?1, show_context_icon = ?2, persist_session = ?3, resume_playback_position = ?4 WHERE id = 1",
params![
defaults.autoplay_mode.to_db_value(),
if defaults.show_context_icon { 1 } else { 0 },
if defaults.persist_session { 1 } else { 0 },
if defaults.resume_playback_position { 1 } else { 0 },
],
)
.map_err(|e| format!("Failed to reset playback preferences: {}", e))?;
Ok(defaults)
@@ -297,6 +343,15 @@ impl PlaybackPreferencesState {
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_persist_session(persist)
}
pub fn set_resume_playback_position(&self, resume: bool) -> Result<(), String> {
let guard = self
.store
.lock()
.map_err(|_| "Failed to lock playback preferences store".to_string())?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_resume_playback_position(resume)
}
}
// Tauri commands
@@ -337,3 +392,11 @@ pub fn set_persist_session(
) -> Result<(), String> {
state.set_persist_session(persist)
}
#[tauri::command]
pub fn set_resume_playback_position(
resume: bool,
state: tauri::State<PlaybackPreferencesState>,
) -> Result<(), String> {
state.set_resume_playback_position(resume)
}
+1
View File
@@ -1392,6 +1392,7 @@ pub fn run() {
commands_v2::v2_set_autoplay_mode,
commands_v2::v2_set_show_context_icon,
commands_v2::v2_set_persist_session,
commands_v2::v2_set_resume_playback_position,
commands_v2::v2_get_playback_preferences,
commands_v2::v2_get_favorites_preferences,
commands_v2::v2_save_favorites_preferences,
@@ -144,6 +144,7 @@
setAutoplayMode,
setShowContextIcon,
setPersistSession,
setResumePlaybackPosition,
type AutoplayMode
} from '$lib/stores/playbackPreferencesStore';
import {
@@ -984,6 +985,7 @@
let autoplayMode = $state<AutoplayMode>('continue');
let showContextIcon = $state(true);
let persistSession = $state(false);
let resumePlaybackPosition = $state(false);
let gaplessPlayback = $state(true);
let crossfade = $state(0);
let normalizeVolume = $state(false);
@@ -3293,9 +3295,11 @@
autoplayMode = prefs.autoplay_mode;
showContextIcon = prefs.show_context_icon;
persistSession = prefs.persist_session;
resumePlaybackPosition = prefs.resume_playback_position;
console.log('[Settings] Set autoplayMode to:', autoplayMode);
console.log('[Settings] Set showContextIcon to:', showContextIcon);
console.log('[Settings] Set persistSession to:', persistSession);
console.log('[Settings] Set resumePlaybackPosition to:', resumePlaybackPosition);
} catch (err) {
console.error('Failed to load playback preferences:', err);
}
@@ -3384,6 +3388,17 @@
}
}
async function handleResumePlaybackPositionChange(resume: boolean) {
console.log('[Settings] Changing resume playback position to:', resume);
try {
await setResumePlaybackPosition(resume);
resumePlaybackPosition = resume;
console.log('[Settings] Resume playback position saved successfully');
} catch (err) {
console.error('[Settings] Failed to set resume playback position:', err);
}
}
async function checkLegacyCachedFiles() {
try {
const result = await invoke<{has_legacy_files: boolean, total_tracks: number}>('v2_detect_legacy_cached_files');
@@ -4268,6 +4283,13 @@
</div>
<Toggle enabled={persistSession} onchange={handlePersistSessionChange} />
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{$t('settings.playback.resumePlaybackPosition')}</span>
<span class="setting-desc">{$t('settings.playback.resumePlaybackPositionDesc')}</span>
</div>
<Toggle enabled={resumePlaybackPosition} onchange={handleResumePlaybackPositionChange} />
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{$t('settings.playback.gapless')}</span>
+3 -1
View File
@@ -2032,7 +2032,9 @@
"showContextIcon": "Kontext des abgespielten Titels im Player anzeigen",
"showContextIconTooltip": "Zeigt den Kontext, aus dem der aktuelle Titel abgespielt wird (Album, Künstler, Playlist usw.)",
"persistSession": "Sitzung beim Start wiederherstellen",
"persistSessionDesc": "Warteschlange, Wiedergabeposition und aktuelle Ansicht beim Neustart merken",
"persistSessionDesc": "Warteschlange und aktuelle Ansicht beim Neustart merken (Titel pausiert bei 0:00)",
"resumePlaybackPosition": "Wiedergabeposition fortsetzen",
"resumePlaybackPositionDesc": "Beim Wiederherstellen einer Sitzung an die letzte Position springen, statt den Titel von vorne zu starten",
"streamUncached": "Nicht zwischengespeicherte Titel streamen",
"streamUncachedDesc": "Startet die Wiedergabe schneller, wenn der Titel nicht im Cache ist. Vorspulen kann während der initialen Pufferung eingeschränkt sein.",
"initialBuffer": "Initiale Puffergröße",
+3 -1
View File
@@ -2032,7 +2032,9 @@
"showContextIcon": "Show track playing context in player",
"showContextIconTooltip": "Shows the context from which the current track is being played (album, artist, playlist, etc.)",
"persistSession": "Restore session on startup",
"persistSessionDesc": "Remember your queue, playback position, and current view when restarting",
"persistSessionDesc": "Remember your queue and current view when restarting (track shown paused at 0:00)",
"resumePlaybackPosition": "Resume playback position",
"resumePlaybackPositionDesc": "When restoring a session, seek to where you left off instead of starting the track from the beginning",
"streamUncached": "Stream Uncached Tracks",
"streamUncachedDesc": "Start playback faster when track is not in cache. Seeking may be limited during initial buffering.",
"initialBuffer": "Initial Buffer Size",
+3 -1
View File
@@ -2032,7 +2032,9 @@
"showContextIcon": "Mostrar contexto de reproducción en el reproductor",
"showContextIconTooltip": "Muestra el contexto desde donde se reproduce la pista actual (álbum, artista, playlist, etc.)",
"persistSession": "Restaurar sesión al iniciar",
"persistSessionDesc": "Recuerda tu cola, posición de reproducción y vista actual al reiniciar",
"persistSessionDesc": "Recuerda tu cola y vista actual al reiniciar (pista en pausa al 0:00)",
"resumePlaybackPosition": "Continuar desde la posición guardada",
"resumePlaybackPositionDesc": "Al restaurar una sesión, salta a donde quedaste en lugar de empezar la pista desde el principio",
"streamUncached": "Transmitir Pistas Sin Caché",
"streamUncachedDesc": "Inicia la reproducción más rápido cuando la pista no está en caché. La búsqueda puede estar limitada durante el buffering inicial.",
"initialBuffer": "Tamaño del Buffer Inicial",
+3 -1
View File
@@ -2032,7 +2032,9 @@
"showContextIcon": "Afficher le contexte du titre en cours de lecture dans le lecteur",
"showContextIconTooltip": "Affiche le contexte depuis lequel le titre actuel est lu (album, artiste, playlist, etc.)",
"persistSession": "Restaurer la session au démarrage",
"persistSessionDesc": "Se souvenir de votre file d'attente, position de lecture et vue actuelle au redémarrage",
"persistSessionDesc": "Se souvenir de votre file d'attente et vue actuelle au redémarrage (titre en pause à 0:00)",
"resumePlaybackPosition": "Reprendre à la position sauvegardée",
"resumePlaybackPositionDesc": "Lors de la restauration d'une session, passer à l'endroit où vous vous êtes arrêté au lieu de redémarrer le titre du début",
"streamUncached": "Streamer les titres non mis en cache",
"streamUncachedDesc": "Démarrez la lecture plus rapidement lorsque le titre n'est pas dans le cache. La recherche peut être limitée pendant la mise en tampon initiale.",
"initialBuffer": "Taille du tampon initiale",
+3 -1
View File
@@ -2032,7 +2032,9 @@
"showContextIcon": "Mostrar contexto de reprodução da faixa no player",
"showContextIconTooltip": "Mostra o contexto de onde a faixa atual está sendo reproduzida (álbum, artista, playlist, etc.)",
"persistSession": "Restaurar sessão na inicialização",
"persistSessionDesc": "Lembrar sua fila, posição de reprodução e visualização atual ao reiniciar",
"persistSessionDesc": "Lembrar sua fila e visualização atual ao reiniciar (faixa em pausa em 0:00)",
"resumePlaybackPosition": "Retomar posição de reprodução",
"resumePlaybackPositionDesc": "Ao restaurar uma sessão, ir para onde você parou em vez de iniciar a faixa do começo",
"streamUncached": "Transmitir Faixas sem Cache",
"streamUncachedDesc": "Iniciar reprodução mais rápido quando a faixa não está no cache. A busca pode ser limitada durante o buffer inicial.",
"initialBuffer": "Tamanho do Buffer Inicial",
+19 -1
View File
@@ -15,6 +15,11 @@ export interface PlaybackPreferences {
autoplay_mode: AutoplayMode;
show_context_icon: boolean;
persist_session: boolean;
/** Sub-preference of persist_session: when true, restore also seeks
* to the saved track position. When false (default), the restored
* track is shown paused at 0:00 (#360 / issue 317 — the common
* "fresh start each day" behavior). */
resume_playback_position: boolean;
}
// ============ State ============
@@ -22,7 +27,8 @@ export interface PlaybackPreferences {
let preferences: PlaybackPreferences = {
autoplay_mode: 'continue',
show_context_icon: true,
persist_session: false
persist_session: false,
resume_playback_position: false
};
const listeners = new Set<() => void>();
@@ -85,6 +91,18 @@ export async function setPersistSession(persist: boolean): Promise<void> {
notifyListeners();
}
/**
* Set whether to resume the saved playback position (seek to where the
* user left off) when restoring a session. Sub-preference of
* `persist_session` — has no effect when persist is off. Default: false.
*/
export async function setResumePlaybackPosition(resume: boolean): Promise<void> {
if (skipIfRemote()) return;
await invoke('v2_set_resume_playback_position', { resume });
preferences.resume_playback_position = resume;
notifyListeners();
}
/**
* Get cached preferences (no backend call)
*/
+35 -8
View File
@@ -186,8 +186,12 @@ let gaplessRequestInFlight = false;
// One-shot guard: attempt gapless pre-queue only once per current track.
let gaplessAttemptTrackId: number | null = null;
// Session restore state - when set, next play will load the track first
let pendingSessionRestore: { trackId: number } | null = null;
// Session restore state — when set, next play will load the track first.
// `positionSecs` (when present) is honored only if the user enabled the
// "resume playback position" preference (issue #317). Default is to
// start the restored track from the beginning, so opening the app the
// next morning lands on a clean 0:00.
let pendingSessionRestore: { trackId: number; positionSecs?: number } | null = null;
// Listeners
const listeners = new Set<() => void>();
@@ -346,11 +350,17 @@ export function setQueueEnded(ended: boolean): void {
// ============ Playback Controls ============
/**
* Set pending session restore - will load track on next play
* Set pending session restore — will load track on next play. Pass
* `positionSecs` only when the user opted into resume-playback-position
* (issue #317). Without it, the restored track starts from 0.
*/
export function setPendingSessionRestore(trackId: number): void {
pendingSessionRestore = { trackId };
console.log('[Player] Set pending session restore:', trackId);
export function setPendingSessionRestore(trackId: number, positionSecs?: number): void {
pendingSessionRestore = { trackId, positionSecs };
console.log(
'[Player] Set pending session restore:',
trackId,
positionSecs ? `(resume @ ${positionSecs}s)` : '(start at 0:00)'
);
}
/**
@@ -408,10 +418,17 @@ export async function togglePlay(): Promise<void> {
if (newIsPlaying) {
// Check if we need to load the track first (session restore)
if (pendingSessionRestore && pendingSessionRestore.trackId === currentTrack.id) {
console.log('[Player] Loading restored track from start:', pendingSessionRestore.trackId);
const restorePosition = pendingSessionRestore.positionSecs ?? 0;
console.log(
'[Player] Loading restored track:',
pendingSessionRestore.trackId,
restorePosition > 0 ? `(will seek to ${restorePosition}s)` : '(from start)'
);
pendingSessionRestore = null; // Clear before loading
// Restore source-specific playback (always from start)
// Restore source-specific playback (always from start; if the
// user enabled resume-playback-position the seek is queued
// below and applied once the stream is loaded).
if (currentTrack.source === 'plex') {
const plexBaseUrl = getUserItem('qbz-plex-poc-base-url') || '';
const plexToken = getUserItem('qbz-plex-poc-token') || '';
@@ -445,6 +462,16 @@ export async function togglePlay(): Promise<void> {
);
}
// Apply the user's saved playback position (#317 opt-in).
// Queue via the existing pendingSeekPosition / flushPendingSeek
// path so the seek runs against an already-loaded stream and
// honors the in-flight guard. If the position is at or near 0,
// skip — the load already starts there.
if (restorePosition > 1) {
pendingSeekPosition = restorePosition;
void flushPendingSeek();
}
} else {
await cmdResume();
}
+13 -3
View File
@@ -4125,9 +4125,19 @@
console.debug('[Session] Could not restore playback context');
}
// First play will load a fresh stream instead of seeking
setPendingSessionRestore(track.id);
console.log(`[Session] Track ${track.id} restored visually (paused at 0:00)`);
// The restored track is shown paused; the user's first
// press of play loads a fresh stream. If they opted into
// resume-playback-position (#317), we also forward the
// saved offset so togglePlay() seeks once the stream is
// ready. Default behavior remains "fresh start at 0:00".
const resumePosition = getCachedPreferences().resume_playback_position
? session.current_position_secs
: undefined;
setPendingSessionRestore(track.id, resumePosition);
console.log(
`[Session] Track ${track.id} restored visually`,
resumePosition ? `(will resume @ ${resumePosition}s on first play)` : '(fresh at 0:00)'
);
}
console.log('[Session] Session restored successfully');