feat(library): render from locally-cached data when offline
The Library showed a blocking error with the backend unreachable. Now it composes a read-only library from everything already in the RTK Query cache (Tier-2 rehydrated last-seen data + anything fetched this session), so it keeps rendering offline instead of erroring. - selectors: `selectLocalTracks/Albums/Artists` — memoized, union + dedupe across getTracks/getAlbums/getArtists, the per-album/artist list endpoints, and single-entity fetches; skips pending/rejected entries - LibraryPage: when offline, fall back to the composed lists (live data still wins online), filter client-side for search, show an offline banner, and never show the retry-only ErrorState - i18n: `library.offline.*` (en + ru) - test: selector composition / dedup / status filtering (3 cases) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{offline && (
|
||||
<div style={{ padding: '0.75rem 1.5rem 0', flexShrink: 0 }}>
|
||||
<Callout variant="info">{t('library.offline.banner')}</Callout>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
@@ -112,74 +159,84 @@ export function LibraryPage() {
|
||||
|
||||
<TabsContent value="tracks" style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<ScrollArea style={{ height: '100%' }}>
|
||||
{tracksQuery.isLoading && <LoadingSkeleton rows={12} />}
|
||||
{tracksQuery.isError && (
|
||||
{!tracksToShow && tracksQuery.isLoading && (
|
||||
<LoadingSkeleton rows={12} />
|
||||
)}
|
||||
{!tracksToShow && !offline && tracksQuery.isError && (
|
||||
<ErrorState onRetry={() => tracksQuery.refetch()} />
|
||||
)}
|
||||
{tracksQuery.data && tracksQuery.data.items.length === 0 && (
|
||||
{tracksToShow && tracksToShow.length === 0 && (
|
||||
<EmptyState
|
||||
icon="♫"
|
||||
title={t('library.empty.tracks.title')}
|
||||
description={t('library.empty.tracks.description')}
|
||||
title={t(
|
||||
offline
|
||||
? 'library.offline.emptyTitle'
|
||||
: 'library.empty.tracks.title',
|
||||
)}
|
||||
description={t(
|
||||
offline
|
||||
? 'library.offline.emptyDesc'
|
||||
: 'library.empty.tracks.description',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{tracksQuery.data &&
|
||||
tracksQuery.data.items.length > 0 &&
|
||||
(() => {
|
||||
const data = tracksQuery.data!;
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
padding: '0.5rem 0.75rem',
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
alignItems: 'center',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => handlePlayAll(data.items)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--color-accent)',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{t('library.playAll', { count: data.total })}
|
||||
</button>
|
||||
</div>
|
||||
{data.items.map((track, i) => (
|
||||
<TrackRow
|
||||
key={track.id}
|
||||
track={track}
|
||||
index={i}
|
||||
showAlbum
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{tracksToShow && tracksToShow.length > 0 && (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
padding: '0.5rem 0.75rem',
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
alignItems: 'center',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => handlePlayAll(tracksToShow)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--color-accent)',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{t('library.playAll', { count: tracksToShow.length })}
|
||||
</button>
|
||||
</div>
|
||||
{tracksToShow.map((track, i) => (
|
||||
<TrackRow key={track.id} track={track} index={i} showAlbum />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="albums" style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<ScrollArea style={{ height: '100%' }}>
|
||||
{albumsQuery.isLoading && <LoadingSkeleton rows={8} height={72} />}
|
||||
{albumsQuery.isError && (
|
||||
{!albumsToShow && albumsQuery.isLoading && (
|
||||
<LoadingSkeleton rows={8} height={72} />
|
||||
)}
|
||||
{!albumsToShow && !offline && albumsQuery.isError && (
|
||||
<ErrorState onRetry={() => albumsQuery.refetch()} />
|
||||
)}
|
||||
{albumsQuery.data && albumsQuery.data.items.length === 0 && (
|
||||
{albumsToShow && albumsToShow.length === 0 && (
|
||||
<EmptyState
|
||||
icon="💿"
|
||||
title={t('library.empty.albums.title')}
|
||||
description={t('library.empty.albums.description')}
|
||||
title={t(
|
||||
offline
|
||||
? 'library.offline.emptyTitle'
|
||||
: 'library.empty.albums.title',
|
||||
)}
|
||||
description={t(
|
||||
offline
|
||||
? 'library.offline.emptyDesc'
|
||||
: 'library.empty.albums.description',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{albumsQuery.data && (
|
||||
{albumsToShow && albumsToShow.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
@@ -188,7 +245,7 @@ export function LibraryPage() {
|
||||
padding: '1.25rem 1.5rem',
|
||||
}}
|
||||
>
|
||||
{albumsQuery.data.items.map((album) => (
|
||||
{albumsToShow.map((album) => (
|
||||
<AlbumCard
|
||||
key={album.id}
|
||||
album={album}
|
||||
@@ -202,20 +259,30 @@ export function LibraryPage() {
|
||||
|
||||
<TabsContent value="artists" style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<ScrollArea style={{ height: '100%' }}>
|
||||
{artistsQuery.isLoading && <LoadingSkeleton rows={8} />}
|
||||
{artistsQuery.isError && (
|
||||
{!artistsToShow && artistsQuery.isLoading && (
|
||||
<LoadingSkeleton rows={8} />
|
||||
)}
|
||||
{!artistsToShow && !offline && artistsQuery.isError && (
|
||||
<ErrorState onRetry={() => artistsQuery.refetch()} />
|
||||
)}
|
||||
{artistsQuery.data && artistsQuery.data.items.length === 0 && (
|
||||
{artistsToShow && artistsToShow.length === 0 && (
|
||||
<EmptyState
|
||||
icon="🎤"
|
||||
title={t('library.empty.artists.title')}
|
||||
description={t('library.empty.artists.description')}
|
||||
title={t(
|
||||
offline
|
||||
? 'library.offline.emptyTitle'
|
||||
: 'library.empty.artists.title',
|
||||
)}
|
||||
description={t(
|
||||
offline
|
||||
? 'library.offline.emptyDesc'
|
||||
: 'library.empty.artists.description',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{artistsQuery.data && (
|
||||
{artistsToShow && artistsToShow.length > 0 && (
|
||||
<div style={{ padding: '0.5rem 0' }}>
|
||||
{artistsQuery.data.items.map((artist) => (
|
||||
{artistsToShow.map((artist) => (
|
||||
<ArtistRow
|
||||
key={artist.id}
|
||||
artist={artist}
|
||||
|
||||
Reference in New Issue
Block a user