Files
mcma-webui/src/features/library/LibraryPage.tsx
T
Senko-san 8a0e6782ad
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
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>
2026-06-14 01:44:45 +03:00

424 lines
13 KiB
TypeScript

import { useState } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import {
Tabs,
TabsList,
TabsContent,
ScrollArea,
Card,
TextField,
Callout,
} from '@olly/modern-sk';
import {
useGetTracksQuery,
useGetAlbumsQuery,
useGetArtistsQuery,
} from '../../api/endpoints/library';
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, 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();
const dispatch = useAppDispatch();
const [tab, setTab] = useState('tracks');
const [search, setSearch] = useState('');
const [debouncedSearch] = useDebounce(search, 300);
const tracksQuery = useGetTracksQuery(
debouncedSearch ? { search } : undefined,
);
const albumsQuery = useGetAlbumsQuery(
debouncedSearch ? { search } : undefined,
);
const artistsQuery = useGetArtistsQuery(
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({
entries: tracks.map((t) => ({
trackId: t.id,
title: t.title,
artistName: t.artistName,
albumTitle: t.albumTitle,
durationMs: t.durationMs,
albumArtUrl: t.albumArtUrl,
})),
source: 'manual',
sourceName: t('library.title'),
}),
);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div
style={{
padding: '1.25rem 1.5rem',
borderBottom: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
gap: '1rem',
flexShrink: 0,
}}
>
<h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 700 }}>
{t('library.title')}
</h2>
<div style={{ flex: 1, maxWidth: '20rem' }}>
<TextField
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('library.searchPlaceholder')}
/>
</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}
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<div
style={{
padding: '0 1.5rem',
borderBottom: '1px solid var(--color-border)',
flexShrink: 0,
}}
>
<TabsList
items={[
{ value: 'tracks', label: t('library.tabs.tracks') },
{ value: 'albums', label: t('library.tabs.albums') },
{ value: 'artists', label: t('library.tabs.artists') },
]}
/>
</div>
<TabsContent value="tracks" style={{ flex: 1, overflow: 'hidden' }}>
<ScrollArea style={{ height: '100%' }}>
{!tracksToShow && tracksQuery.isLoading && (
<LoadingSkeleton rows={12} />
)}
{!tracksToShow && !offline && tracksQuery.isError && (
<ErrorState onRetry={() => tracksQuery.refetch()} />
)}
{tracksToShow && tracksToShow.length === 0 && (
<EmptyState
icon="♫"
title={t(
offline
? 'library.offline.emptyTitle'
: 'library.empty.tracks.title',
)}
description={t(
offline
? 'library.offline.emptyDesc'
: 'library.empty.tracks.description',
)}
/>
)}
{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%' }}>
{!albumsToShow && albumsQuery.isLoading && (
<LoadingSkeleton rows={8} height={72} />
)}
{!albumsToShow && !offline && albumsQuery.isError && (
<ErrorState onRetry={() => albumsQuery.refetch()} />
)}
{albumsToShow && albumsToShow.length === 0 && (
<EmptyState
icon="💿"
title={t(
offline
? 'library.offline.emptyTitle'
: 'library.empty.albums.title',
)}
description={t(
offline
? 'library.offline.emptyDesc'
: 'library.empty.albums.description',
)}
/>
)}
{albumsToShow && albumsToShow.length > 0 && (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(10rem, 1fr))',
gap: '1rem',
padding: '1.25rem 1.5rem',
}}
>
{albumsToShow.map((album) => (
<AlbumCard
key={album.id}
album={album}
onClick={() => void navigate(`/albums/${album.id}`)}
/>
))}
</div>
)}
</ScrollArea>
</TabsContent>
<TabsContent value="artists" style={{ flex: 1, overflow: 'hidden' }}>
<ScrollArea style={{ height: '100%' }}>
{!artistsToShow && artistsQuery.isLoading && (
<LoadingSkeleton rows={8} />
)}
{!artistsToShow && !offline && artistsQuery.isError && (
<ErrorState onRetry={() => artistsQuery.refetch()} />
)}
{artistsToShow && artistsToShow.length === 0 && (
<EmptyState
icon="🎤"
title={t(
offline
? 'library.offline.emptyTitle'
: 'library.empty.artists.title',
)}
description={t(
offline
? 'library.offline.emptyDesc'
: 'library.empty.artists.description',
)}
/>
)}
{artistsToShow && artistsToShow.length > 0 && (
<div style={{ padding: '0.5rem 0' }}>
{artistsToShow.map((artist) => (
<ArtistRow
key={artist.id}
artist={artist}
onClick={() => void navigate(`/artists/${artist.id}`)}
/>
))}
</div>
)}
</ScrollArea>
</TabsContent>
</Tabs>
</div>
);
}
function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
const { t } = useTranslation();
const artUrl = getCoverUrl(album.artUrl);
return (
<Card
onClick={onClick}
style={{
cursor: 'pointer',
padding: '0.75rem',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
}}
>
{artUrl ? (
<img
src={artUrl}
alt={album.title}
style={{
width: '100%',
aspectRatio: '1',
objectFit: 'cover',
borderRadius: 6,
}}
/>
) : (
<div
style={{
width: '100%',
aspectRatio: '1',
background: 'var(--color-surface-3)',
borderRadius: 6,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '2rem',
}}
>
💿
</div>
)}
<div>
<div
style={{
fontWeight: 600,
fontSize: '0.8125rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{album.title}
</div>
<div
style={{
fontSize: '0.75rem',
color: 'var(--color-text-2)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{album.artistName}
</div>
<div style={{ fontSize: '0.6875rem', color: 'var(--color-text-3)' }}>
{t('library.albumCard.tracksDuration', {
count: album.trackCount,
duration: formatDuration(album.totalDurationMs),
})}
</div>
</div>
</Card>
);
}
function ArtistRow({
artist,
onClick,
}: {
artist: Artist;
onClick: () => void;
}) {
const { t } = useTranslation();
return (
<div
onClick={onClick}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.5rem 1.5rem',
cursor: 'pointer',
}}
>
<div
style={{
width: 40,
height: 40,
borderRadius: '50%',
background: 'var(--color-surface-3)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
fontSize: '1.25rem',
}}
>
🎤
</div>
<div>
<div style={{ fontWeight: 500, fontSize: '0.875rem' }}>
{artist.name}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}>
{t('library.artistRow.meta', {
albumCount: artist.albumCount,
trackCount: artist.trackCount,
})}
</div>
</div>
</div>
);
}