diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 5a76874..9492b2e 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -47,6 +47,7 @@ import { SetRatingSheet } from '../components/SetRatingSheet'; import { SleepTimerSheet } from '../components/SleepTimerSheet'; import { PlaybackToast } from '../components/PlaybackToast'; import { ProcessingOverlay } from '../components/ProcessingOverlay'; +import { startSongLibraryCacheAutoWarm } from '../hooks/useAllSongsByTitle'; import { useDownloadBackgroundNotification } from '../hooks/useDownloadBackgroundNotification'; import { useDownloadKeepAwake } from '../hooks/useDownloadKeepAwake'; import { useLayoutMode } from '../hooks/useLayoutMode'; @@ -241,6 +242,12 @@ async function runDeferredStartup(getCancelled: () => boolean): Promise { await stage('deferredDataSyncInit', () => deferredDataSyncInit()); if (getCancelled()) return; + // Keep the songs-library cache warm so the first tap on the Songs segment is + // an instant hit. Starts the initial warm + a debounced re-warm on every + // song-index mutation (the album-detail sync above churns the counter). + await stage('startSongLibraryCacheAutoWarm', () => { startSongLibraryCacheAutoWarm(); }); + if (getCancelled()) return; + // Recover any image-download-queue rows left stalled by a previous // session (in 'downloading' or 'error'), then drain whatever's queued. // Both stages are no-ops when there's nothing to do. diff --git a/src/hooks/useAllSongsByTitle.ts b/src/hooks/useAllSongsByTitle.ts index 51ec9c9..88adf52 100644 --- a/src/hooks/useAllSongsByTitle.ts +++ b/src/hooks/useAllSongsByTitle.ts @@ -1,9 +1,10 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { InteractionManager } from 'react-native'; import { getLocalTrackUri } from '../services/musicCacheService'; import { favoritesStore } from '../store/favoritesStore'; import { musicCacheStore } from '../store/musicCacheStore'; -import { fetchAllSongsByTitle } from '../store/persistence/detailTables'; +import { fetchAllSongsByTitleAsync } from '../store/persistence/detailTables'; import { songIndexStore } from '../store/songIndexStore'; import type { Child } from '../services/subsonicService'; @@ -15,24 +16,70 @@ interface UseAllSongsByTitleOpts { interface UseAllSongsByTitleResult { songs: Child[]; totalCount: number; + /** True while the base list is being fetched (cold cache); false once warm. */ + loading: boolean; refresh: () => void; } +const EMPTY: Child[] = []; + /** * Module-level cache for the unfiltered base list keyed by mutationCounter * (+ pull-to-refresh nonce). Filtering is applied in the hook against live * stores so star/download changes anywhere in the app reflect immediately. + * + * The fetch is async (SQLite read off the JS thread, chunked mapping), so the + * cache is filled in the background. A warm hit is returned synchronously and + * instantly; a cold miss resolves asynchronously while the UI shows a spinner. */ let cachedBase: Child[] | null = null; let cachedKey = -1; +/** Key currently being fetched + its promise, so concurrent callers (e.g. the + * auto-warmer and a cold mount) share one fetch and all await its result. */ +let inFlightKey = -1; +let inFlightPromise: Promise | null = null; + +const keyOf = (counter: number, refreshNonce: number): number => + counter + refreshNonce * 1e9; + +/** True when the module cache already holds the list for this key. */ +function isWarm(counter: number, refreshNonce: number): boolean { + return cachedBase !== null && cachedKey === keyOf(counter, refreshNonce); +} -function getBaseList(counter: number, refreshNonce: number): Child[] { - const effectiveKey = counter + refreshNonce * 1e9; - if (cachedBase === null || cachedKey !== effectiveKey) { - cachedBase = fetchAllSongsByTitle(); - cachedKey = effectiveKey; +/** + * Fill the module cache for the given key. Resolves once `cachedBase` reflects + * this key. If a fetch for the same key is already in flight, awaits it rather + * than starting a second one (so a cold mount that races the auto-warmer still + * sees the populated cache when it resolves). + */ +async function fillBaseAsync(counter: number, refreshNonce: number): Promise { + const key = keyOf(counter, refreshNonce); + if (cachedBase !== null && cachedKey === key) return; + if (inFlightKey === key && inFlightPromise) { + await inFlightPromise; + return; } - return cachedBase; + inFlightKey = key; + const run = async () => { + try { + const list = await fetchAllSongsByTitleAsync(); + // Commit only if this is still the key we set out to fetch — a newer + // mutation may have superseded us mid-flight. + if (inFlightKey === key) { + cachedBase = list; + cachedKey = key; + } + } finally { + if (inFlightKey === key) { + inFlightKey = -1; + inFlightPromise = null; + } + } + }; + const p = run(); + inFlightPromise = p; + await p; } /** @@ -42,7 +89,10 @@ function getBaseList(counter: number, refreshNonce: number): Child[] { * **Reactivity model:** * - The unfiltered base list is cached at module scope keyed by * `songIndexStore.mutationCounter` (advances on album sync writes and - * orphan reaps) plus a pull-to-refresh nonce. + * orphan reaps) plus a pull-to-refresh nonce. It's fetched asynchronously, + * so a cold cache shows a brief spinner rather than freezing the JS thread. + * `startSongLibraryCacheAutoWarm` keeps it warm so the common path is a + * synchronous instant hit. * - `downloadedOnly` and `favoritesOnly` filters are applied in JS against * live stores (`musicCacheStore.cachedItems`, `favoritesStore.songs` + * `overrides`) so star/download/delete actions from anywhere in the app @@ -61,18 +111,39 @@ export function useAllSongsByTitle( const totalCount = songIndexStore((s) => s.totalCount); const [refreshNonce, setRefreshNonce] = useState(0); - // Live subscriptions — re-fire useMemo when star/download state changes. + // Live subscriptions — re-fire the filter useMemo when star/download changes. const starredSongs = favoritesStore((s) => s.songs); const starOverrides = favoritesStore((s) => s.overrides); const cachedItems = musicCacheStore((s) => s.cachedItems); - const base = useMemo( - () => getBaseList(mutationCounter, refreshNonce), - [mutationCounter, refreshNonce], - ); + // Seed from a warm cache synchronously so the common (pre-warmed) path + // renders the list instantly with no spinner and no async round-trip. + const warmAtMount = isWarm(mutationCounter, refreshNonce); + const [base, setBase] = useState(warmAtMount ? cachedBase : null); + const [loading, setLoading] = useState(!warmAtMount); + + useEffect(() => { + let cancelled = false; + if (isWarm(mutationCounter, refreshNonce)) { + setBase(cachedBase); + setLoading(false); + return; + } + setLoading(true); + void fillBaseAsync(mutationCounter, refreshNonce).then(() => { + if (cancelled) return; + setBase(cachedBase); + setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [mutationCounter, refreshNonce]); + + const safeBase = base ?? EMPTY; const songs = useMemo(() => { - if (!downloadedOnly && !favoritesOnly) return base; + if (!downloadedOnly && !favoritesOnly) return safeBase; let starredIds: Set | null = null; if (favoritesOnly) { @@ -85,7 +156,7 @@ export function useAllSongsByTitle( } } - return base.filter((song) => { + return safeBase.filter((song) => { if (favoritesOnly && starredIds && !starredIds.has(song.id)) return false; if (downloadedOnly && getLocalTrackUri(song.id) === null) return false; return true; @@ -93,23 +164,88 @@ export function useAllSongsByTitle( // cachedItems is a dep so the JS filter re-runs whenever a download // completes/is deleted (trackUriMap is synchronised with cachedItems // writes, so reading getLocalTrackUri inside the filter sees fresh state). - }, [base, downloadedOnly, favoritesOnly, starredSongs, starOverrides, cachedItems]); + }, [safeBase, downloadedOnly, favoritesOnly, starredSongs, starOverrides, cachedItems]); const refresh = useMemo( () => () => setRefreshNonce((n) => n + 1), [], ); - return { songs, totalCount, refresh }; + return { songs, totalCount, loading, refresh }; } /** - * Populate the module-level base cache without rendering anything. Called - * once after SQLite rehydration so the first tap on the Songs library - * segment finds a hot cache. Safe to call multiple times — subsequent - * calls are a cache-hit no-op. + * Populate the module-level base cache without rendering anything. Idempotent — + * a call for an already-warm key is a no-op. Async so the SQLite read stays off + * the JS thread; callers may ignore the returned promise. */ -export function warmSongLibraryCache(): void { +export function warmSongLibraryCache(): Promise { const mc = songIndexStore.getState().mutationCounter; - getBaseList(mc, 0); + return fillBaseAsync(mc, 0); +} + +/* ------------------------------------------------------------------ */ +/* Background auto-warmer */ +/* ------------------------------------------------------------------ */ + +let autoWarmStarted = false; +let warmDebounce: ReturnType | null = null; +/** + * Wait for song-index writes to settle before re-warming. The startup + * album-detail walk bumps `mutationCounter` per album; debouncing means a long + * write burst triggers a single rebuild once it quiets, not one per album. + */ +const WARM_DEBOUNCE_MS = 2000; + +/** + * Keep the songs-library cache hot across the app's lifetime so the first tap + * on the Songs segment is an instant warm hit. + * + * Warms once after startup interactions settle, then re-warms (debounced, on + * idle) whenever `songIndexStore.mutationCounter` changes. This is the fix for + * the first-browse lag: the old one-shot warm was invalidated by the + * post-startup album-detail sync that advances the counter, re-arming the cold + * fetch before the user ever reached the segment. + * + * Idempotent — safe to call more than once; only the first call wires up. + * Returns a disposer (unused in normal app flow; the subscription lives for the + * app's lifetime). + */ +export function startSongLibraryCacheAutoWarm(): () => void { + if (autoWarmStarted) return () => {}; + autoWarmStarted = true; + + const scheduleWarm = () => { + if (warmDebounce) clearTimeout(warmDebounce); + warmDebounce = setTimeout(() => { + warmDebounce = null; + InteractionManager.runAfterInteractions(() => { + void warmSongLibraryCache(); + }); + }, WARM_DEBOUNCE_MS); + }; + + // Initial warm once startup interactions have settled. + InteractionManager.runAfterInteractions(() => { + void warmSongLibraryCache(); + }); + + // Re-warm on any song-index mutation (album walk, orphan reap, manual scan). + // songIndexStore has no subscribeWithSelector middleware, so diff manually. + let lastCounter = songIndexStore.getState().mutationCounter; + const unsub = songIndexStore.subscribe((s) => { + if (s.mutationCounter !== lastCounter) { + lastCounter = s.mutationCounter; + scheduleWarm(); + } + }); + + return () => { + unsub(); + if (warmDebounce) { + clearTimeout(warmDebounce); + warmDebounce = null; + } + autoWarmStarted = false; + }; } diff --git a/src/screens/song-library-list.tsx b/src/screens/song-library-list.tsx index b0a9a0f..8228090 100644 --- a/src/screens/song-library-list.tsx +++ b/src/screens/song-library-list.tsx @@ -20,7 +20,7 @@ export function SongLibraryListScreen({ contentInsetTop?: number; }) { const { t } = useTranslation(); - const { songs, refresh } = useAllSongsByTitle({ + const { songs, refresh, loading } = useAllSongsByTitle({ downloadedOnly, favoritesOnly, }); @@ -54,7 +54,7 @@ export function SongLibraryListScreen({ { const fake = { getFirstSync: jest.fn(), getAllSync: jest.fn(), + getAllAsync: jest.fn(), runSync: jest.fn(), execSync: jest.fn(), withTransactionSync: jest.fn(), diff --git a/src/store/persistence/__tests__/detailTables.test.ts b/src/store/persistence/__tests__/detailTables.test.ts index f6bc417..8df1dc5 100644 --- a/src/store/persistence/__tests__/detailTables.test.ts +++ b/src/store/persistence/__tests__/detailTables.test.ts @@ -19,6 +19,7 @@ import { deleteAlbumDetail, deleteSongsForAlbums, fetchAllSongsByTitle, + fetchAllSongsByTitleAsync, hydrateAlbumDetails, upsertAlbumDetail, upsertSongsForAlbum, @@ -116,6 +117,9 @@ function makeFakeDb() { } return []; }, + getAllAsync(sql: string): Promise { + return Promise.resolve(this.getAllSync(sql)); + }, runSync, execSync: () => {}, withTransactionSync: (fn: () => void) => fn(), @@ -365,6 +369,28 @@ describe('detailTables — fetchAllSongsByTitle', () => { }); }); +describe('detailTables — fetchAllSongsByTitleAsync', () => { + it('returns the same sorted Child list as the sync variant', async () => { + upsertSongsForAlbum('a1', [ + { id: 's3', title: 'cherry' }, + { id: 's1', title: 'Apple' }, + { id: 's2', title: 'banana' }, + ].map((s) => ({ ...s, artist: 'A', duration: 1, discNumber: 1 })) as any); + const out = await fetchAllSongsByTitleAsync(); + expect(out.map((s) => s.title)).toEqual(['Apple', 'banana', 'cherry']); + }); + + it('honors filters and returns [] when db is unavailable', async () => { + upsertSongsForAlbum('a1', [ + { id: 's1', title: 'aaa', starred: '2020' }, + { id: 's2', title: 'bbb' }, + ] as any); + expect((await fetchAllSongsByTitleAsync({ favoritesOnly: true })).map((s) => s.id)).toEqual(['s1']); + __setDbForTests(null); + expect(await fetchAllSongsByTitleAsync()).toEqual([]); + }); +}); + describe('detailTables — disabled db (healthy=false path)', () => { beforeEach(() => { __setDbForTests(null); diff --git a/src/store/persistence/__tests__/kvStorage.test.ts b/src/store/persistence/__tests__/kvStorage.test.ts index a300df3..7d47f35 100644 --- a/src/store/persistence/__tests__/kvStorage.test.ts +++ b/src/store/persistence/__tests__/kvStorage.test.ts @@ -15,6 +15,7 @@ describe('kvStorage (happy path)', () => { __setDbForTests({ getFirstSync: mockGetFirstSync, getAllSync: jest.fn(), + getAllAsync: jest.fn(), runSync: mockRunSync, execSync: jest.fn(), withTransactionSync: jest.fn(), diff --git a/src/store/persistence/db.ts b/src/store/persistence/db.ts index 01c4d95..a3ec3bd 100644 --- a/src/store/persistence/db.ts +++ b/src/store/persistence/db.ts @@ -40,6 +40,12 @@ export interface RunResult { export interface InternalDb { getFirstSync(sql: string, params?: readonly unknown[]): T | undefined; getAllSync(sql: string, params?: readonly unknown[]): T[]; + /** + * Async row read. expo-sqlite runs this on a background native thread, so + * the SQLite IO does not block the JS thread (unlike `getAllSync`). Used by + * the songs-library pre-warm / cold fetch — see `fetchAllSongsByTitleAsync`. + */ + getAllAsync(sql: string, params?: readonly unknown[]): Promise; runSync(sql: string, params?: readonly unknown[]): RunResult; execSync(sql: string): void; withTransactionSync(fn: () => void): void; diff --git a/src/store/persistence/detailTables.ts b/src/store/persistence/detailTables.ts index f964d4c..a73051c 100644 --- a/src/store/persistence/detailTables.ts +++ b/src/store/persistence/detailTables.ts @@ -172,21 +172,17 @@ interface SongIndexRow { disc: number | null; } -/** - * Read every song row sorted alphabetically by title (case-insensitive), - * with optional downloadedOnly / favoritesOnly filters. Used by the Songs - * library segment. Backed by `idx_song_index_title` so the sort is free. - * NULL titles sort to the end and `id` is the stable tie-breaker. - */ -export function fetchAllSongsByTitle( - opts: { downloadedOnly?: boolean; favoritesOnly?: boolean } = {}, -): Child[] { - const db = getDb(); - if (db === null) return []; +interface SongsByTitleOpts { + downloadedOnly?: boolean; + favoritesOnly?: boolean; +} + +/** Build the songs-library SELECT, honoring the downloaded/favorites filters. */ +function buildSongsByTitleSql(opts: SongsByTitleOpts): string { const useJoin = opts.downloadedOnly === true; const wantFavorites = opts.favoritesOnly === true; const prefix = useJoin ? 's.' : ''; - const sql = + return ( `SELECT ${prefix}id AS id, ${prefix}albumId AS albumId, ${prefix}title AS title,` + ` ${prefix}artist AS artist, ${prefix}album AS album,` + ` ${prefix}duration AS duration, ${prefix}coverArt AS coverArt,` + @@ -194,28 +190,76 @@ export function fetchAllSongsByTitle( ` ${prefix}track AS track, ${prefix}disc AS disc` + ` FROM song_index${useJoin ? ' s INNER JOIN cached_songs c ON c.song_id = s.id' : ''}` + (wantFavorites ? ` WHERE ${prefix}starred = 1` : '') + - ` ORDER BY (${prefix}title IS NULL), lower(${prefix}title), ${prefix}id;`; + ` ORDER BY (${prefix}title IS NULL), lower(${prefix}title), ${prefix}id;` + ); +} + +/** Map one `song_index` row to the `Child` shape used by the library list. */ +function mapSongRow(r: SongIndexRow): Child { + return { + id: r.id, + albumId: r.albumId, + title: r.title ?? '', + artist: r.artist ?? undefined, + album: r.album ?? undefined, + duration: r.duration ?? undefined, + coverArt: r.coverArt ?? undefined, + userRating: r.userRating ?? undefined, + starred: r.starred ? new Date(0) : undefined, + year: r.year ?? undefined, + track: r.track ?? undefined, + discNumber: r.disc ?? undefined, + isDir: false, + } as Child; +} + +/** + * Read every song row sorted alphabetically by title (case-insensitive), + * with optional downloadedOnly / favoritesOnly filters. Used by the Songs + * library segment. Backed by `idx_song_index_title` so the sort is free. + * NULL titles sort to the end and `id` is the stable tie-breaker. + * + * **Synchronous** — blocks the JS thread for the read + mapping. Prefer + * `fetchAllSongsByTitleAsync` for the library pre-warm / cold path so a big + * library doesn't freeze the UI; this variant remains for sync callers/tests. + */ +export function fetchAllSongsByTitle(opts: SongsByTitleOpts = {}): Child[] { + const db = getDb(); + if (db === null) return []; + try { + const rows = db.getAllSync(buildSongsByTitleSql(opts)); + const out: Child[] = new Array(rows.length); + for (let i = 0; i < rows.length; i++) out[i] = mapSongRow(rows[i]); + return out; + } catch { + return []; + } +} + +/** Rows mapped per macrotask yield, keeping the JS thread responsive. */ +const SONG_MAP_CHUNK = 2000; + +/** + * Async counterpart of `fetchAllSongsByTitle`. The SQLite read runs on + * expo-sqlite's background thread (`getAllAsync`), and the JS row→`Child` + * mapping is chunked with `setTimeout(0)` yields so neither stage blocks the + * JS thread for long — even on a large library. Used by the pre-warm and the + * Songs segment's cold path. + */ +export async function fetchAllSongsByTitleAsync( + opts: SongsByTitleOpts = {}, +): Promise { + const db = getDb(); + if (db === null) return []; try { - const rows = db.getAllSync(sql); + const rows = await db.getAllAsync(buildSongsByTitleSql(opts)); const out: Child[] = new Array(rows.length); for (let i = 0; i < rows.length; i++) { - const r = rows[i]; - const child: Child = { - id: r.id, - albumId: r.albumId, - title: r.title ?? '', - artist: r.artist ?? undefined, - album: r.album ?? undefined, - duration: r.duration ?? undefined, - coverArt: r.coverArt ?? undefined, - userRating: r.userRating ?? undefined, - starred: r.starred ? new Date(0) : undefined, - year: r.year ?? undefined, - track: r.track ?? undefined, - discNumber: r.disc ?? undefined, - isDir: false, - } as Child; - out[i] = child; + out[i] = mapSongRow(rows[i]); + if (i > 0 && i % SONG_MAP_CHUNK === 0) { + // Yield to the event loop so touches/animations aren't starved. + await new Promise((resolve) => setTimeout(resolve, 0)); + } } return out; } catch { diff --git a/src/store/persistence/rehydrate.ts b/src/store/persistence/rehydrate.ts index 912923d..27e3b2c 100644 --- a/src/store/persistence/rehydrate.ts +++ b/src/store/persistence/rehydrate.ts @@ -62,19 +62,10 @@ export function rehydrateAllStores(): RehydrationResult { // eslint-disable-next-line no-console console.warn('[rehydrateAllStores] partial failure', result.failed); } - // Defer the songs-library cache warm-up to a microtask. The first tap on - // the Songs library segment otherwise pays a 100-300ms SQL + JS-mapping - // cost during render. Lazy-required to avoid pulling React into the - // persistence layer. - queueMicrotask(() => { - try { - const { warmSongLibraryCache } = require('../../hooks/useAllSongsByTitle') as { - warmSongLibraryCache: () => void; - }; - warmSongLibraryCache(); - } catch { - /* non-critical */ - } - }); + // The songs-library cache warm-up is owned by `startSongLibraryCacheAutoWarm` + // (started from the deferred-startup chain). A one-shot warm here was + // invalidated by the post-startup album-detail sync advancing + // `songIndexStore.mutationCounter`, so it's been moved to a resilient + // debounced auto-warmer that survives that churn. return result; }