diff --git a/changelog/v0.1.0-beta3.txt b/changelog/v0.1.0-beta3.txt index 37b89f6d..a83ea355 100644 --- a/changelog/v0.1.0-beta3.txt +++ b/changelog/v0.1.0-beta3.txt @@ -41,6 +41,9 @@ MUSIC - A song paused while it was still loading stays paused, and a recovery never starts a song you paused. - A song whose link expires during long pauses recovers each time, waits for the connection to come back when the network drops, and tries another audio format when one cannot be decoded. Errors now say which of these went wrong. - Media keys and the system's Now Playing controls follow whichever player started last, show the song's progress, and seek forward and back. Starting a video pauses the music and starting music pauses the video. +- Flow reopens the music player on the song you stopped at, paused at the same point, with its queue, shuffle order and radio. "Restore the last queue" in Settings turns this off. +- History and recommendations count the time a song actually played, so skipping or dragging to the end no longer counts as a full listen, and a short replay no longer replaces a longer one. +- A new "Resume long tracks" setting, off by default, starts mixes, podcasts and episodes of 10 minutes or more where you left them. AUDIO - Equalizer boosts no longer distort: the music equalizer turns the whole curve down by its highest boost before the filters, and a soft limiter bends any peak that still goes past full scale. diff --git a/src-tauri/migrations/0019_music_queue_state.sql b/src-tauri/migrations/0019_music_queue_state.sql new file mode 100644 index 00000000..be8d8b58 --- /dev/null +++ b/src-tauri/migrations/0019_music_queue_state.sql @@ -0,0 +1,17 @@ +-- The music queue as the player last left it, so a restart reopens on the same song (Flow for +-- Android's QueuePersistence). One row. state: JSON written and read by the player (queue, index, +-- position, source, shuffle order, radio tracks). Device-local, never synced. +CREATE TABLE IF NOT EXISTS music_queue_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + state TEXT NOT NULL, + saved_at INTEGER NOT NULL +); + +-- Where long tracks (ten minutes or more: mixes, podcasts, episodes) were left, so they resume +-- there (Android's MusicResumeTracker). Device-local, never synced. +CREATE TABLE IF NOT EXISTS music_resume_positions ( + video_id TEXT PRIMARY KEY, + position_seconds REAL NOT NULL, + duration_seconds REAL NOT NULL, + updated_at INTEGER NOT NULL +); diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 923eac7c..9c889d37 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -7,6 +7,7 @@ pub mod files; pub mod home_feed; pub mod music; pub mod music_brain; +pub mod music_queue; pub mod notifications; pub mod pip; pub mod recommendation; diff --git a/src-tauri/src/commands/music_queue.rs b/src-tauri/src/commands/music_queue.rs new file mode 100644 index 00000000..6fd532a6 --- /dev/null +++ b/src-tauri/src/commands/music_queue.rs @@ -0,0 +1,81 @@ +//! Saves and restores the music queue between launches, and where long tracks were left. + +use sqlx::SqlitePool; +use tauri::State; + +use crate::db::music_queue; +use crate::errors::{AppError, ErrorResponse}; +use crate::security::validation::validate_video_id; + +type CmdResult = Result; + +/// A saved queue is a few hundred songs at most; anything far larger is not one. +const MAX_QUEUE_STATE_BYTES: usize = 4 * 1024 * 1024; + +fn unix_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |elapsed| { + i64::try_from(elapsed.as_millis()).unwrap_or(i64::MAX) + }) +} + +#[tauri::command] +pub async fn save_music_queue_state(state: String, pool: State<'_, SqlitePool>) -> CmdResult<()> { + if state.len() > MAX_QUEUE_STATE_BYTES { + return Err(AppError::Validation("Music queue state is too large".into()).into()); + } + serde_json::from_str::(&state).map_err(|_| { + ErrorResponse::from(AppError::Validation("Music queue state is not JSON".into())) + })?; + music_queue::save_queue_state(&pool, &state, unix_ms()) + .await + .map_err(ErrorResponse::from) +} + +#[tauri::command] +pub async fn load_music_queue_state(pool: State<'_, SqlitePool>) -> CmdResult> { + music_queue::load_queue_state(&pool) + .await + .map_err(ErrorResponse::from) +} + +#[tauri::command] +pub async fn clear_music_queue_state(pool: State<'_, SqlitePool>) -> CmdResult<()> { + music_queue::clear_queue_state(&pool) + .await + .map_err(ErrorResponse::from) +} + +#[tauri::command] +pub async fn save_music_resume_position( + video_id: String, + position_seconds: f64, + duration_seconds: f64, + pool: State<'_, SqlitePool>, +) -> CmdResult<()> { + validate_video_id(&video_id).map_err(ErrorResponse::from)?; + if !position_seconds.is_finite() || !duration_seconds.is_finite() || duration_seconds <= 0.0 { + return Err(AppError::Validation("Invalid resume position".into()).into()); + } + music_queue::save_resume_position( + &pool, + video_id.trim(), + position_seconds.clamp(0.0, duration_seconds), + duration_seconds, + unix_ms(), + ) + .await + .map_err(ErrorResponse::from) +} + +#[tauri::command] +pub async fn get_music_resume_position( + video_id: String, + pool: State<'_, SqlitePool>, +) -> CmdResult> { + validate_video_id(&video_id).map_err(ErrorResponse::from)?; + music_queue::get_resume_position(&pool, video_id.trim()) + .await + .map_err(ErrorResponse::from) +} diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index aa498e9b..c4283ccf 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -8,6 +8,7 @@ pub mod downloads; pub mod library; pub mod music_content; pub mod music_home; +pub mod music_queue; pub mod neuro_stores; pub mod notifications; pub mod recommendations; diff --git a/src-tauri/src/db/music_queue.rs b/src-tauri/src/db/music_queue.rs new file mode 100644 index 00000000..517d4c72 --- /dev/null +++ b/src-tauri/src/db/music_queue.rs @@ -0,0 +1,112 @@ +//! The music player's saved queue and long-track positions (migration 0018). + +use crate::errors::{AppError, AppResult}; +use sqlx::{Row, SqlitePool}; + +pub async fn save_queue_state(pool: &SqlitePool, state: &str, saved_at: i64) -> AppResult<()> { + sqlx::query( + "INSERT INTO music_queue_state (id, state, saved_at) VALUES (1, ?, ?) + ON CONFLICT(id) DO UPDATE SET state = excluded.state, saved_at = excluded.saved_at", + ) + .bind(state) + .bind(saved_at) + .execute(pool) + .await + .map_err(AppError::from)?; + Ok(()) +} + +pub async fn load_queue_state(pool: &SqlitePool) -> AppResult> { + let row = sqlx::query("SELECT state FROM music_queue_state WHERE id = 1") + .fetch_optional(pool) + .await + .map_err(AppError::from)?; + Ok(row.map(|row| row.get::(0))) +} + +pub async fn clear_queue_state(pool: &SqlitePool) -> AppResult<()> { + sqlx::query("DELETE FROM music_queue_state") + .execute(pool) + .await + .map_err(AppError::from)?; + Ok(()) +} + +pub async fn save_resume_position( + pool: &SqlitePool, + video_id: &str, + position_seconds: f64, + duration_seconds: f64, + updated_at: i64, +) -> AppResult<()> { + sqlx::query( + "INSERT INTO music_resume_positions (video_id, position_seconds, duration_seconds, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(video_id) DO UPDATE SET + position_seconds = excluded.position_seconds, + duration_seconds = excluded.duration_seconds, + updated_at = excluded.updated_at", + ) + .bind(video_id) + .bind(position_seconds) + .bind(duration_seconds) + .bind(updated_at) + .execute(pool) + .await + .map_err(AppError::from)?; + Ok(()) +} + +pub async fn get_resume_position(pool: &SqlitePool, video_id: &str) -> AppResult> { + let row = sqlx::query("SELECT position_seconds FROM music_resume_positions WHERE video_id = ?") + .bind(video_id) + .fetch_optional(pool) + .await + .map_err(AppError::from)?; + Ok(row.map(|row| row.get::(0))) +} + +#[cfg(test)] +mod tests { + use super::*; + + async fn pool() -> SqlitePool { + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + sqlx::migrate!("./migrations").run(&pool).await.unwrap(); + pool + } + + #[tokio::test] + async fn the_queue_keeps_only_its_latest_state() { + let pool = pool().await; + assert_eq!(load_queue_state(&pool).await.unwrap(), None); + save_queue_state(&pool, r#"{"v":1}"#, 1).await.unwrap(); + save_queue_state(&pool, r#"{"v":2}"#, 2).await.unwrap(); + assert_eq!( + load_queue_state(&pool).await.unwrap().as_deref(), + Some(r#"{"v":2}"#) + ); + clear_queue_state(&pool).await.unwrap(); + assert_eq!(load_queue_state(&pool).await.unwrap(), None); + } + + #[tokio::test] + async fn a_long_track_remembers_where_it_was_left() { + let pool = pool().await; + assert_eq!(get_resume_position(&pool, "mix").await.unwrap(), None); + save_resume_position(&pool, "mix", 600.0, 3600.0, 1) + .await + .unwrap(); + save_resume_position(&pool, "mix", 1200.5, 3600.0, 2) + .await + .unwrap(); + assert_eq!( + get_resume_position(&pool, "mix").await.unwrap(), + Some(1200.5) + ); + } +} diff --git a/src-tauri/src/db/watch_history.rs b/src-tauri/src/db/watch_history.rs index 4db576c9..5914092b 100644 --- a/src-tauri/src/db/watch_history.rs +++ b/src-tauri/src/db/watch_history.rs @@ -45,14 +45,16 @@ pub async fn upsert_watch_record(pool: &SqlitePool, record: &WatchHistoryRecord) if let Some(row) = existing { let id: i64 = sqlx::Row::get(&row, 0); // Clearing the sync stamp makes it follow the new watch time, so a re-watch beats a - // deletion synced from another device before it. + // deletion synced from another device before it. A song's row keeps its longest listen: + // a short replay or a skip must not wipe out a full play recorded earlier. sqlx::query( "UPDATE watch_history SET title = ?, channel_name = ?, channel_id = COALESCE(?, channel_id), watch_date = ?, - watch_duration_seconds = ?, + watch_duration_seconds = CASE WHEN ? AND is_music + THEN MAX(watch_duration_seconds, ?) ELSE ? END, total_duration_seconds = ?, is_music = ?, is_short = is_short OR ?, @@ -63,6 +65,8 @@ pub async fn upsert_watch_record(pool: &SqlitePool, record: &WatchHistoryRecord) .bind(&record.channel_name) .bind(&record.channel_id) .bind(&record.watch_date) + .bind(record.is_music) + .bind(record.watch_duration_seconds) .bind(record.watch_duration_seconds) .bind(record.total_duration_seconds) .bind(record.is_music) @@ -300,6 +304,38 @@ mod channel_tests { assert_eq!(history[0].watch_duration_seconds, 20); } + #[tokio::test] + async fn a_song_keeps_its_longest_listen_and_a_video_its_latest_watch() { + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + sqlx::migrate!("./migrations").run(&pool).await.unwrap(); + let song = |seconds| WatchHistoryRecord { + video_id: "song".into(), + is_music: true, + ..record(None, seconds) + }; + upsert_watch_record(&pool, &song(190)).await.unwrap(); + upsert_watch_record(&pool, &song(2)).await.unwrap(); + upsert_watch_record(&pool, &record(None, 300)) + .await + .unwrap(); + upsert_watch_record(&pool, &record(None, 40)).await.unwrap(); + + let history = get_watch_history(&pool, 10, 0).await.unwrap(); + let seconds = |id: &str| { + history + .iter() + .find(|row| row.video_id == id) + .unwrap() + .watch_duration_seconds + }; + assert_eq!(seconds("song"), 190); + assert_eq!(seconds("v1"), 40); + } + #[tokio::test] async fn a_short_stays_a_short_whatever_writes_it_next() { let pool = sqlx::sqlite::SqlitePoolOptions::new() diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 113f1ef7..b3857619 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -67,6 +67,10 @@ use commands::music_brain::{ get_music_time_rotation, rank_music_candidates, record_music_interaction, reset_music_brain, unblock_music_artist, }; +use commands::music_queue::{ + clear_music_queue_state, get_music_resume_position, load_music_queue_state, + save_music_queue_state, save_music_resume_position, +}; use commands::notifications::{ check_subscriptions_now, clear_notifications, delete_notification, get_notifications, get_unread_notification_count, mark_notifications_read, @@ -617,6 +621,12 @@ pub fn run() { get_music_brain_snapshot, get_music_taste_profile, reset_music_brain, + // --- Music queue restore and long-track resume --- + save_music_queue_state, + load_music_queue_state, + clear_music_queue_state, + save_music_resume_position, + get_music_resume_position, // --- Flow Local Sync (P2P LAN) --- sync_device_info, sync_status, diff --git a/src/components/music/GlobalMusicAudio.tsx b/src/components/music/GlobalMusicAudio.tsx index bfe31ab4..cdfa4dc4 100644 --- a/src/components/music/GlobalMusicAudio.tsx +++ b/src/components/music/GlobalMusicAudio.tsx @@ -4,16 +4,10 @@ import { useMusicPlayerStore } from "../../store/useMusicPlayerStore"; import { musicAudioEngine } from "../../lib/audio/musicAudioEngine"; import { bindMusicAudioSettings } from "../../lib/audio/musicAudioSettings"; import { PRELOAD_LEAD_SECONDS } from "../../lib/musicPreload"; -import { recordSongHistory } from "../../lib/musicHistory"; import { useMusicMediaSession } from "../../lib/useMusicMediaSession"; import { reportMediaPosition } from "../../lib/mediaSessionArbiter"; -import type { SongItem } from "../../types/music"; - -const HISTORY_PERSIST_MS = 5000; -/** - * Media-time delta below which a progress write is skipped. A seek always - * exceeds it, so scrubbing still lands immediately. - */ +import { musicListens, useMusicListenHistory } from "../../lib/useMusicListenHistory"; +import { useMusicQueuePersistence } from "../../lib/useMusicQueuePersistence"; /** * Wraps a media-element handler so only the element that is playing drives the @@ -29,12 +23,6 @@ const fromActive = export function GlobalMusicAudio() { const firstRef = useRef(null); const secondRef = useRef(null); - const currentTrack = useMusicPlayerStore((s) => s.currentTrack); - - // --- watch-history persistence --- - const historyTrackRef = useRef(null); - const lastProgressRef = useRef({ time: 0, duration: 0 }); - const lastPersistAtRef = useRef(0); useEffect(() => { const first = firstRef.current; @@ -47,33 +35,8 @@ export function GlobalMusicAudio() { bindMusicAudioSettings(); }, []); - useEffect(() => { - if (currentTrack) { - historyTrackRef.current = currentTrack; - lastProgressRef.current = { time: 0, duration: currentTrack.duration ?? 0 }; - lastPersistAtRef.current = Date.now(); - void recordSongHistory(currentTrack, 0, currentTrack.duration ?? 0); - } - return () => { - const prev = historyTrackRef.current; - if (prev) { - const { time, duration } = lastProgressRef.current; - void recordSongHistory(prev, time, duration); - } - }; - }, [currentTrack?.id]); - - useEffect(() => { - const handler = () => { - const t = historyTrackRef.current; - if (!t) return; - const { time, duration } = lastProgressRef.current; - void recordSongHistory(t, time, duration); - }; - window.addEventListener("beforeunload", handler); - return () => window.removeEventListener("beforeunload", handler); - }, []); - + useMusicListenHistory(); + useMusicQueuePersistence(); useMusicMediaSession(); const store = () => useMusicPlayerStore.getState(); @@ -81,8 +44,14 @@ export function GlobalMusicAudio() { hidden: true, preload: "auto", onPlay: fromActive(() => store()._reflectPlaying(true)), - onPause: fromActive(() => store()._reflectPlaying(false)), - onWaiting: fromActive(() => store()._setBuffering(true)), + onPause: fromActive(() => { + musicListens.pause(); + store()._reflectPlaying(false); + }), + onWaiting: fromActive(() => { + musicListens.hold(); + store()._setBuffering(true); + }), onPlaying: fromActive(() => store()._setBuffering(false)), onDurationChange: fromActive((el) => store()._syncTime(el.currentTime, el.duration)), onSeeked: fromActive((el) => store()._syncTime(el.currentTime, el.duration)), @@ -105,17 +74,21 @@ export function GlobalMusicAudio() { store()._syncTime(el.currentTime, el.duration); reportMediaPosition("music"); - // History is persisted from here for the same reason: a frame loop + // Listening time is counted from here for the same reason: a frame loop // stops in the background, and listening there is the common case. - lastProgressRef.current = { time: el.currentTime, duration }; - const now = Date.now(); - if (now - lastPersistAtRef.current > HISTORY_PERSIST_MS) { - lastPersistAtRef.current = now; - const t = historyTrackRef.current; - if (t) void recordSongHistory(t, el.currentTime, duration); + // While the next song resolves, the element still plays the last one, + // which must not count toward the new song. + const { currentTrack, loadingStreamId } = store(); + if (currentTrack && !el.paused && loadingStreamId === null) { + musicListens.tick(currentTrack, el.currentTime, duration, Date.now()); + } else { + musicListens.hold(); } }), - onEnded: fromActive(() => store().handleEnded()), + onEnded: fromActive(() => { + musicListens.finish(Date.now()); + store().handleEnded(); + }), onError: (event: SyntheticEvent) => { if (event.currentTarget === musicAudioEngine.getActiveElement()) store()._onPlaybackError(); else store()._onPreloadError(); diff --git a/src/components/settings/tabs/PlayerTab.tsx b/src/components/settings/tabs/PlayerTab.tsx index d3e44c63..1cfbd179 100644 --- a/src/components/settings/tabs/PlayerTab.tsx +++ b/src/components/settings/tabs/PlayerTab.tsx @@ -35,6 +35,8 @@ export function PlayerTab() { const [artworkControlColors, setArtworkControlColors] = useBoolPref(SETTINGS.MUSIC_ARTWORK_CONTROL_COLORS); const [plainControlColors, setPlainControlColors] = usePreference(SETTINGS.MUSIC_PLAIN_CONTROL_COLORS); const [followLightTheme, setFollowLightTheme] = useBoolPref(SETTINGS.MUSIC_PLAYER_FOLLOW_LIGHT_THEME); + const [restoreMusicQueue, setRestoreMusicQueue] = useBoolPref(SETTINGS.MUSIC_RESTORE_QUEUE, true); + const [resumeLongTracks, setResumeLongTracks] = useBoolPref(SETTINGS.MUSIC_RESUME_LONG_TRACKS, false); const [subtitles, setSubtitles] = useBoolPref(SETTINGS.SUBTITLES_ENABLED, false); const [subtitleLang, setSubtitleLang] = usePreference(SETTINGS.PREFERRED_SUBTITLE_LANGUAGE, 'en'); @@ -235,6 +237,12 @@ export function PlayerTab() { + + + + + + ); diff --git a/src/lib/api/musicQueue.ts b/src/lib/api/musicQueue.ts new file mode 100644 index 00000000..b1a7ef30 --- /dev/null +++ b/src/lib/api/musicQueue.ts @@ -0,0 +1,27 @@ +import { invokeBackend } from "./errors"; + +/** The saved queue's JSON, or null when nothing is saved. */ +export function loadMusicQueueState(): Promise { + return invokeBackend("load_music_queue_state"); +} + +export function saveMusicQueueState(state: string): Promise { + return invokeBackend("save_music_queue_state", { state }); +} + +export function clearMusicQueueState(): Promise { + return invokeBackend("clear_music_queue_state"); +} + +/** Where a long track was left, in seconds, or null when it never was. */ +export function getMusicResumePosition(videoId: string): Promise { + return invokeBackend("get_music_resume_position", { videoId }); +} + +export function saveMusicResumePosition( + videoId: string, + positionSeconds: number, + durationSeconds: number, +): Promise { + return invokeBackend("save_music_resume_position", { videoId, positionSeconds, durationSeconds }); +} diff --git a/src/lib/i18n/strings.json b/src/lib/i18n/strings.json index b3fc05cc..b050850f 100644 --- a/src/lib/i18n/strings.json +++ b/src/lib/i18n/strings.json @@ -657,6 +657,10 @@ "settings_music_plain_control_colors_app_theme": "App theme", "settings_music_player_follow_light_theme": "Light in light theme", "settings_music_player_follow_light_theme_desc": "Artwork backgrounds turn light when the app theme is light", + "settings_music_restore_queue": "Restore the last queue", + "settings_music_restore_queue_desc": "Reopen the music player on the song you stopped at, paused, with its queue", + "settings_music_resume_long_tracks": "Resume long tracks", + "settings_music_resume_long_tracks_desc": "Mixes, podcasts and episodes of 10 minutes or more start where you left them", "settings_group_layout": "Layout", "settings_grid_columns": "Grid Columns", "settings_grid_columns_desc": "Up to %s videos per row — narrower windows automatically use fewer", diff --git a/src/lib/musicHistory.ts b/src/lib/musicHistory.ts index ced8615c..580d9624 100644 --- a/src/lib/musicHistory.ts +++ b/src/lib/musicHistory.ts @@ -5,15 +5,20 @@ import type { SongItem } from "../types/music"; const videoIdOf = (t: SongItem): string => t.videoId ?? t.id; +/** + * Saves a listen of `listenedSeconds` actually played (not the playhead) to the + * shared history, as a music row so it never feeds the video brain, and to + * MusicBrain. + */ export async function recordSongHistory( track: SongItem, - progressSeconds: number, + listenedSeconds: number, totalSeconds: number, ): Promise { const id = videoIdOf(track); const channelName = track.artists.map((a) => a.name).filter(Boolean).join(", "); const total = Math.floor(totalSeconds || track.duration || 0); - const watched = Math.floor(Math.max(0, progressSeconds)); + const watched = Math.floor(Math.max(0, listenedSeconds)); if (shouldRecordWatchHistory()) { try { @@ -31,7 +36,7 @@ export async function recordSongHistory( } } - const percentWatched = total > 0 ? Math.min(1, Math.max(0, progressSeconds / total)) : 0; + const percentWatched = total > 0 ? Math.min(1, Math.max(0, listenedSeconds / total)) : 0; try { await recordMusicInteraction({ trackId: id, diff --git a/src/lib/musicListenTracker.test.ts b/src/lib/musicListenTracker.test.ts new file mode 100644 index 00000000..468a1c95 --- /dev/null +++ b/src/lib/musicListenTracker.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SongItem } from "../types/music"; +import { MusicListenTracker } from "./musicListenTracker"; + +const song = (id: string) => ({ id, videoId: id, duration: 200 }) as SongItem; +const a = song("a"); +const b = song("b"); + +const recordListen = vi.fn(); +const recordPosition = vi.fn(); +let tracker: MusicListenTracker; + +/** Plays `track` for `seconds` of wall clock from `start`, ticking like timeupdate (4 Hz). */ +const play = (track: SongItem, start: number, seconds: number, position = (t: number) => t / 1000) => { + for (let t = 0; t <= seconds * 1000; t += 250) tracker.tick(track, position(t), 200, start + t); +}; + +beforeEach(() => { + recordListen.mockClear(); + recordPosition.mockClear(); + tracker = new MusicListenTracker({ recordListen, recordPosition }); +}); + +describe("listen accounting", () => { + it("counts the time a song played, not where the playhead went", () => { + // A drag to the end after two seconds is two seconds of listening. + play(a, 0, 2, (t) => (t >= 1000 ? 199 : 0)); + tracker.switchTo(b, 2_000); + expect(recordListen).toHaveBeenLastCalledWith(a, 2, 200); + }); + + it("writes no row for a song that never played", () => { + tracker.switchTo(a, 0); + tracker.switchTo(b, 1_000); + expect(recordListen).not.toHaveBeenCalled(); + expect(recordPosition).not.toHaveBeenCalled(); + }); + + it("leaves pauses and buffering out", () => { + play(a, 0, 3); + tracker.pause(); + play(a, 60_000, 2); + tracker.hold(); + play(a, 120_000, 1); + tracker.switchTo(null, 130_000); + expect(recordListen).toHaveBeenLastCalledWith(a, 6, 200); + }); + + it("drops a gap no song could have filled, such as a sleeping machine", () => { + tracker.tick(a, 0, 200, 0); + tracker.tick(a, 1, 200, 1_000); + tracker.tick(a, 2, 200, 3_600_000); + expect(tracker.listenedSeconds()).toBe(1); + }); + + it("credits a gapless swap to the song that played", () => { + play(a, 0, 4); + // The new song's first tick arrives before the player says it moved on. + tracker.tick(b, 0, 200, 4_250); + tracker.switchTo(b, 4_300); + expect(recordListen).toHaveBeenCalledWith(a, 4, 200); + expect(recordListen).not.toHaveBeenCalledWith(b, expect.anything(), expect.anything()); + }); + + it("counts a repeat of the same song as a new listen", () => { + play(a, 0, 3); + tracker.finish(3_000); + expect(recordListen).toHaveBeenLastCalledWith(a, 3, 200); + expect(recordPosition).toHaveBeenLastCalledWith(a, 200, 200); + play(a, 4_000, 1); + expect(tracker.listenedSeconds()).toBe(1); + }); + + it("saves the listen as it goes, so a crash loses little", () => { + play(a, 0, 6); + expect(recordListen).toHaveBeenCalledWith(a, 5, 200); + }); + + it("remembers where a paused song was left", () => { + play(a, 0, 2, () => 120); + tracker.pause(); + expect(recordPosition).toHaveBeenLastCalledWith(a, 120, 200); + }); +}); diff --git a/src/lib/musicListenTracker.ts b/src/lib/musicListenTracker.ts new file mode 100644 index 00000000..b310d8ba --- /dev/null +++ b/src/lib/musicListenTracker.ts @@ -0,0 +1,110 @@ +import type { SongItem } from "../types/music"; + +/** + * Counts how long a song actually played, by the wall clock, for history and + * MusicBrain. Seeks move the playhead but not the clock, and pauses, buffering + * and a sleeping machine add nothing, so dragging to the end is not a full + * listen and a song that never started writes no row. + */ + +/** A gap between clock ticks longer than this is time the song was not heard. */ +export const MAX_TICK_GAP_MS = 5_000; +/** How often a listen in progress is saved, so a crash loses little. */ +export const LISTEN_PERSIST_MS = 5_000; + +export interface ListenSink { + /** A listen of `listenedSeconds` (at least one) on `track`, `durationSeconds` long. */ + recordListen: (track: SongItem, listenedSeconds: number, durationSeconds: number) => void; + /** Where the song was left: on a pause, a change of song, its end or the app closing. */ + recordPosition: (track: SongItem, positionSeconds: number, durationSeconds: number) => void; +} + +const idOf = (track: SongItem): string => track.videoId ?? track.id; + +export class MusicListenTracker { + private track: SongItem | null = null; + private listenedMs = 0; + private lastTickAt: number | null = null; + private lastPersistAt = 0; + private position = 0; + private duration = 0; + /** Whether the element has reported a position for this song yet. */ + private ticked = false; + + constructor(private readonly sink: ListenSink) {} + + listenedSeconds(): number { + return Math.floor(this.listenedMs / 1000); + } + + /** + * One `timeupdate` of the playing element. A different song than the one + * being counted closes that listen first, so a gapless swap credits the song + * that played rather than the one that just took over. + */ + tick(track: SongItem, positionSeconds: number, durationSeconds: number, now: number): void { + if (!this.track || idOf(this.track) !== idOf(track)) this.start(track, now); + if (this.lastTickAt !== null) { + const gap = now - this.lastTickAt; + if (gap > 0 && gap <= MAX_TICK_GAP_MS) this.listenedMs += gap; + } + this.lastTickAt = now; + this.ticked = true; + this.position = positionSeconds; + if (durationSeconds > 0) this.duration = durationSeconds; + if (now - this.lastPersistAt >= LISTEN_PERSIST_MS) { + this.lastPersistAt = now; + this.saveListen(); + } + } + + /** The song stopped making sound (paused or buffering): the clock stops until the next tick. */ + hold(): void { + this.lastTickAt = null; + } + + /** The user paused: save the listen so far and where the song was left. */ + pause(): void { + this.hold(); + this.flush(); + } + + /** The song played to its end. A replay of it (repeat one) counts as a new listen. */ + finish(now: number): void { + if (!this.track) return; + this.position = this.duration; + this.flush(); + this.start(this.track, now); + } + + /** The player moved to `track` (or to nothing): close the listen of the song before it. */ + switchTo(track: SongItem | null, now: number): void { + if (this.track && track && idOf(this.track) === idOf(track)) return; + this.flush(); + this.track = null; + if (track) this.start(track, now); + } + + /** The app is closing: save what there is. */ + flush(): void { + this.saveListen(); + // A song that never reported a position would overwrite where it was left with 0. + if (this.track && this.ticked) this.sink.recordPosition(this.track, this.position, this.duration); + } + + private start(track: SongItem, now: number): void { + if (this.track && idOf(this.track) !== idOf(track)) this.flush(); + this.track = track; + this.listenedMs = 0; + this.lastTickAt = null; + this.lastPersistAt = now; + this.position = 0; + this.ticked = false; + this.duration = track.duration ?? 0; + } + + private saveListen(): void { + const seconds = this.listenedSeconds(); + if (this.track && seconds >= 1) this.sink.recordListen(this.track, seconds, this.duration); + } +} diff --git a/src/lib/musicQueuePersistence.test.ts b/src/lib/musicQueuePersistence.test.ts new file mode 100644 index 00000000..2d6f664b --- /dev/null +++ b/src/lib/musicQueuePersistence.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; + +import type { SongItem } from "../types/music"; +import { parseSavedQueue, serializeQueue, type QueueSnapshot } from "./musicQueuePersistence"; + +const track = (id: string, seconds: number | null = 200): SongItem => ({ + id, + title: id, + artists: [], + album: null, + duration: seconds, + musicVideoType: null, + thumbnail: "", + explicit: false, + videoId: id, + playlistId: null, + params: null, +}); + +const snapshot = (overrides: Partial = {}): QueueSnapshot => ({ + queue: [track("a"), track("b"), track("c")], + currentIndex: 1, + progress: 61, + queueSource: "Quick picks", + unshuffledQueue: null, + radioQueuedIds: [], + radioStationActive: false, + ...overrides, +}); + +const roundTrip = (overrides: Partial = {}) => + parseSavedQueue(JSON.stringify(serializeQueue(snapshot(overrides)))); + +// Cases ported from Android's RestoredMusicQueueTest. +describe("restoring the saved queue", () => { + it("resumes the saved song where it stopped", () => { + const restored = roundTrip()!; + expect(restored.queue[restored.currentIndex]?.id).toBe("b"); + expect(restored.currentIndex).toBe(1); + expect(restored.resumeAt).toBe(61); + expect(restored.queueSource).toBe("Quick picks"); + }); + + it("lets the song id win over a stale index", () => { + const saved = { ...serializeQueue(snapshot()), currentIndex: 0, currentTrackId: "c" }; + expect(parseSavedQueue(JSON.stringify(saved))!.currentIndex).toBe(2); + }); + + it("starts a song stopped in its last five seconds over", () => { + expect(roundTrip({ progress: 197 })!.resumeAt).toBe(0); + expect(roundTrip({ progress: 194 })!.resumeAt).toBe(194); + }); + + it("still resumes a song of unknown length", () => { + expect(roundTrip({ queue: [track("a", 0)], currentIndex: 0 })!.resumeAt).toBe(61); + expect(roundTrip({ queue: [track("a", null)], currentIndex: 0 })!.resumeAt).toBe(61); + }); + + it("clamps an index past the end, and restores nothing from an empty or broken save", () => { + const saved = { ...serializeQueue(snapshot()), currentIndex: 9, currentTrackId: null }; + expect(parseSavedQueue(JSON.stringify(saved))!.currentIndex).toBe(2); + expect(roundTrip({ queue: [], currentIndex: -1 })).toBeNull(); + expect(parseSavedQueue("not json")).toBeNull(); + expect(parseSavedQueue(null)).toBeNull(); + expect(parseSavedQueue(JSON.stringify({ version: 2, queue: [] }))).toBeNull(); + }); + + it("brings back the order shuffle restores, and the station's songs", () => { + const [a, b, c] = [track("a"), track("b"), track("c")]; + const restored = roundTrip({ + queue: [b, c, a], + currentIndex: 0, + unshuffledQueue: [a, b, c], + radioQueuedIds: ["c"], + radioStationActive: true, + })!; + expect(restored.unshuffledQueue?.map((t) => t.id)).toEqual(["a", "b", "c"]); + expect(restored.unshuffledQueue?.[0]).toBe(restored.queue[2]); + expect(restored.radioQueuedIds).toEqual(["c"]); + expect(restored.radioStationActive).toBe(true); + }); +}); diff --git a/src/lib/musicQueuePersistence.ts b/src/lib/musicQueuePersistence.ts new file mode 100644 index 00000000..8e60428d --- /dev/null +++ b/src/lib/musicQueuePersistence.ts @@ -0,0 +1,106 @@ +import type { SongItem } from "../types/music"; + +/** + * The music queue as it is saved between launches, and the rules for bringing + * it back, ported from Android's QueuePersistence and RestoredMusicQueue. + */ +export interface SavedMusicQueue { + version: 1; + queue: SongItem[]; + currentIndex: number; + currentTrackId: string | null; + positionSeconds: number; + queueSource: string | null; + /** The order shuffle restores, as indexes into `queue`; null while shuffle is off. */ + unshuffledOrder: number[] | null; + radioQueuedIds: string[]; + radioStationActive: boolean; +} + +export interface QueueSnapshot { + queue: SongItem[]; + currentIndex: number; + progress: number; + queueSource: string | null; + unshuffledQueue: SongItem[] | null; + radioQueuedIds: string[]; + radioStationActive: boolean; +} + +export interface RestoredQueue { + queue: SongItem[]; + currentIndex: number; + /** Where the restored song picks up; 0 starts it over. */ + resumeAt: number; + queueSource: string | null; + unshuffledQueue: SongItem[] | null; + radioQueuedIds: string[]; + radioStationActive: boolean; +} + +/** A song this close to its end starts over rather than playing only its last seconds. */ +export const RESUME_END_MARGIN_SECONDS = 5; + +const idOf = (track: SongItem): string => track.videoId ?? track.id; + +export function serializeQueue(snapshot: QueueSnapshot): SavedMusicQueue { + const { queue, unshuffledQueue } = snapshot; + const current = queue[snapshot.currentIndex]; + const order = unshuffledQueue?.map((track) => queue.indexOf(track)).filter((index) => index >= 0) ?? null; + return { + version: 1, + queue, + currentIndex: snapshot.currentIndex, + currentTrackId: current ? idOf(current) : null, + positionSeconds: Math.max(0, snapshot.progress), + queueSource: snapshot.queueSource, + unshuffledOrder: order, + radioQueuedIds: snapshot.radioQueuedIds, + radioStationActive: snapshot.radioStationActive, + }; +} + +const isSongLike = (value: unknown): value is SongItem => + typeof value === "object" && + value !== null && + typeof (value as SongItem).id === "string" && + typeof (value as SongItem).title === "string" && + Array.isArray((value as SongItem).artists); + +/** Reads saved JSON back, or null when there is no queue worth restoring. */ +export function parseSavedQueue(json: string | null): RestoredQueue | null { + if (!json) return null; + let saved: Partial; + try { + saved = JSON.parse(json) as Partial; + } catch { + return null; + } + if (saved.version !== 1 || !Array.isArray(saved.queue)) return null; + const queue = saved.queue.filter(isSongLike); + if (queue.length === 0 || queue.length !== saved.queue.length) return null; + + // The track id wins over a stale index. + const byId = saved.currentTrackId ? queue.findIndex((t) => idOf(t) === saved.currentTrackId) : -1; + const currentIndex = byId >= 0 ? byId : Math.min(Math.max(0, saved.currentIndex ?? 0), queue.length - 1); + const track = queue[currentIndex]; + const duration = track?.duration ?? 0; + const position = Math.max(0, saved.positionSeconds ?? 0); + const resumesInside = duration <= 0 || position < duration - RESUME_END_MARGIN_SECONDS; + + const order = saved.unshuffledOrder; + const unshuffledQueue = + Array.isArray(order) && order.length === queue.length && new Set(order).size === queue.length + ? order.map((index) => queue[index]).filter((t): t is SongItem => t !== undefined) + : null; + + return { + queue, + currentIndex, + resumeAt: resumesInside ? position : 0, + queueSource: typeof saved.queueSource === "string" ? saved.queueSource : null, + unshuffledQueue: unshuffledQueue && unshuffledQueue.length === queue.length ? unshuffledQueue : null, + radioQueuedIds: Array.isArray(saved.radioQueuedIds) ? saved.radioQueuedIds.filter((id) => typeof id === "string") : [], + radioStationActive: saved.radioStationActive === true, + }; +} diff --git a/src/lib/musicResume.test.ts b/src/lib/musicResume.test.ts new file mode 100644 index 00000000..9197e041 --- /dev/null +++ b/src/lib/musicResume.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from "vitest"; + +const { settings, getMusicResumePosition, saveMusicResumePosition } = vi.hoisted(() => ({ + settings: { music_resume_long_tracks: "true" } as Record, + getMusicResumePosition: vi.fn(async (): Promise => 23 * 60), + saveMusicResumePosition: vi.fn(async () => {}), +})); + +vi.mock("../store/useAppSettingsStore", () => ({ getSettingValue: (key: string) => settings[key] ?? "" })); +vi.mock("./api/musicQueue", () => ({ getMusicResumePosition, saveMusicResumePosition })); + +import type { SongItem } from "../types/music"; +import { isLongTrack, rememberResumePosition, resumePositionFor, resumeStartSeconds } from "./musicResume"; + +const episode = 45 * 60; +const track = (seconds: number) => ({ id: "episode0001", videoId: "episode0001", duration: seconds }) as SongItem; + +// Cases ported from Android's MusicResumePolicyTest. +describe("resumeStartSeconds", () => { + it("resumes a long track where it stopped", () => { + expect(resumeStartSeconds(23 * 60, episode)).toBe(23 * 60); + }); + + it("always starts a song over", () => { + expect(resumeStartSeconds(90, 4 * 60)).toBe(0); + }); + + it("counts ten minutes as long", () => { + expect(isLongTrack(10 * 60)).toBe(true); + expect(isLongTrack(10 * 60 - 1)).toBe(false); + }); + + it("starts a finished track over", () => { + expect(resumeStartSeconds(episode, episode)).toBe(0); + expect(resumeStartSeconds(episode * 0.99, episode)).toBe(0); + }); + + it("never resumes a track of unknown length", () => { + expect(resumeStartSeconds(60, 0)).toBe(0); + }); +}); + +describe("the resume setting", () => { + it("reads and writes positions only while it is on, and only for long tracks", async () => { + expect(await resumePositionFor(track(episode))).toBe(23 * 60); + rememberResumePosition(track(episode), 30 * 60, episode); + rememberResumePosition(track(200), 100, 200); + expect(saveMusicResumePosition).toHaveBeenCalledTimes(1); + + settings.music_resume_long_tracks = "false"; + expect(await resumePositionFor(track(episode))).toBe(0); + rememberResumePosition(track(episode), 30 * 60, episode); + expect(saveMusicResumePosition).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/musicResume.ts b/src/lib/musicResume.ts new file mode 100644 index 00000000..ae04b4a5 --- /dev/null +++ b/src/lib/musicResume.ts @@ -0,0 +1,40 @@ +import type { SongItem } from "../types/music"; +import { getMusicResumePosition, saveMusicResumePosition } from "./api/musicQueue"; +import { SETTINGS } from "./settings/schema"; +import { getSettingValue } from "../store/useAppSettingsStore"; + +/** + * Long tracks (mixes, podcasts, episodes) pick up where they stopped; songs + * always start over. Ported from Android's MusicResumePolicy, MusicResumeTracker + * and PlaybackResumePolicy. + */ +export const LONG_TRACK_SECONDS = 10 * 60; + +export const isLongTrack = (durationSeconds: number): boolean => durationSeconds >= LONG_TRACK_SECONDS; + +/** Where a long track starts: its saved position, unless it was finished or barely begun. */ +export function resumeStartSeconds(savedSeconds: number, durationSeconds: number): number { + if (!isLongTrack(durationSeconds) || savedSeconds <= 0.5) return 0; + const finished = durationSeconds - savedSeconds <= 1.5 || savedSeconds >= durationSeconds * 0.98; + return finished ? 0 : savedSeconds; +} + +const resumeEnabled = (): boolean => getSettingValue(SETTINGS.MUSIC_RESUME_LONG_TRACKS) === "true"; + +const idOf = (track: SongItem): string => track.videoId ?? track.id; + +/** The position a fresh play of `track` starts from, 0 for a song or with the setting off. */ +export async function resumePositionFor(track: SongItem): Promise { + const duration = track.duration ?? 0; + if (!resumeEnabled() || !isLongTrack(duration)) return 0; + const saved = await getMusicResumePosition(idOf(track)).catch(() => null); + return saved === null ? 0 : resumeStartSeconds(saved, duration); +} + +/** Remembers where a long track was left; nothing is written for songs or with the setting off. */ +export function rememberResumePosition(track: SongItem, positionSeconds: number, durationSeconds: number): void { + if (!resumeEnabled() || !isLongTrack(durationSeconds)) return; + void saveMusicResumePosition(idOf(track), positionSeconds, durationSeconds).catch((error) => + console.warn("Failed to save where a long track was left", error), + ); +} diff --git a/src/lib/settings/schema.ts b/src/lib/settings/schema.ts index 27e6e0e7..7657e971 100644 --- a/src/lib/settings/schema.ts +++ b/src/lib/settings/schema.ts @@ -67,6 +67,8 @@ export const SETTINGS = { MUSIC_ARTWORK_CONTROL_COLORS: "music_artwork_control_colors", MUSIC_PLAIN_CONTROL_COLORS: "music_plain_control_colors", MUSIC_PLAYER_FOLLOW_LIGHT_THEME: "music_player_follow_light_theme", + MUSIC_RESTORE_QUEUE: "music_restore_queue", + MUSIC_RESUME_LONG_TRACKS: "music_resume_long_tracks", LYRICS_PROVIDER_ORDER: "lyrics_provider_order", LYRICS_PROVIDER_ENABLED_STATES: "lyrics_provider_enabled_states", @@ -281,6 +283,8 @@ export const SETTING_DEFINITIONS = [ bool(SETTINGS.MUSIC_ARTWORK_CONTROL_COLORS, "player", true, "wired"), str(SETTINGS.MUSIC_PLAIN_CONTROL_COLORS, "player", "monochrome", "wired", ["monochrome", "app_theme"]), bool(SETTINGS.MUSIC_PLAYER_FOLLOW_LIGHT_THEME, "player", false, "wired"), + bool(SETTINGS.MUSIC_RESTORE_QUEUE, "player", true, "wired"), + bool(SETTINGS.MUSIC_RESUME_LONG_TRACKS, "player", false, "wired"), str(SETTINGS.LYRICS_PROVIDER_ORDER, "player", "", "wired"), json(SETTINGS.LYRICS_PROVIDER_ENABLED_STATES, "player", "{}", "wired"), diff --git a/src/lib/useMusicListenHistory.ts b/src/lib/useMusicListenHistory.ts new file mode 100644 index 00000000..91284935 --- /dev/null +++ b/src/lib/useMusicListenHistory.ts @@ -0,0 +1,29 @@ +import { useEffect } from "react"; + +import { useMusicPlayerStore } from "../store/useMusicPlayerStore"; +import { MusicListenTracker } from "./musicListenTracker"; +import { recordSongHistory } from "./musicHistory"; +import { rememberResumePosition } from "./musicResume"; + +/** The one tracker the audio element's events feed. */ +export const musicListens = new MusicListenTracker({ + recordListen: (track, listenedSeconds, durationSeconds) => + void recordSongHistory(track, listenedSeconds, durationSeconds), + recordPosition: rememberResumePosition, +}); + +/** Closes a song's listen when the player moves on, and saves it when the app closes. */ +export function useMusicListenHistory() { + const currentTrack = useMusicPlayerStore((s) => s.currentTrack); + const currentId = currentTrack ? (currentTrack.videoId ?? currentTrack.id) : null; + + useEffect(() => { + musicListens.switchTo(useMusicPlayerStore.getState().currentTrack, Date.now()); + }, [currentId]); + + useEffect(() => { + const flush = () => musicListens.flush(); + window.addEventListener("beforeunload", flush); + return () => window.removeEventListener("beforeunload", flush); + }, []); +} diff --git a/src/lib/useMusicQueuePersistence.ts b/src/lib/useMusicQueuePersistence.ts new file mode 100644 index 00000000..34531d76 --- /dev/null +++ b/src/lib/useMusicQueuePersistence.ts @@ -0,0 +1,97 @@ +import { useEffect } from "react"; + +import { useMusicPlayerStore } from "../store/useMusicPlayerStore"; +import { getSettingValue, useAppSettingsStore } from "../store/useAppSettingsStore"; +import { clearMusicQueueState, loadMusicQueueState, saveMusicQueueState } from "./api/musicQueue"; +import { parseSavedQueue, serializeQueue } from "./musicQueuePersistence"; +import { SETTINGS } from "./settings/schema"; + +/** Saves land this long after a change, so a burst of edits or a playing song writes rarely. */ +const SAVE_DELAY_MS = 5_000; + +const restoreEnabled = () => getSettingValue(SETTINGS.MUSIC_RESTORE_QUEUE) !== "false"; + +const settingsLoaded = (): Promise => + new Promise((resolve) => { + if (useAppSettingsStore.getState().loaded) { + resolve(); + return; + } + const unsubscribe = useAppSettingsStore.subscribe((state) => { + if (!state.loaded) return; + unsubscribe(); + resolve(); + }); + }); + +function writeNow(): void { + if (!restoreEnabled()) return; + const state = useMusicPlayerStore.getState(); + const request = + state.queue.length === 0 + ? clearMusicQueueState() + : saveMusicQueueState(JSON.stringify(serializeQueue(state))); + request.catch((error) => console.warn("Failed to save the music queue", error)); +} + +/** + * Brings back the last session's queue at launch, paused on its song (Android's + * QueuePersistence), and keeps the saved copy current: five seconds after a + * change, and at once on a pause or when the app closes. + */ +export function useMusicQueuePersistence() { + useEffect(() => { + let cancelled = false; + // Nothing is written until the saved queue has been read, or the empty + // player at launch would overwrite it. + let ready = false; + let timer: ReturnType | null = null; + + const flush = () => { + if (timer !== null) clearTimeout(timer); + timer = null; + if (ready) writeNow(); + }; + const schedule = () => { + if (timer === null) timer = setTimeout(flush, SAVE_DELAY_MS); + }; + + const unsubscribe = useMusicPlayerStore.subscribe((s, prev) => { + if (!ready) return; + if ((prev.isPlaying && !s.isPlaying) || (s.queue !== prev.queue && s.queue.length === 0)) { + flush(); + return; + } + if ( + s.queue !== prev.queue || + s.currentIndex !== prev.currentIndex || + s.progress !== prev.progress || + s.queueSource !== prev.queueSource || + s.unshuffledQueue !== prev.unshuffledQueue || + s.radioQueuedIds !== prev.radioQueuedIds || + s.radioStationActive !== prev.radioStationActive + ) { + schedule(); + } + }); + window.addEventListener("beforeunload", flush); + + void (async () => { + await settingsLoaded(); + if (cancelled) return; + if (restoreEnabled() && !useMusicPlayerStore.getState().currentTrack) { + const saved = parseSavedQueue(await loadMusicQueueState().catch(() => null)); + if (cancelled) return; + if (saved) useMusicPlayerStore.getState().restoreSavedQueue(saved); + } + ready = true; + })(); + + return () => { + cancelled = true; + unsubscribe(); + window.removeEventListener("beforeunload", flush); + if (timer !== null) clearTimeout(timer); + }; + }, []); +} diff --git a/src/store/useMusicPlayerStore.playback.test.ts b/src/store/useMusicPlayerStore.playback.test.ts index 75742bf6..ec7b3ce0 100644 --- a/src/store/useMusicPlayerStore.playback.test.ts +++ b/src/store/useMusicPlayerStore.playback.test.ts @@ -258,3 +258,40 @@ describe("watching for a stalled connection", () => { useMusicPlayerStore.getState()._setBuffering(false); }); }); + +describe("a queue restored at launch", () => { + const saved = (ids: string[], currentIndex: number, resumeAt: number) => ({ + queue: ids.map(song), + currentIndex, + resumeAt, + queueSource: null, + unshuffledQueue: null, + radioQueuedIds: [], + radioStationActive: false, + }); + + it("shows the song paused, and the first play loads it at the saved position", async () => { + useMusicPlayerStore.getState().dismiss(); + useMusicPlayerStore.getState().restoreSavedQueue(saved(["restored-a", "restored-b"], 1, 73)); + + let state = useMusicPlayerStore.getState(); + expect(state.currentTrack?.id).toBe("restored-b"); + expect(state.isPlaying).toBe(false); + expect(state.progress).toBe(73); + const { prefetchMusicStream } = await import("../lib/musicStreamResolution"); + expect(resolveMusicStream).not.toHaveBeenCalled(); + expect(prefetchMusicStream).not.toHaveBeenCalled(); + + state.play(); + await vi.waitFor(() => expect(engine.seekWhenReady).toHaveBeenLastCalledWith(73)); + state = useMusicPlayerStore.getState(); + expect(state.isPlaying).toBe(true); + expect(resolveMusicStream).toHaveBeenCalledWith("restored-b", expect.anything(), expect.anything()); + }); + + it("never replaces a song the user already started", async () => { + await useMusicPlayerStore.getState().playTrack(song("chosen")); + useMusicPlayerStore.getState().restoreSavedQueue(saved(["old"], 0, 0)); + expect(useMusicPlayerStore.getState().currentTrack?.id).toBe("chosen"); + }); +}); diff --git a/src/store/useMusicPlayerStore.ts b/src/store/useMusicPlayerStore.ts index 5c679b7c..ad6105ce 100644 --- a/src/store/useMusicPlayerStore.ts +++ b/src/store/useMusicPlayerStore.ts @@ -29,6 +29,8 @@ import { type QueueLists, } from "../lib/musicQueueEdits"; import { radioSession } from "../lib/musicRadioSession"; +import type { RestoredQueue } from "../lib/musicQueuePersistence"; +import { isLongTrack, resumePositionFor } from "../lib/musicResume"; import { POOL_LOW_WATER } from "../lib/musicRadioPlanner"; import { SETTINGS } from "../lib/settings/schema"; import { getSettingValue } from "./useAppSettingsStore"; @@ -119,6 +121,10 @@ const waitForNetwork = (onBack: () => void) => { stopWaitingForNetwork = () => window.removeEventListener("online", handler); }; +// A queue restored at launch shows its song paused without loading it; the +// first play loads it at the saved position. +let awaitingFirstPlay = false; + // Unplayable tracks skipped since the last one that started. let unplayableSkips = 0; @@ -270,6 +276,8 @@ interface MusicPlayerState { clearQueue: () => void; /** Puts back the queue as it was before the last removal or clear. */ undoQueueEdit: () => void; + /** Shows the queue saved at the last session, paused, unless something already plays. */ + restoreSavedQueue: (restored: RestoredQueue) => void; // --- block list integration (driven by useMusicActionsStore) --- setHiddenPredicate: (predicate: (track: SongItem) => boolean) => void; @@ -410,8 +418,9 @@ export const useMusicPlayerStore = create((set, get) => ({ const track = get().queue[index]; if (!track) return; const videoId = videoIdOf(track); - const resumeAt = options?.resumeAt ?? 0; + let resumeAt = options?.resumeAt ?? 0; const autoplay = options?.autoplay ?? true; + awaitingFirstPlay = false; cancelNetworkWait(); if (autoplay) userPaused = false; const start = async () => { @@ -440,6 +449,13 @@ export const useMusicPlayerStore = create((set, get) => ({ get()._prefetchUpcoming(); }; + // A fresh play of a long track picks up where it was left (a setting). + if (options?.resumeAt === undefined && isLongTrack(track.duration ?? 0)) { + resumeAt = await resumePositionFor(track); + if (get().loadingStreamId !== videoId) return; + if (resumeAt > 0) set({ progress: resumeAt }); + } + // Gapless: the standby element already holds this track, buffered. if (resumeAt === 0 && musicAudioEngine.activatePreloaded(videoId)) { const loudnessDb = musicAudioEngine.getActiveLoudness(); @@ -551,6 +567,10 @@ export const useMusicPlayerStore = create((set, get) => ({ play: () => { if (!get().currentTrack) return; + if (awaitingFirstPlay) { + void get()._loadIndex(get().currentIndex, { resumeAt: get().progress }); + return; + } userPaused = false; void musicAudioEngine.play(); set({ isPlaying: true }); @@ -566,6 +586,7 @@ export const useMusicPlayerStore = create((set, get) => ({ // Tear down the player entirely — stops audio, clears the queue, hides the // dock/overlay. (The controller flushes a final history record on track clear.) dismiss: () => { + awaitingFirstPlay = false; stopStallWatch(); cancelNetworkWait(); musicAudioEngine.stop(); @@ -813,6 +834,26 @@ export const useMusicPlayerStore = create((set, get) => ({ set({ ...snapshot, currentIndex }); }, + restoreSavedQueue: (restored) => { + const track = restored.queue[restored.currentIndex]; + if (get().currentTrack || !track) return; + resetRadioSession(restored.queue.map(videoIdOf)); + const unshuffled = get().isShuffle ? (restored.unshuffledQueue ?? restored.queue) : null; + awaitingFirstPlay = true; + set({ + ...freshQueue(restored.queue, unshuffled), + radioQueuedIds: restored.radioQueuedIds, + radioStationActive: restored.radioStationActive, + queueSource: restored.queueSource, + currentTrack: track, + currentIndex: restored.currentIndex, + progress: restored.resumeAt, + duration: track.duration ?? 0, + isPlaying: false, + isBuffering: false, + }); + }, + setHiddenPredicate: (predicate) => { hiddenPredicate = predicate; }, @@ -935,9 +976,10 @@ export const useMusicPlayerStore = create((set, get) => ({ })); // Radio additions, queue edits and reorders all change what plays next, so warm -// its link whenever the queue does. +// its link whenever the queue does. A queue restored at launch warms nothing +// until it is played, so startup makes no network request for it. useMusicPlayerStore.subscribe((state, previous) => { - if (state.queue !== previous.queue && state.currentTrack && state.loadingStreamId === null) { + if (state.queue !== previous.queue && state.currentTrack && state.loadingStreamId === null && !awaitingFirstPlay) { state._prefetchUpcoming(); } });