Compare commits

...

7 Commits

Author SHA1 Message Date
Senko-san cdcacc56d1 fix(queue): marquee long track names + dedupe now-playing bars
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
Queue sidebar no longer scrolls horizontally on long titles/artists:
text now ping-pong scrolls (news-ticker style) only when it overflows,
via a new Marquee component; .qd-scroll also clips overflow-x.

The current track previously showed the playing-bars indicator both in
place of the drag grip and over the cover. Keep only the cover overlay
and restore the drag grip on the current row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 02:28:27 +03:00
Senko-san b966ad8be5 fix(library): show album cover art in the Albums grid
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
The album cards always fell back to the 💿 placeholder: the mapper dropped
the backend's `has_cover` and no album cover URL was ever built. Carry
`hasCover` through `RawAlbum`/`Album` and add `getAlbumCoverUrl` (GET
/albums/{id}/cover, token in the query like the track/stream URLs). The
Library Albums grid and the artist-detail discography now render real
covers, same source the album-detail page already used.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 02:15:50 +03:00
Senko-san 6595417246 feat(storage): show device-local storage alongside the server
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
The Storage dashboard only showed the remote server library. Split it into
two sections so both storages live there:

- "On this device": the Tier-3 service-worker audio cache (downloaded
  audio — usage gauge vs max, cached-track count) plus the offline library
  metadata (tracks/albums/artists browsable without the server, from the
  selectLocal* selectors). Always rendered, even with no backend.
- "On the server": the existing remote dashboard, now offline-aware — a
  quiet "server unreachable" notice instead of a blocking error when off.

- hook: useAudioCacheStats (reads getAudioCacheStats from the SW)
- i18n: storage.{device,server,audioCache,...} (en + ru)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 02:05:22 +03:00
Senko-san 94361899a8 feat(library): offline fallback for album & artist detail pages
Docker Build & Publish / Prune old image versions (push) Has been cancelled
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Extend the offline-library behaviour to the detail screens: with the
backend unreachable, both pages resolve their entity + tracks/albums from
the locally-cached library (reusing the `selectLocal*` selectors, filtered
by id) instead of showing a retry-only error.

- album detail: album + tracks from cache; offline banner; "not available
  offline" state when the album isn't cached; inner track states no longer
  error over locally-available tracks
- artist detail: artist + discography + tracks from cache; same treatment
- i18n: `common.offlineBanner`, `album.offline.*`, `artist.offline.*`
  (en + ru)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 01:49:39 +03:00
Senko-san 8a0e6782ad feat(library): render from locally-cached data when offline
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
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
Senko-san 4aa071eeeb feat(upload): persistent "Recently uploaded" list (§A8)
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
The transient client-side upload queue vanished on refresh, so a just-
uploaded track seemed to disappear. Add a server-backed "Recently
uploaded" section (source=upload, newest first) that survives refresh and
auto-refreshes after each upload (the upload mutation already invalidates
the `Track` tag this query provides).

- api: `source` filter on `LibraryFilters` → `GET /tracks?source=`
- i18n: `upload.recent.*` (en + ru); loading/error/empty states

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 01:36:07 +03:00
Senko-san 45a624b642 feat(artist): functional Artist detail screen (§A3)
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
Replace the scaffold Placeholder with a real artist page wired to the
existing `/artists/{id}` (+ `/albums`, `/tracks`) endpoints: header with
procedural avatar / counts / "Play all" (queues with source=artist),
discography grid (cards → album detail), and the full track list. All
three list states per async section.

Also make the Library artists-tab rows clickable → `/artists/:id`,
closing the previous navigation dead-end.

i18n: new `artist.*` block (en + ru).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 01:30:06 +03:00
17 changed files with 1118 additions and 119 deletions
+1
View File
@@ -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),
+15
View File
@@ -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)}`;
}
+4
View File
@@ -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
+4
View File
@@ -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;
+39
View File
@@ -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>
);
}
+6 -9
View File
@@ -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>
+46 -17
View File
@@ -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')}
+302 -3
View File
@@ -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>
);
} }
+148 -64
View File
@@ -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
+176 -13
View File
@@ -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));
+46 -1
View File
@@ -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>
+22
View File
@@ -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;
}
+44
View File
@@ -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',
+45
View File
@@ -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 · метаданные в ожидании',
+97
View File
@@ -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
View File
@@ -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;
+87
View File
@@ -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']);
});