Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -241,6 +242,12 @@ async function runDeferredStartup(getCancelled: () => boolean): Promise<void> {
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.
Expand Down
184 changes: 160 additions & 24 deletions src/hooks/useAllSongsByTitle.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<void> | 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<void> {
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;
}

/**
Expand All @@ -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
Expand All @@ -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<Child[] | null>(warmAtMount ? cachedBase : null);
const [loading, setLoading] = useState<boolean>(!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<string> | null = null;
if (favoritesOnly) {
Expand All @@ -85,31 +156,96 @@ 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;
});
// 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<void> {
const mc = songIndexStore.getState().mutationCounter;
getBaseList(mc, 0);
return fillBaseAsync(mc, 0);
}

/* ------------------------------------------------------------------ */
/* Background auto-warmer */
/* ------------------------------------------------------------------ */

let autoWarmStarted = false;
let warmDebounce: ReturnType<typeof setTimeout> | 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;
};
}
4 changes: 2 additions & 2 deletions src/screens/song-library-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function SongLibraryListScreen({
contentInsetTop?: number;
}) {
const { t } = useTranslation();
const { songs, refresh } = useAllSongsByTitle({
const { songs, refresh, loading } = useAllSongsByTitle({
downloadedOnly,
favoritesOnly,
});
Expand Down Expand Up @@ -54,7 +54,7 @@ export function SongLibraryListScreen({
<SongListView
songs={songs}
layout={layout}
loading={!hasHydrated}
loading={!hasHydrated || loading}
onRefresh={handleRefresh}
refreshing={refreshing}
onSongPress={handleSongPress}
Expand Down
1 change: 1 addition & 0 deletions src/store/persistence/__tests__/db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ describe('persistence/db (happy path)', () => {
const fake = {
getFirstSync: jest.fn(),
getAllSync: jest.fn(),
getAllAsync: jest.fn(),
runSync: jest.fn(),
execSync: jest.fn(),
withTransactionSync: jest.fn(),
Expand Down
26 changes: 26 additions & 0 deletions src/store/persistence/__tests__/detailTables.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
deleteAlbumDetail,
deleteSongsForAlbums,
fetchAllSongsByTitle,
fetchAllSongsByTitleAsync,
hydrateAlbumDetails,
upsertAlbumDetail,
upsertSongsForAlbum,
Expand Down Expand Up @@ -116,6 +117,9 @@ function makeFakeDb() {
}
return [];
},
getAllAsync<T>(sql: string): Promise<T[]> {
return Promise.resolve(this.getAllSync<T>(sql));
},
runSync,
execSync: () => {},
withTransactionSync: (fn: () => void) => fn(),
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/store/persistence/__tests__/kvStorage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
6 changes: 6 additions & 0 deletions src/store/persistence/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ export interface RunResult {
export interface InternalDb {
getFirstSync<T>(sql: string, params?: readonly unknown[]): T | undefined;
getAllSync<T>(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<T>(sql: string, params?: readonly unknown[]): Promise<T[]>;
runSync(sql: string, params?: readonly unknown[]): RunResult;
execSync(sql: string): void;
withTransactionSync(fn: () => void): void;
Expand Down
Loading