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