57044dabba
Adds src/api/endpoints/settings.ts (getSettings/updateSettings, getScrobbling/updateScrobbling) plus AppSettings/ScrobblingConfig types and a Settings cache tag. Replaces the "coming soon" scaffolds: - Playback: stream-quality preference (SegmentedControl), server-synced. - Scrobbling: enable + provider (Last.fm/ListenBrainz) + username + write-only session key, with a linked/not-linked badge and save states. Built with modern-sk (Window/SegmentedControl/TextField/Button/Callout/ Badge) + the shared Loading/Error states; en + ru strings added. Server state flows through RTK Query hooks only (no fetch in components). Also drops a pre-existing unused TextField import in LibraryPage that was failing `tsc --noEmit`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
457 lines
14 KiB
TypeScript
457 lines
14 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useNavigate } from 'react-router';
|
|
import { useTranslation } from 'react-i18next';
|
|
import {
|
|
Tabs,
|
|
TabsList,
|
|
TabsContent,
|
|
ScrollArea,
|
|
Card,
|
|
Callout,
|
|
SearchField,
|
|
} 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, getAlbumCoverUrl } from '../../api/endpoints/streaming';
|
|
import { formatDuration } from '../../lib/format';
|
|
import { useDebounce } from 'use-debounce';
|
|
import { MagnifyingGlassIcon } from '@phosphor-icons/react';
|
|
|
|
/** 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);
|
|
|
|
// Poll while any listed track is still being enriched, then stop. Enrichment
|
|
// runs asynchronously in a worker after import/upload; without this the row
|
|
// stays stuck on "Identifying metadata…" until something else invalidates the
|
|
// Track tag. Cleared to 0 once nothing is pending (and while offline).
|
|
const [tracksPollMs, setTracksPollMs] = useState(0);
|
|
const tracksQuery = useGetTracksQuery(
|
|
debouncedSearch ? { search } : undefined,
|
|
{ pollingInterval: tracksPollMs },
|
|
);
|
|
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();
|
|
|
|
const anyPending =
|
|
!offline &&
|
|
(tracksQuery.data?.items.some((tr) => tr.metadataStatus === 'pending') ??
|
|
false);
|
|
useEffect(() => {
|
|
setTracksPollMs(anyPending ? 4000 : 0);
|
|
}, [anyPending]);
|
|
|
|
// 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' }}>
|
|
<SearchField
|
|
icon={<MagnifyingGlassIcon />}
|
|
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 token = useAppSelector((s) => s.auth.accessToken);
|
|
// The album record has no cover URL; build one from `hasCover` (served by
|
|
// GET /albums/{id}/cover, token in the query — <img> can't send a header).
|
|
const artUrl =
|
|
getCoverUrl(album.artUrl) ??
|
|
(token ? getAlbumCoverUrl(album.id, token, album.hasCover) : undefined);
|
|
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>
|
|
);
|
|
}
|