Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cdcacc56d1 | |||
| b966ad8be5 | |||
| 6595417246 | |||
| 94361899a8 | |||
| 8a0e6782ad | |||
| 4aa071eeeb | |||
| 45a624b642 |
@@ -42,6 +42,7 @@ function trackParams(f: LibraryFilters) {
|
|||||||
q: f.search,
|
q: f.search,
|
||||||
artist_id: f.artistId,
|
artist_id: f.artistId,
|
||||||
album_id: f.albumId,
|
album_id: f.albumId,
|
||||||
|
source: f.source,
|
||||||
sort_by: f.sortBy ? SORT_BY[f.sortBy] : undefined,
|
sort_by: f.sortBy ? SORT_BY[f.sortBy] : undefined,
|
||||||
order: f.sortOrder,
|
order: f.sortOrder,
|
||||||
...paging(f.page, f.pageSize),
|
...paging(f.page, f.pageSize),
|
||||||
|
|||||||
@@ -32,3 +32,18 @@ export function getTrackCoverUrl(
|
|||||||
const base = getApiBaseUrl();
|
const base = getApiBaseUrl();
|
||||||
return `${base}/tracks/${trackId}/cover?token=${encodeURIComponent(token)}`;
|
return `${base}/tracks/${trackId}/cover?token=${encodeURIComponent(token)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cover image URL for an album, served by `GET /albums/{id}/cover`. Same
|
||||||
|
* `?token=` rationale as the track cover. Returns undefined when the album has
|
||||||
|
* no cover (so callers fall back to generated tile art).
|
||||||
|
*/
|
||||||
|
export function getAlbumCoverUrl(
|
||||||
|
albumId: string,
|
||||||
|
token: string,
|
||||||
|
hasCover: boolean,
|
||||||
|
): string | undefined {
|
||||||
|
if (!hasCover) return undefined;
|
||||||
|
const base = getApiBaseUrl();
|
||||||
|
return `${base}/albums/${albumId}/cover?token=${encodeURIComponent(token)}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ export interface RawAlbum {
|
|||||||
artist_name: string;
|
artist_name: string;
|
||||||
year: number | null;
|
year: number | null;
|
||||||
track_count: number;
|
track_count: number;
|
||||||
|
has_cover: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +172,10 @@ export const toAlbum = (r: RawAlbum): Album => ({
|
|||||||
title: r.title,
|
title: r.title,
|
||||||
artistId: r.artist_id,
|
artistId: r.artist_id,
|
||||||
artistName: r.artist_name,
|
artistName: r.artist_name,
|
||||||
|
// The album record carries no cover *URL*; `hasCover` says one exists, and the
|
||||||
|
// URL (which needs `?token=`) is built in components via `getAlbumCoverUrl`.
|
||||||
artUrl: undefined,
|
artUrl: undefined,
|
||||||
|
hasCover: r.has_cover,
|
||||||
year: r.year ?? undefined,
|
year: r.year ?? undefined,
|
||||||
trackCount: r.track_count,
|
trackCount: r.track_count,
|
||||||
// AlbumOut has no aggregate duration; computed client-side from tracks when
|
// AlbumOut has no aggregate duration; computed client-side from tracks when
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export interface Album {
|
|||||||
artistId: string;
|
artistId: string;
|
||||||
artistName: string;
|
artistName: string;
|
||||||
artUrl?: string;
|
artUrl?: string;
|
||||||
|
/** Whether the album has cover art served by `GET /albums/{id}/cover`. */
|
||||||
|
hasCover: boolean;
|
||||||
year?: number;
|
year?: number;
|
||||||
trackCount: number;
|
trackCount: number;
|
||||||
totalDurationMs: number;
|
totalDurationMs: number;
|
||||||
@@ -177,6 +179,8 @@ export interface LibraryFilters {
|
|||||||
genre?: string;
|
genre?: string;
|
||||||
artistId?: string;
|
artistId?: string;
|
||||||
albumId?: string;
|
albumId?: string;
|
||||||
|
/** Filter by ingest origin, e.g. `upload`, `youtube`, `local`. */
|
||||||
|
source?: string;
|
||||||
liked?: boolean;
|
liked?: boolean;
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useLayoutEffect, useRef, useState, type CSSProperties } from 'react';
|
||||||
|
|
||||||
|
/** Single-line text that ping-pong scrolls (like a news ticker) only when it
|
||||||
|
* overflows its container, otherwise renders as static clipped text. Keeps the
|
||||||
|
* queue panel from ever growing a horizontal scrollbar on long titles. */
|
||||||
|
export function Marquee({
|
||||||
|
text,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
|
const [shift, setShift] = useState(0);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const measure = () => {
|
||||||
|
const inner = el.firstElementChild as HTMLElement | null;
|
||||||
|
const overflow = (inner?.scrollWidth ?? 0) - el.clientWidth;
|
||||||
|
setShift(overflow > 1 ? overflow : 0);
|
||||||
|
};
|
||||||
|
measure();
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [text]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
ref={ref}
|
||||||
|
className={`marquee${shift ? ' on' : ''}${className ? ` ${className}` : ''}`}
|
||||||
|
style={shift ? ({ '--mq-shift': `-${shift}px` } as CSSProperties) : undefined}
|
||||||
|
>
|
||||||
|
<span className="marquee-inner">{text}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { Icon } from '../common/Icon';
|
import { Icon } from '../common/Icon';
|
||||||
import { ArtTile } from '../common/ArtTile';
|
import { ArtTile } from '../common/ArtTile';
|
||||||
|
import { Marquee } from '../common/Marquee';
|
||||||
import { PlayingIndicator } from '../common/PlayingIndicator';
|
import { PlayingIndicator } from '../common/PlayingIndicator';
|
||||||
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||||
import {
|
import {
|
||||||
@@ -259,13 +260,9 @@ function QueueRow({
|
|||||||
onDoubleClick={onPlay}
|
onDoubleClick={onPlay}
|
||||||
title={t('queue.doubleClickPlay')}
|
title={t('queue.doubleClickPlay')}
|
||||||
>
|
>
|
||||||
{isCurrent ? (
|
<span className="grip" {...attributes} {...listeners}>
|
||||||
<PlayingIndicator animate={isPlaying} />
|
<Icon name="dots-six-vertical" />
|
||||||
) : (
|
</span>
|
||||||
<span className="grip" {...attributes} {...listeners}>
|
|
||||||
<Icon name="dots-six-vertical" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="qart">
|
<div className="qart">
|
||||||
<ArtTile seed={albumTitle} size={36} label={albumTitle} src={artUrl} />
|
<ArtTile seed={albumTitle} size={36} label={albumTitle} src={artUrl} />
|
||||||
{isCurrent && (
|
{isCurrent && (
|
||||||
@@ -275,8 +272,8 @@ function QueueRow({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="qt">
|
<div className="qt">
|
||||||
<div className="t">{resolved?.title ?? entry.title}</div>
|
<Marquee className="t" text={resolved?.title ?? entry.title} />
|
||||||
<div className="r">{resolved?.artistName ?? entry.artistName}</div>
|
<Marquee className="r" text={resolved?.artistName ?? entry.artistName} />
|
||||||
</div>
|
</div>
|
||||||
<Menu>
|
<Menu>
|
||||||
<MenuTrigger asChild>
|
<MenuTrigger asChild>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useParams, useNavigate } from 'react-router';
|
import { useParams, useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ScrollArea, IconButton, Button } from '@olly/modern-sk';
|
import { ScrollArea, IconButton, Button, Callout } from '@olly/modern-sk';
|
||||||
import {
|
import {
|
||||||
useGetAlbumQuery,
|
useGetAlbumQuery,
|
||||||
useGetAlbumTracksQuery,
|
useGetAlbumTracksQuery,
|
||||||
@@ -10,6 +10,11 @@ import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
|||||||
import { ErrorState } from '../../components/common/ErrorState';
|
import { ErrorState } from '../../components/common/ErrorState';
|
||||||
import { EmptyState } from '../../components/common/EmptyState';
|
import { EmptyState } from '../../components/common/EmptyState';
|
||||||
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||||
|
import { useIsOffline } from '../../hooks/useConnectionStatus';
|
||||||
|
import {
|
||||||
|
selectLocalAlbums,
|
||||||
|
selectLocalTracks,
|
||||||
|
} from '../../store/selectors/localLibrary';
|
||||||
import { setQueue } from '../../store/slices/queue';
|
import { setQueue } from '../../store/slices/queue';
|
||||||
import { formatDuration } from '../../lib/format';
|
import { formatDuration } from '../../lib/format';
|
||||||
import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
|
import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
|
||||||
@@ -24,15 +29,36 @@ export function AlbumDetailPage() {
|
|||||||
const albumQuery = useGetAlbumQuery(albumId ?? '', { skip: !albumId });
|
const albumQuery = useGetAlbumQuery(albumId ?? '', { skip: !albumId });
|
||||||
const tracksQuery = useGetAlbumTracksQuery(albumId ?? '', { skip: !albumId });
|
const tracksQuery = useGetAlbumTracksQuery(albumId ?? '', { skip: !albumId });
|
||||||
|
|
||||||
if (albumQuery.isLoading || tracksQuery.isLoading) {
|
// Offline fallback: resolve the album + its tracks from the locally-cached
|
||||||
return (
|
// library when the backend is unreachable (same approach as LibraryPage).
|
||||||
<div style={{ padding: '1.5rem' }}>
|
const offline = useIsOffline();
|
||||||
<LoadingSkeleton rows={10} />
|
const localAlbums = useAppSelector(selectLocalAlbums);
|
||||||
</div>
|
const localTracks = useAppSelector(selectLocalTracks);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (albumQuery.isError) {
|
const album =
|
||||||
|
albumQuery.data ??
|
||||||
|
(offline ? localAlbums.find((a) => a.id === albumId) : undefined);
|
||||||
|
const tracks =
|
||||||
|
tracksQuery.data ??
|
||||||
|
(offline ? localTracks.filter((tr) => tr.albumId === albumId) : []);
|
||||||
|
|
||||||
|
if (!album) {
|
||||||
|
if (albumQuery.isLoading && !offline) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '1.5rem' }}>
|
||||||
|
<LoadingSkeleton rows={10} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (offline) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon="💿"
|
||||||
|
title={t('album.offline.title')}
|
||||||
|
description={t('album.offline.description')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<ErrorState
|
<ErrorState
|
||||||
message={t('album.error')}
|
message={t('album.error')}
|
||||||
@@ -40,9 +66,6 @@ export function AlbumDetailPage() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const album = albumQuery.data;
|
|
||||||
const tracks = tracksQuery.data ?? [];
|
|
||||||
// The album record itself carries no cover; fall back to a track's cover.
|
// The album record itself carries no cover; fall back to a track's cover.
|
||||||
const coverTrack = tracks.find((t) => t.hasCover);
|
const coverTrack = tracks.find((t) => t.hasCover);
|
||||||
const artUrl =
|
const artUrl =
|
||||||
@@ -72,6 +95,11 @@ export function AlbumDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
{offline && (
|
||||||
|
<div style={{ padding: '0.75rem 1.5rem 0', flexShrink: 0 }}>
|
||||||
|
<Callout variant="info">{t('common.offlineBanner')}</Callout>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '1.25rem 1.5rem',
|
padding: '1.25rem 1.5rem',
|
||||||
@@ -168,16 +196,17 @@ export function AlbumDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea style={{ flex: 1 }}>
|
<ScrollArea style={{ flex: 1 }}>
|
||||||
{tracksQuery.isLoading && <LoadingSkeleton rows={10} />}
|
{tracks.length === 0 && !offline && tracksQuery.isLoading && (
|
||||||
{tracksQuery.isError && (
|
<LoadingSkeleton rows={10} />
|
||||||
|
)}
|
||||||
|
{tracks.length === 0 && !offline && tracksQuery.isError && (
|
||||||
<ErrorState
|
<ErrorState
|
||||||
message={t('album.tracksError')}
|
message={t('album.tracksError')}
|
||||||
onRetry={() => tracksQuery.refetch()}
|
onRetry={() => tracksQuery.refetch()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!tracksQuery.isLoading &&
|
{tracks.length === 0 &&
|
||||||
!tracksQuery.isError &&
|
(offline || (!tracksQuery.isLoading && !tracksQuery.isError)) && (
|
||||||
tracks.length === 0 && (
|
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="♫"
|
icon="♫"
|
||||||
title={t('album.empty.title')}
|
title={t('album.empty.title')}
|
||||||
|
|||||||
@@ -1,8 +1,307 @@
|
|||||||
|
import { useParams, useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Placeholder } from '../../components/common/Placeholder';
|
import { ScrollArea, IconButton, Button, Card, Callout } from '@olly/modern-sk';
|
||||||
|
import {
|
||||||
|
useGetArtistQuery,
|
||||||
|
useGetArtistAlbumsQuery,
|
||||||
|
useGetArtistTracksQuery,
|
||||||
|
} from '../../api/endpoints/library';
|
||||||
|
import { TrackRow } from '../../components/track/TrackRow';
|
||||||
|
import { ArtTile } from '../../components/common/ArtTile';
|
||||||
|
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||||
|
import { ErrorState } from '../../components/common/ErrorState';
|
||||||
|
import { EmptyState } from '../../components/common/EmptyState';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||||
|
import { useIsOffline } from '../../hooks/useConnectionStatus';
|
||||||
|
import {
|
||||||
|
selectLocalArtists,
|
||||||
|
selectLocalAlbums,
|
||||||
|
selectLocalTracks,
|
||||||
|
} from '../../store/selectors/localLibrary';
|
||||||
|
import { setQueue } from '../../store/slices/queue';
|
||||||
|
import { formatDuration } from '../../lib/format';
|
||||||
|
import { getCoverUrl, getAlbumCoverUrl } from '../../api/endpoints/streaming';
|
||||||
|
import type { Album } from '../../api/types';
|
||||||
|
|
||||||
/** `/artists/:artistId` — A3 artist detail (discography + similar). Scaffold only. */
|
|
||||||
export function ArtistDetailPage() {
|
export function ArtistDetailPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return <Placeholder title={t('pages.artist')} />;
|
const { artistId } = useParams<{ artistId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const artistQuery = useGetArtistQuery(artistId ?? '', { skip: !artistId });
|
||||||
|
const albumsQuery = useGetArtistAlbumsQuery(artistId ?? '', {
|
||||||
|
skip: !artistId,
|
||||||
|
});
|
||||||
|
const tracksQuery = useGetArtistTracksQuery(artistId ?? '', {
|
||||||
|
skip: !artistId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Offline fallback: resolve the artist + their albums/tracks from the
|
||||||
|
// locally-cached library when the backend is unreachable.
|
||||||
|
const offline = useIsOffline();
|
||||||
|
const localArtists = useAppSelector(selectLocalArtists);
|
||||||
|
const localAlbums = useAppSelector(selectLocalAlbums);
|
||||||
|
const localTracks = useAppSelector(selectLocalTracks);
|
||||||
|
|
||||||
|
const artist =
|
||||||
|
artistQuery.data ??
|
||||||
|
(offline ? localArtists.find((a) => a.id === artistId) : undefined);
|
||||||
|
const albums =
|
||||||
|
albumsQuery.data ??
|
||||||
|
(offline ? localAlbums.filter((a) => a.artistId === artistId) : []);
|
||||||
|
const tracks =
|
||||||
|
tracksQuery.data ??
|
||||||
|
(offline ? localTracks.filter((tr) => tr.artistId === artistId) : []);
|
||||||
|
|
||||||
|
if (!artist) {
|
||||||
|
if (artistQuery.isLoading && !offline) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '1.5rem' }}>
|
||||||
|
<LoadingSkeleton rows={10} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (offline) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon="🎤"
|
||||||
|
title={t('artist.offline.title')}
|
||||||
|
description={t('artist.offline.description')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
message={t('artist.error')}
|
||||||
|
onRetry={() => artistQuery.refetch()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePlayAll = () => {
|
||||||
|
if (!tracks.length) return;
|
||||||
|
dispatch(
|
||||||
|
setQueue({
|
||||||
|
entries: tracks.map((tr) => ({
|
||||||
|
trackId: tr.id,
|
||||||
|
title: tr.title,
|
||||||
|
artistName: tr.artistName,
|
||||||
|
albumTitle: tr.albumTitle,
|
||||||
|
durationMs: tr.durationMs,
|
||||||
|
albumArtUrl: tr.albumArtUrl,
|
||||||
|
})),
|
||||||
|
source: 'artist',
|
||||||
|
sourceId: artist.id,
|
||||||
|
sourceName: artist.name,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
{offline && (
|
||||||
|
<div style={{ padding: '0.75rem 1.5rem 0', flexShrink: 0 }}>
|
||||||
|
<Callout variant="info">{t('common.offlineBanner')}</Callout>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '1.25rem 1.5rem',
|
||||||
|
borderBottom: '1px solid var(--color-border)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '1rem',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
aria-label={t('common.back')}
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</IconButton>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '1.5rem',
|
||||||
|
alignItems: 'center',
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArtTile seed={artist.id} label={artist.name} size={96} radius={48} />
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('artist.type')}
|
||||||
|
</p>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
margin: '0.25rem 0',
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{artist.name}
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
color: 'var(--color-text-2)',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('artist.meta', {
|
||||||
|
albumCount: artist.albumCount,
|
||||||
|
trackCount: artist.trackCount,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handlePlayAll}
|
||||||
|
disabled={!tracks.length}
|
||||||
|
>
|
||||||
|
{t('artist.play')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea style={{ flex: 1 }}>
|
||||||
|
{/* Discography */}
|
||||||
|
<section style={{ padding: '1.25rem 1.5rem 0' }}>
|
||||||
|
<h2
|
||||||
|
style={{ margin: '0 0 0.75rem', fontSize: '1rem', fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
{t('artist.albums')}
|
||||||
|
</h2>
|
||||||
|
{albums.length === 0 && !offline && albumsQuery.isLoading && (
|
||||||
|
<LoadingSkeleton rows={3} height={72} />
|
||||||
|
)}
|
||||||
|
{albums.length === 0 && !offline && albumsQuery.isError && (
|
||||||
|
<ErrorState onRetry={() => albumsQuery.refetch()} />
|
||||||
|
)}
|
||||||
|
{albums.length === 0 &&
|
||||||
|
(offline || (!albumsQuery.isLoading && !albumsQuery.isError)) && (
|
||||||
|
<p style={{ color: 'var(--color-text-3)', fontSize: '0.875rem' }}>
|
||||||
|
{t('artist.noAlbums')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{albums.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(9rem, 1fr))',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{albums.map((album) => (
|
||||||
|
<AlbumCard
|
||||||
|
key={album.id}
|
||||||
|
album={album}
|
||||||
|
onClick={() => void navigate(`/albums/${album.id}`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* All tracks */}
|
||||||
|
<section style={{ padding: '1.5rem 0 0.5rem' }}>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
margin: '0 1.5rem 0.5rem',
|
||||||
|
fontSize: '1rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('artist.tracks')}
|
||||||
|
</h2>
|
||||||
|
{tracks.length === 0 && !offline && tracksQuery.isLoading && (
|
||||||
|
<LoadingSkeleton rows={6} />
|
||||||
|
)}
|
||||||
|
{tracks.length === 0 && !offline && tracksQuery.isError && (
|
||||||
|
<ErrorState onRetry={() => tracksQuery.refetch()} />
|
||||||
|
)}
|
||||||
|
{tracks.length === 0 &&
|
||||||
|
(offline || (!tracksQuery.isLoading && !tracksQuery.isError)) && (
|
||||||
|
<EmptyState
|
||||||
|
icon="♫"
|
||||||
|
title={t('artist.empty.title')}
|
||||||
|
description={t('artist.empty.description')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tracks.map((track, i) => (
|
||||||
|
<TrackRow key={track.id} track={track} index={i} showAlbum />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const token = useAppSelector((s) => s.auth.accessToken);
|
||||||
|
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' }}>
|
||||||
|
<ArtTile seed={album.id} label={album.title} size={144} radius={6} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{album.title}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.6875rem', color: 'var(--color-text-3)' }}>
|
||||||
|
{album.year ? `${album.year} · ` : ''}
|
||||||
|
{t('library.albumCard.tracksDuration', {
|
||||||
|
count: album.trackCount,
|
||||||
|
duration: formatDuration(album.totalDurationMs),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ScrollArea,
|
ScrollArea,
|
||||||
Card,
|
Card,
|
||||||
TextField,
|
TextField,
|
||||||
|
Callout,
|
||||||
} from '@olly/modern-sk';
|
} from '@olly/modern-sk';
|
||||||
import {
|
import {
|
||||||
useGetTracksQuery,
|
useGetTracksQuery,
|
||||||
@@ -18,13 +19,33 @@ import { TrackRow } from '../../components/track/TrackRow';
|
|||||||
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||||
import { EmptyState } from '../../components/common/EmptyState';
|
import { EmptyState } from '../../components/common/EmptyState';
|
||||||
import { ErrorState } from '../../components/common/ErrorState';
|
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 { setQueue } from '../../store/slices/queue';
|
||||||
import type { Track, Album, Artist } from '../../api/types';
|
import type { Track, Album, Artist } from '../../api/types';
|
||||||
import { getCoverUrl } from '../../api/endpoints/streaming';
|
import { getCoverUrl, getAlbumCoverUrl } from '../../api/endpoints/streaming';
|
||||||
import { formatDuration } from '../../lib/format';
|
import { formatDuration } from '../../lib/format';
|
||||||
import { useDebounce } from 'use-debounce';
|
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() {
|
export function LibraryPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -43,6 +64,26 @@ export function LibraryPage() {
|
|||||||
debouncedSearch ? { search } : undefined,
|
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[]) => {
|
const handlePlayAll = (tracks: Track[]) => {
|
||||||
dispatch(
|
dispatch(
|
||||||
setQueue({
|
setQueue({
|
||||||
@@ -84,6 +125,12 @@ export function LibraryPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{offline && (
|
||||||
|
<div style={{ padding: '0.75rem 1.5rem 0', flexShrink: 0 }}>
|
||||||
|
<Callout variant="info">{t('library.offline.banner')}</Callout>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={tab}
|
value={tab}
|
||||||
onValueChange={setTab}
|
onValueChange={setTab}
|
||||||
@@ -112,74 +159,84 @@ export function LibraryPage() {
|
|||||||
|
|
||||||
<TabsContent value="tracks" style={{ flex: 1, overflow: 'hidden' }}>
|
<TabsContent value="tracks" style={{ flex: 1, overflow: 'hidden' }}>
|
||||||
<ScrollArea style={{ height: '100%' }}>
|
<ScrollArea style={{ height: '100%' }}>
|
||||||
{tracksQuery.isLoading && <LoadingSkeleton rows={12} />}
|
{!tracksToShow && tracksQuery.isLoading && (
|
||||||
{tracksQuery.isError && (
|
<LoadingSkeleton rows={12} />
|
||||||
|
)}
|
||||||
|
{!tracksToShow && !offline && tracksQuery.isError && (
|
||||||
<ErrorState onRetry={() => tracksQuery.refetch()} />
|
<ErrorState onRetry={() => tracksQuery.refetch()} />
|
||||||
)}
|
)}
|
||||||
{tracksQuery.data && tracksQuery.data.items.length === 0 && (
|
{tracksToShow && tracksToShow.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="♫"
|
icon="♫"
|
||||||
title={t('library.empty.tracks.title')}
|
title={t(
|
||||||
description={t('library.empty.tracks.description')}
|
offline
|
||||||
|
? 'library.offline.emptyTitle'
|
||||||
|
: 'library.empty.tracks.title',
|
||||||
|
)}
|
||||||
|
description={t(
|
||||||
|
offline
|
||||||
|
? 'library.offline.emptyDesc'
|
||||||
|
: 'library.empty.tracks.description',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tracksQuery.data &&
|
{tracksToShow && tracksToShow.length > 0 && (
|
||||||
tracksQuery.data.items.length > 0 &&
|
<div>
|
||||||
(() => {
|
<div
|
||||||
const data = tracksQuery.data!;
|
style={{
|
||||||
return (
|
padding: '0.5rem 0.75rem',
|
||||||
<div>
|
display: 'flex',
|
||||||
<div
|
gap: '0.5rem',
|
||||||
style={{
|
alignItems: 'center',
|
||||||
padding: '0.5rem 0.75rem',
|
borderBottom: '1px solid var(--color-border)',
|
||||||
display: 'flex',
|
}}
|
||||||
gap: '0.5rem',
|
>
|
||||||
alignItems: 'center',
|
<button
|
||||||
borderBottom: '1px solid var(--color-border)',
|
onClick={() => handlePlayAll(tracksToShow)}
|
||||||
}}
|
style={{
|
||||||
>
|
background: 'none',
|
||||||
<button
|
border: 'none',
|
||||||
onClick={() => handlePlayAll(data.items)}
|
cursor: 'pointer',
|
||||||
style={{
|
color: 'var(--color-accent)',
|
||||||
background: 'none',
|
fontSize: '0.875rem',
|
||||||
border: 'none',
|
fontWeight: 500,
|
||||||
cursor: 'pointer',
|
}}
|
||||||
color: 'var(--color-accent)',
|
>
|
||||||
fontSize: '0.875rem',
|
{t('library.playAll', { count: tracksToShow.length })}
|
||||||
fontWeight: 500,
|
</button>
|
||||||
}}
|
</div>
|
||||||
>
|
{tracksToShow.map((track, i) => (
|
||||||
{t('library.playAll', { count: data.total })}
|
<TrackRow key={track.id} track={track} index={i} showAlbum />
|
||||||
</button>
|
))}
|
||||||
</div>
|
</div>
|
||||||
{data.items.map((track, i) => (
|
)}
|
||||||
<TrackRow
|
|
||||||
key={track.id}
|
|
||||||
track={track}
|
|
||||||
index={i}
|
|
||||||
showAlbum
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="albums" style={{ flex: 1, overflow: 'hidden' }}>
|
<TabsContent value="albums" style={{ flex: 1, overflow: 'hidden' }}>
|
||||||
<ScrollArea style={{ height: '100%' }}>
|
<ScrollArea style={{ height: '100%' }}>
|
||||||
{albumsQuery.isLoading && <LoadingSkeleton rows={8} height={72} />}
|
{!albumsToShow && albumsQuery.isLoading && (
|
||||||
{albumsQuery.isError && (
|
<LoadingSkeleton rows={8} height={72} />
|
||||||
|
)}
|
||||||
|
{!albumsToShow && !offline && albumsQuery.isError && (
|
||||||
<ErrorState onRetry={() => albumsQuery.refetch()} />
|
<ErrorState onRetry={() => albumsQuery.refetch()} />
|
||||||
)}
|
)}
|
||||||
{albumsQuery.data && albumsQuery.data.items.length === 0 && (
|
{albumsToShow && albumsToShow.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="💿"
|
icon="💿"
|
||||||
title={t('library.empty.albums.title')}
|
title={t(
|
||||||
description={t('library.empty.albums.description')}
|
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
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
@@ -188,7 +245,7 @@ export function LibraryPage() {
|
|||||||
padding: '1.25rem 1.5rem',
|
padding: '1.25rem 1.5rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{albumsQuery.data.items.map((album) => (
|
{albumsToShow.map((album) => (
|
||||||
<AlbumCard
|
<AlbumCard
|
||||||
key={album.id}
|
key={album.id}
|
||||||
album={album}
|
album={album}
|
||||||
@@ -202,21 +259,35 @@ export function LibraryPage() {
|
|||||||
|
|
||||||
<TabsContent value="artists" style={{ flex: 1, overflow: 'hidden' }}>
|
<TabsContent value="artists" style={{ flex: 1, overflow: 'hidden' }}>
|
||||||
<ScrollArea style={{ height: '100%' }}>
|
<ScrollArea style={{ height: '100%' }}>
|
||||||
{artistsQuery.isLoading && <LoadingSkeleton rows={8} />}
|
{!artistsToShow && artistsQuery.isLoading && (
|
||||||
{artistsQuery.isError && (
|
<LoadingSkeleton rows={8} />
|
||||||
|
)}
|
||||||
|
{!artistsToShow && !offline && artistsQuery.isError && (
|
||||||
<ErrorState onRetry={() => artistsQuery.refetch()} />
|
<ErrorState onRetry={() => artistsQuery.refetch()} />
|
||||||
)}
|
)}
|
||||||
{artistsQuery.data && artistsQuery.data.items.length === 0 && (
|
{artistsToShow && artistsToShow.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="🎤"
|
icon="🎤"
|
||||||
title={t('library.empty.artists.title')}
|
title={t(
|
||||||
description={t('library.empty.artists.description')}
|
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' }}>
|
<div style={{ padding: '0.5rem 0' }}>
|
||||||
{artistsQuery.data.items.map((artist) => (
|
{artistsToShow.map((artist) => (
|
||||||
<ArtistRow key={artist.id} artist={artist} />
|
<ArtistRow
|
||||||
|
key={artist.id}
|
||||||
|
artist={artist}
|
||||||
|
onClick={() => void navigate(`/artists/${artist.id}`)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -229,7 +300,12 @@ export function LibraryPage() {
|
|||||||
|
|
||||||
function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
|
function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const artUrl = getCoverUrl(album.artUrl);
|
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 (
|
return (
|
||||||
<Card
|
<Card
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@@ -302,15 +378,23 @@ function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ArtistRow({ artist }: { artist: Artist }) {
|
function ArtistRow({
|
||||||
|
artist,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
artist: Artist;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
onClick={onClick}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '0.75rem',
|
gap: '0.75rem',
|
||||||
padding: '0.5rem 1.5rem',
|
padding: '0.5rem 1.5rem',
|
||||||
|
cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Window, Card, Badge } from '@olly/modern-sk';
|
import { Window, Card, Badge, Callout } from '@olly/modern-sk';
|
||||||
import { useGetStorageStatsQuery } from '../../api/endpoints/storage';
|
import { useGetStorageStatsQuery } from '../../api/endpoints/storage';
|
||||||
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||||
import { EmptyState } from '../../components/common/EmptyState';
|
import { EmptyState } from '../../components/common/EmptyState';
|
||||||
import { ErrorState } from '../../components/common/ErrorState';
|
import { ErrorState } from '../../components/common/ErrorState';
|
||||||
import { Icon, type IconName } from '../../components/common/Icon';
|
import { Icon, type IconName } from '../../components/common/Icon';
|
||||||
|
import { useAppSelector } from '../../hooks/useAppDispatch';
|
||||||
|
import { useIsOffline } from '../../hooks/useConnectionStatus';
|
||||||
|
import { useAudioCacheStats } from '../../hooks/useAudioCacheStats';
|
||||||
|
import {
|
||||||
|
selectLocalTracks,
|
||||||
|
selectLocalAlbums,
|
||||||
|
selectLocalArtists,
|
||||||
|
} from '../../store/selectors/localLibrary';
|
||||||
|
import type { AudioCacheStats } from '../../lib/sw';
|
||||||
import {
|
import {
|
||||||
formatFileSize,
|
formatFileSize,
|
||||||
formatCount,
|
formatCount,
|
||||||
@@ -26,6 +35,13 @@ const STATUS_VARIANT: Record<string, 'lime' | 'ember' | 'neutral' | 'outline'> =
|
|||||||
export function StoragePage() {
|
export function StoragePage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data, isLoading, isError, refetch } = useGetStorageStatsQuery();
|
const { data, isLoading, isError, refetch } = useGetStorageStatsQuery();
|
||||||
|
const offline = useIsOffline();
|
||||||
|
|
||||||
|
// Tier-3 audio cache + the locally-cached library metadata = "this device".
|
||||||
|
const audio = useAudioCacheStats();
|
||||||
|
const localTracks = useAppSelector(selectLocalTracks);
|
||||||
|
const localAlbums = useAppSelector(selectLocalAlbums);
|
||||||
|
const localArtists = useAppSelector(selectLocalArtists);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '1.5rem', maxWidth: 1100, margin: '0 auto' }}>
|
<div style={{ padding: '1.5rem', maxWidth: 1100, margin: '0 auto' }}>
|
||||||
@@ -34,23 +50,170 @@ export function StoragePage() {
|
|||||||
{t('storage.subtitle')}
|
{t('storage.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{isLoading && <LoadingSkeleton rows={6} height={72} />}
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||||
{isError && (
|
{/* ── On this device (local + cached) ───────────────────────── */}
|
||||||
<ErrorState message={t('common.error')} onRetry={() => refetch()} />
|
<div>
|
||||||
)}
|
<SectionTitle icon="hard-drives">
|
||||||
{data && data.totalTracks === 0 && (
|
{t('storage.device')}
|
||||||
<EmptyState
|
</SectionTitle>
|
||||||
icon={<Icon name="hard-drives" />}
|
<div style={{ marginTop: '0.75rem' }}>
|
||||||
title={t('storage.emptyTitle')}
|
<LocalStoragePanel
|
||||||
description={t('storage.emptyDesc')}
|
audio={audio}
|
||||||
/>
|
trackCount={localTracks.length}
|
||||||
)}
|
albumCount={localAlbums.length}
|
||||||
{data && data.totalTracks > 0 && <StorageDashboard stats={data} />}
|
artistCount={localArtists.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── On the server (remote) ────────────────────────────────── */}
|
||||||
|
<div>
|
||||||
|
<SectionTitle icon="cloud">{t('storage.server')}</SectionTitle>
|
||||||
|
<div style={{ marginTop: '0.75rem' }}>
|
||||||
|
{isLoading && <LoadingSkeleton rows={6} height={72} />}
|
||||||
|
{isError && offline && (
|
||||||
|
<Callout variant="info">
|
||||||
|
{t('storage.serverUnreachable')}
|
||||||
|
</Callout>
|
||||||
|
)}
|
||||||
|
{isError && !offline && (
|
||||||
|
<ErrorState
|
||||||
|
message={t('common.error')}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{data && data.totalTracks === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Icon name="hard-drives" />}
|
||||||
|
title={t('storage.emptyTitle')}
|
||||||
|
description={t('storage.emptyDesc')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{data && data.totalTracks > 0 && (
|
||||||
|
<StorageDashboard stats={data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Window>
|
</Window>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "On this device": the SW audio cache (downloaded audio) + the offline
|
||||||
|
* library metadata we can browse without the server. */
|
||||||
|
function LocalStoragePanel({
|
||||||
|
audio,
|
||||||
|
trackCount,
|
||||||
|
albumCount,
|
||||||
|
artistCount,
|
||||||
|
}: {
|
||||||
|
audio: AudioCacheStats | null;
|
||||||
|
trackCount: number;
|
||||||
|
albumCount: number;
|
||||||
|
artistCount: number;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card style={{ padding: '1.25rem' }}>
|
||||||
|
<SectionTitle icon="arrow-circle-down">
|
||||||
|
{t('storage.audioCache')}
|
||||||
|
</SectionTitle>
|
||||||
|
{audio ? (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
background: 'var(--color-surface-3)',
|
||||||
|
marginTop: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${audio.maxBytes > 0 ? Math.min((audio.bytes / audio.maxBytes) * 100, 100) : 0}%`,
|
||||||
|
height: '100%',
|
||||||
|
background: 'var(--color-accent)',
|
||||||
|
transition: 'width 0.5s ease',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '0.6rem',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
color: 'var(--color-text-2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('storage.audioCacheUsage', {
|
||||||
|
used: formatFileSize(audio.bytes),
|
||||||
|
max: formatFileSize(audio.maxBytes),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '0.25rem',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('storage.cachedTracks', { n: audio.count })}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: '0.75rem 0 0',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('storage.audioCacheUnavailable')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card style={{ padding: '1.25rem' }}>
|
||||||
|
<SectionTitle icon="vinyl-record">
|
||||||
|
{t('storage.offlineLibrary')}
|
||||||
|
</SectionTitle>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: '0.75rem 0 0',
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--color-text-1)',
|
||||||
|
lineHeight: 1.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatCount(trackCount)}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '0.35rem',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
color: 'var(--color-text-2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('storage.offlineLibraryMeta', {
|
||||||
|
tracks: formatCount(trackCount),
|
||||||
|
albums: formatCount(albumCount),
|
||||||
|
artists: formatCount(artistCount),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function StorageDashboard({ stats }: { stats: StorageStats }) {
|
function StorageDashboard({ stats }: { stats: StorageStats }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const avgSize = Math.round(stats.totalSize / Math.max(stats.totalTracks, 1));
|
const avgSize = Math.round(stats.totalSize / Math.max(stats.totalTracks, 1));
|
||||||
|
|||||||
@@ -6,8 +6,23 @@ import {
|
|||||||
buildUploadFormData,
|
buildUploadFormData,
|
||||||
useUploadTrackMutation,
|
useUploadTrackMutation,
|
||||||
} from '../../api/endpoints/upload';
|
} from '../../api/endpoints/upload';
|
||||||
import { useGetTrackQuery } from '../../api/endpoints/library';
|
import {
|
||||||
|
useGetTrackQuery,
|
||||||
|
useGetTracksQuery,
|
||||||
|
} from '../../api/endpoints/library';
|
||||||
import { MetadataStatusBadge } from '../../components/track/MetadataStatusBadge';
|
import { MetadataStatusBadge } from '../../components/track/MetadataStatusBadge';
|
||||||
|
import { TrackRow } from '../../components/track/TrackRow';
|
||||||
|
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||||
|
import { ErrorState } from '../../components/common/ErrorState';
|
||||||
|
|
||||||
|
/** A8 "Recently uploaded": server-backed list (source=upload, newest first) so
|
||||||
|
* it survives a page refresh — unlike the transient client-side queue above. */
|
||||||
|
const RECENT_UPLOADS = {
|
||||||
|
source: 'upload',
|
||||||
|
sortBy: 'dateAdded',
|
||||||
|
sortOrder: 'desc',
|
||||||
|
pageSize: 20,
|
||||||
|
} as const;
|
||||||
|
|
||||||
/** Pure client-side state — this is a transient upload queue, never server data. */
|
/** Pure client-side state — this is a transient upload queue, never server data. */
|
||||||
type ItemStatus = 'queued' | 'uploading' | 'done' | 'duplicate' | 'error';
|
type ItemStatus = 'queued' | 'uploading' | 'done' | 'duplicate' | 'error';
|
||||||
@@ -40,6 +55,10 @@ export function UploadPage() {
|
|||||||
const [items, setItems] = useState<QueueItem[]>([]);
|
const [items, setItems] = useState<QueueItem[]>([]);
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
|
|
||||||
|
// Persisted view of past uploads. Auto-refreshes after each upload because the
|
||||||
|
// upload mutation invalidates the `Track` tag this query provides.
|
||||||
|
const recentQuery = useGetTracksQuery(RECENT_UPLOADS);
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const idCounter = useRef(0);
|
const idCounter = useRef(0);
|
||||||
const activeCount = useRef(0);
|
const activeCount = useRef(0);
|
||||||
@@ -228,6 +247,32 @@ export function UploadPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<section
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: '0.875rem' }}>
|
||||||
|
{t('upload.recent.title')}
|
||||||
|
</span>
|
||||||
|
{recentQuery.isLoading && <LoadingSkeleton rows={4} />}
|
||||||
|
{recentQuery.isError && (
|
||||||
|
<ErrorState onRetry={() => recentQuery.refetch()} />
|
||||||
|
)}
|
||||||
|
{recentQuery.data && recentQuery.data.items.length === 0 && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('upload.recent.empty')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{recentQuery.data?.items.map((track, i) => (
|
||||||
|
<TrackRow key={track.id} track={track} index={i} showAlbum />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getAudioCacheStats, type AudioCacheStats } from '../lib/sw';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the service-worker audio offline cache stats (Tier 3 — the audio
|
||||||
|
* actually stored on *this device*). Returns `null` until resolved, or when no
|
||||||
|
* controlling service worker is present (insecure origin, first load, …).
|
||||||
|
* `bump` forces a re-read after the cache is mutated (e.g. cleared).
|
||||||
|
*/
|
||||||
|
export function useAudioCacheStats(bump = 0): AudioCacheStats | null {
|
||||||
|
const [stats, setStats] = useState<AudioCacheStats | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void getAudioCacheStats().then((s) => {
|
||||||
|
if (!cancelled) setStats(s);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [bump]);
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
@@ -97,6 +97,13 @@ const en = {
|
|||||||
artistRow: {
|
artistRow: {
|
||||||
meta: '{{albumCount}} albums · {{trackCount}} tracks',
|
meta: '{{albumCount}} albums · {{trackCount}} tracks',
|
||||||
},
|
},
|
||||||
|
offline: {
|
||||||
|
banner:
|
||||||
|
"You're offline — showing the library available locally. It may be incomplete and is read-only until the server is back.",
|
||||||
|
emptyTitle: 'Nothing available offline',
|
||||||
|
emptyDesc:
|
||||||
|
'No library data is cached on this device yet. Connect to the server once to browse offline.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
album: {
|
album: {
|
||||||
type: 'Album',
|
type: 'Album',
|
||||||
@@ -107,6 +114,27 @@ const en = {
|
|||||||
title: 'No tracks',
|
title: 'No tracks',
|
||||||
description: 'This album has no tracks.',
|
description: 'This album has no tracks.',
|
||||||
},
|
},
|
||||||
|
offline: {
|
||||||
|
title: 'Album not available offline',
|
||||||
|
description: "You're offline and this album isn't cached on this device.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
artist: {
|
||||||
|
type: 'Artist',
|
||||||
|
play: '▶ Play all',
|
||||||
|
error: 'Failed to load artist',
|
||||||
|
meta: '{{albumCount}} albums · {{trackCount}} tracks',
|
||||||
|
albums: 'Albums',
|
||||||
|
tracks: 'Tracks',
|
||||||
|
noAlbums: 'No albums yet.',
|
||||||
|
empty: {
|
||||||
|
title: 'No tracks',
|
||||||
|
description: 'This artist has no tracks.',
|
||||||
|
},
|
||||||
|
offline: {
|
||||||
|
title: 'Artist not available offline',
|
||||||
|
description: "You're offline and this artist isn't cached on this device.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
playlist: {
|
playlist: {
|
||||||
type: 'Playlist',
|
type: 'Playlist',
|
||||||
@@ -209,9 +237,21 @@ const en = {
|
|||||||
retry: 'Retry',
|
retry: 'Retry',
|
||||||
comingSoon: 'Coming soon',
|
comingSoon: 'Coming soon',
|
||||||
back: 'Back',
|
back: 'Back',
|
||||||
|
offlineBanner: "You're offline — showing locally available data, read-only.",
|
||||||
},
|
},
|
||||||
storage: {
|
storage: {
|
||||||
subtitle: 'Everything this instance has tucked away',
|
subtitle: 'Everything this instance has tucked away',
|
||||||
|
device: 'On this device',
|
||||||
|
server: 'On the server',
|
||||||
|
audioCache: 'Cached audio',
|
||||||
|
audioCacheUsage: '{{used}} of {{max}} used',
|
||||||
|
cachedTracks: '{{n}} tracks cached for offline',
|
||||||
|
audioCacheUnavailable:
|
||||||
|
'Offline audio cache unavailable (service worker not active).',
|
||||||
|
offlineLibrary: 'Offline library',
|
||||||
|
offlineLibraryMeta:
|
||||||
|
'{{tracks}} tracks · {{albums}} albums · {{artists}} artists browsable offline',
|
||||||
|
serverUnreachable: 'Server unreachable — showing this device only.',
|
||||||
emptyTitle: 'Nothing stored yet',
|
emptyTitle: 'Nothing stored yet',
|
||||||
emptyDesc:
|
emptyDesc:
|
||||||
'Download or upload some music and your library stats will appear here.',
|
'Download or upload some music and your library stats will appear here.',
|
||||||
@@ -297,6 +337,10 @@ const en = {
|
|||||||
clearCompleted: 'Clear completed',
|
clearCompleted: 'Clear completed',
|
||||||
retry: 'Retry',
|
retry: 'Retry',
|
||||||
editMetadata: 'Edit metadata',
|
editMetadata: 'Edit metadata',
|
||||||
|
recent: {
|
||||||
|
title: 'Recently uploaded',
|
||||||
|
empty: 'Nothing uploaded yet.',
|
||||||
|
},
|
||||||
metadataPending:
|
metadataPending:
|
||||||
'Uploaded tracks land as “Unknown Artist” with metadata pending — enrich them afterwards.',
|
'Uploaded tracks land as “Unknown Artist” with metadata pending — enrich them afterwards.',
|
||||||
unknownArtist: 'Unknown Artist · metadata pending',
|
unknownArtist: 'Unknown Artist · metadata pending',
|
||||||
|
|||||||
@@ -99,6 +99,13 @@ const ru: Translations = {
|
|||||||
artistRow: {
|
artistRow: {
|
||||||
meta: '{{albumCount}} альб. · {{trackCount}} треков',
|
meta: '{{albumCount}} альб. · {{trackCount}} треков',
|
||||||
},
|
},
|
||||||
|
offline: {
|
||||||
|
banner:
|
||||||
|
'Нет связи с сервером — показана локально доступная библиотека. Она может быть неполной и доступна только для чтения, пока сервер недоступен.',
|
||||||
|
emptyTitle: 'Офлайн ничего нет',
|
||||||
|
emptyDesc:
|
||||||
|
'На этом устройстве ещё нет кэша библиотеки. Подключитесь к серверу хотя бы раз, чтобы просматривать офлайн.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
album: {
|
album: {
|
||||||
type: 'Альбом',
|
type: 'Альбом',
|
||||||
@@ -109,6 +116,27 @@ const ru: Translations = {
|
|||||||
title: 'Нет треков',
|
title: 'Нет треков',
|
||||||
description: 'В этом альбоме нет треков.',
|
description: 'В этом альбоме нет треков.',
|
||||||
},
|
},
|
||||||
|
offline: {
|
||||||
|
title: 'Альбом недоступен офлайн',
|
||||||
|
description: 'Нет связи, а этот альбом не сохранён на устройстве.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
artist: {
|
||||||
|
type: 'Исполнитель',
|
||||||
|
play: '▶ Слушать всё',
|
||||||
|
error: 'Не удалось загрузить исполнителя',
|
||||||
|
meta: '{{albumCount}} альбомов · {{trackCount}} треков',
|
||||||
|
albums: 'Альбомы',
|
||||||
|
tracks: 'Треки',
|
||||||
|
noAlbums: 'Пока нет альбомов.',
|
||||||
|
empty: {
|
||||||
|
title: 'Нет треков',
|
||||||
|
description: 'У этого исполнителя нет треков.',
|
||||||
|
},
|
||||||
|
offline: {
|
||||||
|
title: 'Исполнитель недоступен офлайн',
|
||||||
|
description: 'Нет связи, а этот исполнитель не сохранён на устройстве.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
playlist: {
|
playlist: {
|
||||||
type: 'Плейлист',
|
type: 'Плейлист',
|
||||||
@@ -211,9 +239,22 @@ const ru: Translations = {
|
|||||||
retry: 'Повторить',
|
retry: 'Повторить',
|
||||||
comingSoon: 'Скоро',
|
comingSoon: 'Скоро',
|
||||||
back: 'Назад',
|
back: 'Назад',
|
||||||
|
offlineBanner:
|
||||||
|
'Нет связи с сервером — показаны локально доступные данные, только для чтения.',
|
||||||
},
|
},
|
||||||
storage: {
|
storage: {
|
||||||
subtitle: 'Всё, что хранит этот инстанс',
|
subtitle: 'Всё, что хранит этот инстанс',
|
||||||
|
device: 'На этом устройстве',
|
||||||
|
server: 'На сервере',
|
||||||
|
audioCache: 'Кэш аудио',
|
||||||
|
audioCacheUsage: 'Занято {{used}} из {{max}}',
|
||||||
|
cachedTracks: '{{n}} треков сохранено офлайн',
|
||||||
|
audioCacheUnavailable:
|
||||||
|
'Офлайн-кэш аудио недоступен (service worker не активен).',
|
||||||
|
offlineLibrary: 'Офлайн-библиотека',
|
||||||
|
offlineLibraryMeta:
|
||||||
|
'{{tracks}} треков · {{albums}} альбомов · {{artists}} исполнителей доступно офлайн',
|
||||||
|
serverUnreachable: 'Сервер недоступен — показано только это устройство.',
|
||||||
emptyTitle: 'Пока ничего не сохранено',
|
emptyTitle: 'Пока ничего не сохранено',
|
||||||
emptyDesc:
|
emptyDesc:
|
||||||
'Загрузите немного музыки — и здесь появится статистика вашей библиотеки.',
|
'Загрузите немного музыки — и здесь появится статистика вашей библиотеки.',
|
||||||
@@ -299,6 +340,10 @@ const ru: Translations = {
|
|||||||
clearCompleted: 'Убрать завершённые',
|
clearCompleted: 'Убрать завершённые',
|
||||||
retry: 'Повторить',
|
retry: 'Повторить',
|
||||||
editMetadata: 'Изменить метаданные',
|
editMetadata: 'Изменить метаданные',
|
||||||
|
recent: {
|
||||||
|
title: 'Недавно загруженные',
|
||||||
|
empty: 'Пока ничего не загружено.',
|
||||||
|
},
|
||||||
metadataPending:
|
metadataPending:
|
||||||
'Загруженные треки появляются как «Unknown Artist» с метаданными в ожидании — дозаполните их позже.',
|
'Загруженные треки появляются как «Unknown Artist» с метаданными в ожидании — дозаполните их позже.',
|
||||||
unknownArtist: 'Unknown Artist · метаданные в ожидании',
|
unknownArtist: 'Unknown Artist · метаданные в ожидании',
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
* Offline library composition. When the active backend is unreachable, a single
|
||||||
|
* `getTracks` query may be `rejected` (or never matched a rehydrated arg), so we
|
||||||
|
* can't rely on it to render the library. Instead we compose the "locally
|
||||||
|
* available" library from *every* fulfilled entry in the RTK Query cache —
|
||||||
|
* last-seen lists rehydrated from localStorage (Tier 2) plus anything fetched
|
||||||
|
* this session. This is read-only derived data, not a server-data slice copy:
|
||||||
|
* it reads straight from the RTKQ cache the architecture already owns.
|
||||||
|
*/
|
||||||
|
import { createSelector } from '@reduxjs/toolkit';
|
||||||
|
import type { RootState } from '../index';
|
||||||
|
import type { Album, Artist, PaginatedResponse, Track } from '../../api/types';
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
status: string;
|
||||||
|
endpointName?: string;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectQueries = (state: RootState): Record<string, unknown> =>
|
||||||
|
state.api.queries;
|
||||||
|
|
||||||
|
function fulfilled(queries: Record<string, unknown>): 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<string, Track>();
|
||||||
|
for (const e of fulfilled(queries)) {
|
||||||
|
switch (e.endpointName) {
|
||||||
|
case 'getTracks':
|
||||||
|
for (const t of (e.data as PaginatedResponse<Track>).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<string, Album>();
|
||||||
|
for (const e of fulfilled(queries)) {
|
||||||
|
switch (e.endpointName) {
|
||||||
|
case 'getAlbums':
|
||||||
|
for (const a of (e.data as PaginatedResponse<Album>).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<string, Artist>();
|
||||||
|
for (const e of fulfilled(queries)) {
|
||||||
|
switch (e.endpointName) {
|
||||||
|
case 'getArtists':
|
||||||
|
for (const a of (e.data as PaginatedResponse<Artist>).items)
|
||||||
|
byId.set(a.id, a);
|
||||||
|
break;
|
||||||
|
case 'getArtist':
|
||||||
|
byId.set((e.data as Artist).id, e.data as Artist);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...byId.values()];
|
||||||
|
},
|
||||||
|
);
|
||||||
+36
-12
@@ -651,6 +651,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
padding: 12px 12px 18px;
|
padding: 12px 12px 18px;
|
||||||
}
|
}
|
||||||
.qrow {
|
.qrow {
|
||||||
@@ -700,23 +701,46 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--fg-1);
|
color: var(--fg-1);
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
.qrow .qt .r {
|
.qrow .qt .r {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--fg-3);
|
color: var(--fg-3);
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
}
|
||||||
.qrow .qt .r .ph {
|
|
||||||
color: var(--lime);
|
/* News-ticker text: clips by default, ping-pong scrolls only when it overflows
|
||||||
font-size: 11px;
|
(the .on class is set by the Marquee component after measuring). */
|
||||||
|
.marquee {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.marquee-inner {
|
||||||
|
display: inline-block;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
.marquee.on .marquee-inner {
|
||||||
|
max-width: none;
|
||||||
|
animation: marquee-pingpong 9s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
@keyframes marquee-pingpong {
|
||||||
|
0%,
|
||||||
|
12% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
88%,
|
||||||
|
100% {
|
||||||
|
transform: translateX(var(--mq-shift, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.marquee.on .marquee-inner {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.qd-radio {
|
.qd-radio {
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
|
|||||||
@@ -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<string, unknown>): RootState {
|
||||||
|
return { api: { queries } } as unknown as RootState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const track = (id: string, over: Record<string, unknown> = {}) => ({
|
||||||
|
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']);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user