diff --git a/src/features/library/LibraryPage.tsx b/src/features/library/LibraryPage.tsx index b8c9e38..041b5d5 100644 --- a/src/features/library/LibraryPage.tsx +++ b/src/features/library/LibraryPage.tsx @@ -8,6 +8,7 @@ import { ScrollArea, Card, TextField, + Callout, } from '@olly/modern-sk'; import { useGetTracksQuery, @@ -18,13 +19,33 @@ import { TrackRow } from '../../components/track/TrackRow'; import { LoadingSkeleton } from '../../components/common/LoadingSkeleton'; import { EmptyState } from '../../components/common/EmptyState'; import { ErrorState } from '../../components/common/ErrorState'; -import { useAppDispatch } from '../../hooks/useAppDispatch'; +import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch'; +import { useIsOffline } from '../../hooks/useConnectionStatus'; +import { + selectLocalTracks, + selectLocalAlbums, + selectLocalArtists, +} from '../../store/selectors/localLibrary'; import { setQueue } from '../../store/slices/queue'; import type { Track, Album, Artist } from '../../api/types'; import { getCoverUrl } from '../../api/endpoints/streaming'; import { formatDuration } from '../../lib/format'; import { useDebounce } from 'use-debounce'; +/** Case-insensitive substring match used for client-side search while offline + * (the server can't run the query, so we filter the locally-cached library). */ +function matchTrack(tr: Track, q: string): boolean { + return ( + tr.title.toLowerCase().includes(q) || + tr.artistName.toLowerCase().includes(q) || + tr.albumTitle.toLowerCase().includes(q) + ); +} +const matchAlbum = (a: Album, q: string): boolean => + a.title.toLowerCase().includes(q) || a.artistName.toLowerCase().includes(q); +const matchArtist = (a: Artist, q: string): boolean => + a.name.toLowerCase().includes(q); + export function LibraryPage() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -43,6 +64,26 @@ export function LibraryPage() { debouncedSearch ? { search } : undefined, ); + // Offline fallback: when the backend is unreachable, compose the library from + // whatever the RTKQ cache holds locally (rehydrated last-seen + this session), + // filtering client-side since the server can't run the search. + const offline = useIsOffline(); + const localTracks = useAppSelector(selectLocalTracks); + const localAlbums = useAppSelector(selectLocalAlbums); + const localArtists = useAppSelector(selectLocalArtists); + const q = debouncedSearch.trim().toLowerCase(); + + // Live server data wins; offline we fall back to the locally-composed list. + const tracksToShow = + tracksQuery.data?.items ?? + (offline ? (q ? localTracks.filter((tr) => matchTrack(tr, q)) : localTracks) : undefined); + const albumsToShow = + albumsQuery.data?.items ?? + (offline ? (q ? localAlbums.filter((a) => matchAlbum(a, q)) : localAlbums) : undefined); + const artistsToShow = + artistsQuery.data?.items ?? + (offline ? (q ? localArtists.filter((a) => matchArtist(a, q)) : localArtists) : undefined); + const handlePlayAll = (tracks: Track[]) => { dispatch( setQueue({ @@ -84,6 +125,12 @@ export function LibraryPage() { + {offline && ( +
+ {t('library.offline.banner')} +
+ )} + - {tracksQuery.isLoading && } - {tracksQuery.isError && ( + {!tracksToShow && tracksQuery.isLoading && ( + + )} + {!tracksToShow && !offline && tracksQuery.isError && ( tracksQuery.refetch()} /> )} - {tracksQuery.data && tracksQuery.data.items.length === 0 && ( + {tracksToShow && tracksToShow.length === 0 && ( )} - {tracksQuery.data && - tracksQuery.data.items.length > 0 && - (() => { - const data = tracksQuery.data!; - return ( -
-
- -
- {data.items.map((track, i) => ( - - ))} -
- ); - })()} + {tracksToShow && tracksToShow.length > 0 && ( +
+
+ +
+ {tracksToShow.map((track, i) => ( + + ))} +
+ )}
- {albumsQuery.isLoading && } - {albumsQuery.isError && ( + {!albumsToShow && albumsQuery.isLoading && ( + + )} + {!albumsToShow && !offline && albumsQuery.isError && ( albumsQuery.refetch()} /> )} - {albumsQuery.data && albumsQuery.data.items.length === 0 && ( + {albumsToShow && albumsToShow.length === 0 && ( )} - {albumsQuery.data && ( + {albumsToShow && albumsToShow.length > 0 && (
- {albumsQuery.data.items.map((album) => ( + {albumsToShow.map((album) => ( - {artistsQuery.isLoading && } - {artistsQuery.isError && ( + {!artistsToShow && artistsQuery.isLoading && ( + + )} + {!artistsToShow && !offline && artistsQuery.isError && ( artistsQuery.refetch()} /> )} - {artistsQuery.data && artistsQuery.data.items.length === 0 && ( + {artistsToShow && artistsToShow.length === 0 && ( )} - {artistsQuery.data && ( + {artistsToShow && artistsToShow.length > 0 && (
- {artistsQuery.data.items.map((artist) => ( + {artistsToShow.map((artist) => ( => + state.api.queries; + +function fulfilled(queries: Record): CacheEntry[] { + const out: CacheEntry[] = []; + for (const entry of Object.values(queries)) { + const e = entry as CacheEntry | undefined; + if (e && e.status === 'fulfilled' && e.data != null) out.push(e); + } + return out; +} + +/** Every track known locally, deduped by id (last write wins). */ +export const selectLocalTracks = createSelector( + selectQueries, + (queries): Track[] => { + const byId = new Map(); + for (const e of fulfilled(queries)) { + switch (e.endpointName) { + case 'getTracks': + for (const t of (e.data as PaginatedResponse).items) + byId.set(t.id, t); + break; + case 'getAlbumTracks': + case 'getArtistTracks': + for (const t of e.data as Track[]) byId.set(t.id, t); + break; + case 'getTrack': + byId.set((e.data as Track).id, e.data as Track); + break; + } + } + return [...byId.values()]; + }, +); + +/** Every album known locally, deduped by id. */ +export const selectLocalAlbums = createSelector( + selectQueries, + (queries): Album[] => { + const byId = new Map(); + for (const e of fulfilled(queries)) { + switch (e.endpointName) { + case 'getAlbums': + for (const a of (e.data as PaginatedResponse).items) + byId.set(a.id, a); + break; + case 'getArtistAlbums': + for (const a of e.data as Album[]) byId.set(a.id, a); + break; + case 'getAlbum': + byId.set((e.data as Album).id, e.data as Album); + break; + } + } + return [...byId.values()]; + }, +); + +/** Every artist known locally, deduped by id. */ +export const selectLocalArtists = createSelector( + selectQueries, + (queries): Artist[] => { + const byId = new Map(); + for (const e of fulfilled(queries)) { + switch (e.endpointName) { + case 'getArtists': + for (const a of (e.data as PaginatedResponse).items) + byId.set(a.id, a); + break; + case 'getArtist': + byId.set((e.data as Artist).id, e.data as Artist); + break; + } + } + return [...byId.values()]; + }, +); diff --git a/tests/localLibrary.test.ts b/tests/localLibrary.test.ts new file mode 100644 index 0000000..01c469c --- /dev/null +++ b/tests/localLibrary.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from '@rstest/core'; +import { + selectLocalTracks, + selectLocalAlbums, + selectLocalArtists, +} from '../src/store/selectors/localLibrary'; +import type { RootState } from '../src/store/index'; + +function stateWith(queries: Record): RootState { + return { api: { queries } } as unknown as RootState; +} + +const track = (id: string, over: Record = {}) => ({ + id, + title: `Track ${id}`, + artistName: 'A', + albumTitle: 'Alb', + ...over, +}); + +test('selectLocalTracks unions getTracks pages, list endpoints and single tracks', () => { + const state = stateWith({ + 'getTracks(undefined)': { + status: 'fulfilled', + endpointName: 'getTracks', + data: { items: [track('1'), track('2')], total: 2 }, + }, + 'getArtistTracks("x")': { + status: 'fulfilled', + endpointName: 'getArtistTracks', + data: [track('2'), track('3')], // 2 is a dupe + }, + 'getTrack("4")': { + status: 'fulfilled', + endpointName: 'getTrack', + data: track('4'), + }, + }); + + const ids = selectLocalTracks(state) + .map((t) => t.id) + .sort(); + expect(ids).toEqual(['1', '2', '3', '4']); +}); + +test('selectLocalTracks ignores pending/rejected and null-data entries', () => { + const state = stateWith({ + 'getTracks(a)': { + status: 'rejected', + endpointName: 'getTracks', + data: undefined, + }, + 'getTracks(b)': { status: 'pending', endpointName: 'getTracks' }, + 'getTracks(c)': { + status: 'fulfilled', + endpointName: 'getTracks', + data: { items: [track('9')], total: 1 }, + }, + }); + expect(selectLocalTracks(state).map((t) => t.id)).toEqual(['9']); +}); + +test('selectLocalAlbums and selectLocalArtists compose and dedupe', () => { + const state = stateWith({ + 'getAlbums(undefined)': { + status: 'fulfilled', + endpointName: 'getAlbums', + data: { items: [{ id: 'al1' }, { id: 'al2' }], total: 2 }, + }, + 'getArtistAlbums("x")': { + status: 'fulfilled', + endpointName: 'getArtistAlbums', + data: [{ id: 'al2' }], // dupe + }, + 'getArtists(undefined)': { + status: 'fulfilled', + endpointName: 'getArtists', + data: { items: [{ id: 'ar1' }], total: 1 }, + }, + }); + expect( + selectLocalAlbums(state) + .map((a) => a.id) + .sort(), + ).toEqual(['al1', 'al2']); + expect(selectLocalArtists(state).map((a) => a.id)).toEqual(['ar1']); +});