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,