Compare commits

...

11 Commits

Author SHA1 Message Date
Senko-san 808c52484c feat(storage): functional Storage dashboard (§A6)
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
Replace the "coming soon" stub with a real dashboard wired to
`GET /storage`. modern-sk visuals: a layered disk-capacity gauge (library
share vs other-used vs free), stat tiles (tracks/artists/albums/playtime/
footprint/avg size), per-format size bars, metadata-health badges, source
breakdown, a popularity-weighted top-genres cloud, and playful fun facts.

- types: full `StorageStats` shape + `toStorageStats` snake→camel mapper
- endpoint: re-point `getStorageStats` to `GET /storage` with transform
- lib: `formatLongDuration` for big playtime spans
- i18n: `storage.*` keys (en + ru)
- three list states (loading / error / empty) per the UI invariant

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 01:20:01 +03:00
Senko-san 44c8d1870f feat(queue): move shuffle/loop controls into queue drawer, scoped to queue
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
2026-06-13 18:17:21 +03:00
Senko-san a8e060d1a8 fix(player): show actual track format instead of hardcoded FLAC/320kbps
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
2026-06-13 18:06:35 +03:00
Senko-san 8ae447e08d feat(track): icon-based status badges, detect locally-cached tracks
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 labelled availability/metadata badges in track rows with
small icon+tooltip indicators (cloud/hard-drives/warning/etc, derived
from TrackAvailability and MetadataStatus).

Add a `connection` slice fed by a single status poller (Sidebar) so
other components can cheaply check backend reachability. TrackRow uses
this plus the offline audio cache to show "Local" instead of a stale
"On server" when the backend is down but the track is already cached.
2026-06-13 18:00:48 +03:00
Senko-san df8c67b368 feat(album): single cover on album detail, track-number rows
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
Album detail header falls back to a track's cover when the album
record itself has none, and each track row hides its per-track art in
favour of a large album-position number, since the header already
shows the album's cover once.
2026-06-13 17:51:55 +03:00
Senko-san f5767ff55e feat(track): show now-playing bars overlay on cover art
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
Overlay the accent-coloured "hopping bars" PlayingIndicator on a
track's cover art wherever TrackRow appears when it's the active
player track, and reuse the same component/overlay for the current
entry's cover in the queue panel.
2026-06-13 17:49:06 +03:00
Senko-san 3984c7a499 feat(track): play-on-hover cover art, replacing double-click
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
Add a play overlay button shown on cover art hover that inserts the
track into the queue right after the current track and jumps to it,
so it takes priority over what's already queued. Replaces the
double-click-to-play interaction with a new playNow queue action.
2026-06-13 17:44:35 +03:00
Senko-san b37fabd936 feat(queue): make queue tracks draggable via dnd-kit
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
Wire up @dnd-kit sortable context in QueuePanel so tracks can be
reordered by dragging the grip handle, dispatching moveInQueue on drop.
2026-06-13 17:40:58 +03:00
Senko-san 9c70b8a11f feat(queue): add per-track overflow menu in queue panel
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 bare "remove" cross on each queue row with a ghost
three-dot menu offering Play now, Move next (reposition right after
the current track), Track info, and Remove — consolidating the
previously separate info button into the same menu.
2026-06-13 17:37:17 +03:00
Senko-san 5c8f89675d feat(queue): unified persistent queue list with playing indicator
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
Show all queue entries (played and upcoming) in one list instead of
splitting into a "Now playing" card + "Next up" tail, so previously
played tracks don't disappear and reappear when navigating back/forward.
The current track is outlined and shows a reusable "hopping bars"
PlayingIndicator (modern-sk style equalizer animation) for future reuse
across track lists.
2026-06-13 17:18:38 +03:00
Senko-san df2531171e fix(queue): resolve real track covers in queue panel
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 rows passed albumArtUrl straight to ArtTile, but for tracks that
field is usually empty — the real cover is served per-track from
/tracks/{id}/cover. Apply the same resolution PersistentPlayer uses
(getCoverUrl ?? getTrackCoverUrl) for both the now-playing tile and the
up-next rows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 17:06:21 +03:00
28 changed files with 1406 additions and 222 deletions
+70 -1
View File
@@ -8,6 +8,9 @@
"name": "mcma-webui",
"version": "1.0.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@olly/modern-sk": "^0.1.5",
"@phosphor-icons/react": "^2.1.10",
"@reduxjs/toolkit": "^2.12.0",
@@ -16,7 +19,8 @@
"react-dom": "^19.2.6",
"react-i18next": "^17.0.8",
"react-redux": "^9.3.0",
"react-router": "^7.16.0"
"react-router": "^7.16.0",
"use-debounce": "^10.1.1"
},
"devDependencies": {
"@rsbuild/core": "^2.0.7",
@@ -551,6 +555,59 @@
"node": ">=6.9.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@@ -4469,6 +4526,18 @@
}
}
},
"node_modules/use-debounce": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.1.1.tgz",
"integrity": "sha512-kvds8BHR2k28cFsxW8k3nc/tRga2rs1RHYCqmmGqb90MEeE++oALwzh2COiuBLO1/QXiOuShXoSN2ZpWnMmvuQ==",
"license": "MIT",
"engines": {
"node": ">= 16.0.0"
},
"peerDependencies": {
"react": "*"
}
},
"node_modules/use-sidecar": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
+5 -1
View File
@@ -13,6 +13,9 @@
"test:watch": "rstest --watch"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@olly/modern-sk": "^0.1.5",
"@phosphor-icons/react": "^2.1.10",
"@reduxjs/toolkit": "^2.12.0",
@@ -21,7 +24,8 @@
"react-dom": "^19.2.6",
"react-i18next": "^17.0.8",
"react-redux": "^9.3.0",
"react-router": "^7.16.0"
"react-router": "^7.16.0",
"use-debounce": "^10.1.1"
},
"devDependencies": {
"@rsbuild/core": "^2.0.7",
+6 -7
View File
@@ -1,17 +1,16 @@
import { api } from '../index';
import { toStorageStats, type RawStorageStats } from '../mappers';
import type { StorageStats } from '../types';
// NOTE: the backend `/storage` routes are still unimplemented stubs (no body /
// no schema), and the real paths differ from these placeholders (`GET /storage`,
// `/storage/duplicates`, `/storage/broken`, `/storage/missing-metadata`,
// `POST /storage/cleanup`). Re-point paths and add snake→camel mappers (see
// `mappers.ts`) once the backend defines the storage response shapes; until then
// these are provisional and unused by the UI.
// `GET /storage` returns library + disk statistics (§A6). The maintenance
// routes (`/storage/duplicates`, `/storage/broken`, `/storage/missing-metadata`,
// `POST /storage/cleanup`) are still backend stubs and unused by the UI.
export const storageApi = api.injectEndpoints({
endpoints: (build) => ({
getStorageStats: build.query<StorageStats, void>({
query: () => '/storage/stats',
query: () => '/storage',
transformResponse: (raw: RawStorageStats) => toStorageStats(raw),
providesTags: ['Storage'],
}),
scanStorage: build.mutation<{ jobId: string }, void>({
+42
View File
@@ -18,6 +18,7 @@ import type {
MetadataStatus,
PaginatedResponse,
Playlist,
StorageStats,
Track,
User,
} from './types';
@@ -200,6 +201,47 @@ export const toPlaylist = (r: RawPlaylist): Playlist => ({
updatedAt: r.created_at,
});
interface RawStorageStats {
total_tracks: number;
total_artists: number;
total_albums: number;
total_size: number;
total_duration_seconds: number;
largest_track_size: number;
earliest_added: string | null;
latest_added: string | null;
by_format: { file_format: string; track_count: number; total_size: number }[];
by_metadata_status: Record<string, number>;
by_source: Record<string, number>;
top_genres: { genre: string; track_count: number }[];
disk: { total: number; used: number; free: number } | null;
}
export type { RawStorageStats };
export const toStorageStats = (r: RawStorageStats): StorageStats => ({
totalTracks: r.total_tracks,
totalArtists: r.total_artists,
totalAlbums: r.total_albums,
totalSize: r.total_size,
totalDurationSeconds: r.total_duration_seconds,
largestTrackSize: r.largest_track_size,
earliestAdded: r.earliest_added ?? undefined,
latestAdded: r.latest_added ?? undefined,
byFormat: r.by_format.map((f) => ({
fileFormat: f.file_format,
trackCount: f.track_count,
totalSize: f.total_size,
})),
byMetadataStatus: r.by_metadata_status,
bySource: r.by_source,
topGenres: r.top_genres.map((g) => ({
genre: g.genre,
trackCount: g.track_count,
})),
disk: r.disk ?? undefined,
});
/**
* Translate the backend's `{items,total,limit,offset}` envelope into the UI's
* `{items,total,page,pageSize,hasMore}`, mapping each element.
+33 -5
View File
@@ -96,12 +96,40 @@ export interface UploadResponse {
already_exists: boolean;
}
export interface StorageStats {
totalBytes: number;
usedBytes: number;
export interface StorageFormatBreakdown {
fileFormat: string;
trackCount: number;
albumCount: number;
artistCount: number;
totalSize: number;
}
export interface StorageGenreCount {
genre: string;
trackCount: number;
}
/** Capacity of the volume backing the media store. Absent for object-store
* backends (S3), which have no fixed disk to report. */
export interface StorageDiskUsage {
total: number;
used: number;
free: number;
}
export interface StorageStats {
totalTracks: number;
totalArtists: number;
totalAlbums: number;
/** Sum of every track's recorded file size (the library's footprint). */
totalSize: number;
totalDurationSeconds: number;
largestTrackSize: number;
earliestAdded?: string;
latestAdded?: string;
byFormat: StorageFormatBreakdown[];
byMetadataStatus: Record<string, number>;
bySource: Record<string, number>;
topGenres: StorageGenreCount[];
disk?: StorageDiskUsage;
}
export interface User {
+2
View File
@@ -15,6 +15,7 @@ import {
ArrowsClockwise,
CheckCircle,
Cloud,
CloudSlash,
DotsSixVertical,
GearSix,
HardDrives,
@@ -75,6 +76,7 @@ const ICONS = {
'speaker-high': SpeakerHigh,
'speaker-x': SpeakerSimpleX,
cloud: Cloud,
'cloud-slash': CloudSlash,
'check-circle': CheckCircle,
'warning-circle': WarningCircle,
'sign-out': SignOut,
@@ -0,0 +1,22 @@
/*
* "Hopping bars" equalizer indicator (YTM-style) shown next to the currently
* playing track. `animate` controls whether the bars bounce (playback active)
* or sit frozen at full height (paused). Reusable across track lists.
*/
interface Props {
animate?: boolean;
className?: string;
}
export function PlayingIndicator({ animate = true, className }: Props) {
return (
<span
className={`playing-bars${animate ? '' : ' paused'}${className ? ` ${className}` : ''}`}
aria-hidden="true"
>
<span />
<span />
<span />
</span>
);
}
+2 -2
View File
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Icon, type IconName } from '../common/Icon';
import { useAppDispatch } from '../../hooks/useAppDispatch';
import { usePermissions, type Permission } from '../../hooks/usePermissions';
import { useConnectionStatus } from '../../hooks/useConnectionStatus';
import { useConnectionStatusSync } from '../../hooks/useConnectionStatus';
import { logout } from '../../store/slices/auth';
import { useGetPlaylistsQuery } from '../../api/endpoints/playlists';
import { getActiveInstance } from '../../config/instances';
@@ -41,7 +41,7 @@ export function Sidebar() {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const { user, isAdmin, hasPermission } = usePermissions();
const status = useConnectionStatus();
const status = useConnectionStatusSync();
const { data: playlists } = useGetPlaylistsQuery();
const instance = getActiveInstance();
+2 -28
View File
@@ -8,8 +8,6 @@ import {
resume,
toggleMute,
setVolume,
toggleShuffle,
setRepeat,
toggleQueue,
} from '../../store/slices/player';
import { openTrackInfo } from '../../store/slices/ui';
@@ -44,6 +42,7 @@ export function PersistentPlayer() {
: undefined);
const seedLabel = current?.albumTitle ?? current?.title ?? '';
const onStream = !cached;
const formatLabel = current?.format?.toUpperCase();
return (
<div className="player">
@@ -65,20 +64,13 @@ export function PersistentPlayer() {
>
<Icon name={onStream ? 'cloud' : 'check-circle'} fill={!onStream} />
{onStream ? t('player.streaming') : t('player.local')}
{formatLabel && ` · ${formatLabel}`}
</div>
</div>
</div>
<div className="pl-center">
<div className="pl-transport">
<button
type="button"
className={`pl-tbtn${player.shuffle ? ' on' : ''}`}
onClick={() => dispatch(toggleShuffle())}
title={t('player.shuffle')}
>
<Icon name="shuffle" />
</button>
<button
type="button"
className="pl-tbtn"
@@ -105,24 +97,6 @@ export function PersistentPlayer() {
>
<Icon name="skip-forward" fill />
</button>
<button
type="button"
className={`pl-tbtn${player.repeat !== 'none' ? ' on' : ''}`}
onClick={() =>
dispatch(
setRepeat(
player.repeat === 'none'
? 'all'
: player.repeat === 'all'
? 'one'
: 'none',
),
)
}
title={t('player.repeat', { mode: player.repeat })}
>
<Icon name="repeat" />
</button>
</div>
<div className="pl-seek">
<span className="pl-time">
+157 -52
View File
@@ -1,33 +1,71 @@
import { Slider, Badge } from '@olly/modern-sk';
import {
Slider,
Badge,
Menu,
MenuTrigger,
MenuContent,
MenuItem,
IconButton,
} from '@olly/modern-sk';
import { useTranslation } from 'react-i18next';
import {
DndContext,
closestCenter,
PointerSensor,
KeyboardSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Icon } from '../common/Icon';
import { ArtTile } from '../common/ArtTile';
import { PlayingIndicator } from '../common/PlayingIndicator';
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
import {
goToIndex,
removeFromQueue,
moveInQueue,
clearQueue,
toggleShuffle,
toggleLoop,
type QueueEntry,
} from '../../store/slices/queue';
import { toggleQueue } from '../../store/slices/player';
import { openTrackInfo } from '../../store/slices/ui';
import { useResolvedQueueEntry } from '../../hooks/useResolvedQueueEntry';
import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
export function QueuePanel() {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const queue = useAppSelector((s) => s.queue);
const isPlaying = useAppSelector((s) => s.player.isPlaying);
const isOpen = useAppSelector((s) => s.player.isQueueOpen);
const nowEntry =
queue.currentIndex >= 0 ? queue.entries[queue.currentIndex] : undefined;
const now = useResolvedQueueEntry(nowEntry);
const upNext = queue.entries
.map((entry, index) => ({ entry, index }))
.filter(({ index }) => index > queue.currentIndex);
const hasEntries = queue.entries.length > 0;
const isRadio = queue.source === 'radio';
const sourceLabel = queue.sourceName ?? queue.source;
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
dispatch(moveInQueue({ from: Number(active.id), to: Number(over.id) }));
};
return (
<aside className={`qd${isOpen ? '' : ' closed'}`} aria-hidden={!isOpen}>
<div className="qd-inner">
@@ -35,6 +73,22 @@ export function QueuePanel() {
<div className="row">
<h3>{t('queue.title')}</h3>
<div style={{ flex: 1 }} />
<button
type="button"
className={`iconbtn sm${queue.shuffle ? ' on' : ''}`}
onClick={() => dispatch(toggleShuffle())}
title={t('queue.shuffle')}
>
<Icon name="shuffle" />
</button>
<button
type="button"
className={`iconbtn sm${queue.loop ? ' on' : ''}`}
onClick={() => dispatch(toggleLoop())}
title={t('queue.loop')}
>
<Icon name="repeat" />
</button>
<button
type="button"
className="iconbtn sm"
@@ -68,34 +122,8 @@ export function QueuePanel() {
</div>
<div className="qd-scroll">
{now ? (
{hasEntries ? (
<>
<span
className="msk-label"
style={{ display: 'block', marginBottom: 8 }}
>
{t('queue.nowPlaying')}
</span>
<div className="qd-now">
<ArtTile
seed={now.albumTitle}
size={44}
label={now.albumTitle}
/>
<div className="qt">
<div className="t">{now.title}</div>
<div className="r">{now.artistName}</div>
</div>
<button
type="button"
className="iconbtn sm"
onClick={() => dispatch(openTrackInfo(now.trackId))}
title={t('trackInfo.open')}
>
<Icon name="info" />
</button>
</div>
{isRadio && (
<div className="qd-radio">
<div className="row">
@@ -133,18 +161,36 @@ export function QueuePanel() {
>
{t('queue.nextUp')}
</span>
{upNext.length === 0 ? (
<div className="qd-empty">{t('queue.nothingNext')}</div>
) : (
upNext.map(({ entry, index }) => (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={queue.entries.map((_, index) => String(index))}
strategy={verticalListSortingStrategy}
>
{queue.entries.map((entry, index) => (
<QueueRow
key={`${entry.trackId}-${index}`}
id={String(index)}
entry={entry}
isCurrent={index === queue.currentIndex}
isPlaying={isPlaying}
onPlay={() => dispatch(goToIndex(index))}
onMoveNext={() =>
dispatch(
moveInQueue({
from: index,
to: queue.currentIndex + 1,
}),
)
}
onRemove={() => dispatch(removeFromQueue(index))}
/>
))
)}
))}
</SortableContext>
</DndContext>
{isRadio && (
<div className="qd-loadmore">{t('queue.loadingMore')}</div>
@@ -159,43 +205,102 @@ export function QueuePanel() {
);
}
/** An "up next" row, resolving its display fields against the live Track cache
* (same read-through as the now-playing entry) so enrichment updates show. */
/** A queue row, resolving its display fields against the live Track cache so
* enrichment updates show. The currently-playing entry is outlined and shows
* a playing-bars indicator in place of the drag grip. */
function QueueRow({
id,
entry,
isCurrent,
isPlaying,
onPlay,
onMoveNext,
onRemove,
}: {
id: string;
entry: QueueEntry;
isCurrent: boolean;
isPlaying: boolean;
onPlay: () => void;
onMoveNext: () => void;
onRemove: () => void;
}) {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const token = useAppSelector((s) => s.auth.accessToken);
const resolved = useResolvedQueueEntry(entry);
const albumTitle = resolved?.albumTitle ?? entry.albumTitle;
const artUrl =
getCoverUrl(resolved?.albumArtUrl) ??
(token && resolved?.hasCover
? getTrackCoverUrl(resolved.trackId, token, true)
: undefined);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
className="qrow"
ref={setNodeRef}
style={style}
className={`qrow${isCurrent ? ' current' : ''}${isDragging ? ' dragging' : ''}`}
onDoubleClick={onPlay}
title={t('queue.doubleClickPlay')}
>
<span className="grip">
{isCurrent ? (
<PlayingIndicator animate={isPlaying} />
) : (
<span className="grip" {...attributes} {...listeners}>
<Icon name="dots-six-vertical" />
</span>
<ArtTile seed={albumTitle} size={36} label={albumTitle} />
)}
<div className="qart">
<ArtTile seed={albumTitle} size={36} label={albumTitle} src={artUrl} />
{isCurrent && (
<div className="cover-playing">
<PlayingIndicator animate={isPlaying} />
</div>
)}
</div>
<div className="qt">
<div className="t">{resolved?.title ?? entry.title}</div>
<div className="r">{resolved?.artistName ?? entry.artistName}</div>
</div>
<button
type="button"
className="iconbtn sm"
onClick={onRemove}
title={t('queue.removeFromQueue')}
<Menu>
<MenuTrigger asChild>
<IconButton
variant="ghost"
size="sm"
aria-label={t('queue.menu.options')}
>
<Icon name="x" />
</button>
</IconButton>
</MenuTrigger>
<MenuContent>
<MenuItem onSelect={onPlay}>{t('queue.menu.playNow')}</MenuItem>
{!isCurrent && (
<MenuItem onSelect={onMoveNext}>
{t('queue.menu.moveNext')}
</MenuItem>
)}
<MenuItem onSelect={() => dispatch(openTrackInfo(entry.trackId))}>
{t('queue.menu.info')}
</MenuItem>
<MenuItem onSelect={onRemove}>{t('queue.menu.remove')}</MenuItem>
</MenuContent>
</Menu>
</div>
);
}
+55 -5
View File
@@ -1,38 +1,88 @@
import { Badge, Tooltip } from '@olly/modern-sk';
import { Icon, type IconName } from '../common/Icon';
import type { TrackAvailability } from '../../api/types';
/** `TrackAvailability` plus a client-derived state: the backend reports
* `server`, but if it's unreachable and the track's audio is already in the
* offline cache, we know better — show `local` instead. */
export type DisplayAvailability = TrackAvailability | 'local';
interface Props {
availability: TrackAvailability;
availability: DisplayAvailability;
/** Render as a small icon + tooltip instead of a labelled badge — used in
* dense track lists (library, album, playlist). */
iconOnly?: boolean;
}
const COLOR_VAR: Record<Variant, string> = {
lime: 'var(--lime)',
ember: 'var(--ember)',
neutral: 'var(--fg-3)',
outline: 'var(--fg-3)',
};
type Variant = 'lime' | 'ember' | 'neutral' | 'outline';
const CONFIG: Record<
TrackAvailability,
DisplayAvailability,
{
label: string;
variant: 'lime' | 'ember' | 'neutral' | 'outline';
variant: Variant;
icon: IconName;
spin?: boolean;
tooltip: string;
}
> = {
server: {
label: 'On server',
variant: 'lime',
icon: 'cloud',
tooltip: 'File available on server',
},
local: {
label: 'Local',
variant: 'lime',
icon: 'hard-drives',
tooltip: 'Cached on this device — playable offline',
},
downloading: {
label: 'Downloading',
variant: 'neutral',
icon: 'arrows-clockwise',
spin: true,
tooltip: 'Currently downloading',
},
error: { label: 'Error', variant: 'ember', tooltip: 'Download failed' },
error: {
label: 'Error',
variant: 'ember',
icon: 'warning-circle',
tooltip: 'Download failed',
},
missing: {
label: 'Missing',
variant: 'outline',
icon: 'cloud-slash',
tooltip: 'File not found on server',
},
};
export function AvailabilityBadge({ availability }: Props) {
export function AvailabilityBadge({ availability, iconOnly }: Props) {
const cfg = CONFIG[availability];
if (iconOnly) {
return (
<Tooltip content={cfg.tooltip}>
<span style={{ display: 'inline-flex' }}>
<Icon
name={cfg.icon}
className={cfg.spin ? 'spin' : undefined}
style={{ color: COLOR_VAR[cfg.variant], fontSize: 15 }}
/>
</span>
</Tooltip>
);
}
return (
<Tooltip content={cfg.tooltip}>
<Badge variant={cfg.variant} dot>
@@ -1,5 +1,6 @@
import { Badge, Spinner, Tooltip } from '@olly/modern-sk';
import { useTranslation } from 'react-i18next';
import { Icon, type IconName } from '../common/Icon';
import type { MetadataStatus } from '../../api/types';
interface Props {
@@ -9,6 +10,9 @@ interface Props {
/** When true, render nothing for the normal `enriched` state (keeps dense
* track lists quiet; the upload screen sets this false to confirm success). */
hideWhenEnriched?: boolean;
/** Render as a small icon + tooltip instead of a labelled badge — used in
* dense track lists (library, album, playlist). */
iconOnly?: boolean;
}
type Variant = 'lime' | 'ember' | 'neutral' | 'outline';
@@ -20,6 +24,19 @@ const VARIANT: Record<MetadataStatus, Variant> = {
manual: 'outline',
};
const COLOR_VAR: Record<Variant, string> = {
lime: 'var(--lime)',
ember: 'var(--ember)',
neutral: 'var(--fg-3)',
outline: 'var(--fg-3)',
};
const ICON: Record<Exclude<MetadataStatus, 'pending'>, IconName> = {
enriched: 'check-circle',
failed: 'warning-circle',
manual: 'push-pin',
};
/**
* Shows a track's metadata-enrichment state (distinct from file availability).
* `pending` carries a spinner; `failed` exposes the backend reason on hover.
@@ -28,6 +45,7 @@ export function MetadataStatusBadge({
status,
error,
hideWhenEnriched = true,
iconOnly,
}: Props) {
const { t } = useTranslation();
if (status === 'enriched' && hideWhenEnriched) return null;
@@ -36,6 +54,23 @@ export function MetadataStatusBadge({
const tooltip =
status === 'failed' && error ? error : t(`metadata.statusHint.${status}`);
if (iconOnly) {
return (
<Tooltip content={tooltip}>
<span style={{ display: 'inline-flex' }}>
{status === 'pending' ? (
<Spinner size="sm" />
) : (
<Icon
name={ICON[status]}
style={{ color: COLOR_VAR[VARIANT[status]], fontSize: 15 }}
/>
)}
</span>
</Tooltip>
);
}
return (
<Tooltip content={tooltip}>
<Badge variant={VARIANT[status]} dot={status !== 'pending'}>
+73 -5
View File
@@ -1,10 +1,15 @@
import { Row } from '@olly/modern-sk';
import { useTranslation } from 'react-i18next';
import { TrackContextMenu } from './TrackContextMenu';
import { AvailabilityBadge } from './AvailabilityBadge';
import { MetadataStatusBadge } from './MetadataStatusBadge';
import { Icon } from '../common/Icon';
import { PlayingIndicator } from '../common/PlayingIndicator';
import { formatDuration } from '../../lib/format';
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
import { play } from '../../store/slices/player';
import { useIsOffline } from '../../hooks/useConnectionStatus';
import { useStreamCached } from '../../hooks/useStreamCached';
import { playNow } from '../../store/slices/queue';
import type { Track } from '../../api/types';
import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
@@ -12,6 +17,10 @@ interface Props {
track: Track;
index?: number;
showAlbum?: boolean;
/** Hide cover art and show the track's album position instead — used on
* the album detail page, where the album cover is already shown once in
* the header and per-track art would be redundant. */
hideArt?: boolean;
onAddToPlaylist?: (track: Track) => void;
onEditMetadata?: (track: Track) => void;
onDelete?: (track: Track) => void;
@@ -21,10 +30,12 @@ export function TrackRow({
track,
index,
showAlbum = false,
hideArt = false,
onAddToPlaylist,
onEditMetadata,
onDelete,
}: Props) {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const currentTrackId = useAppSelector((s) => s.player.currentTrackId);
const isPlaying = useAppSelector((s) => s.player.isPlaying);
@@ -36,19 +47,43 @@ export function TrackRow({
getCoverUrl(track.albumArtUrl) ??
(token ? getTrackCoverUrl(track.id, token, track.hasCover) : undefined);
// The backend reports `server`, but if it's unreachable and this track's
// audio is already in the offline cache, show "Local" instead.
const offline = useIsOffline();
const cached = useStreamCached(offline ? track.id : undefined);
const displayAvailability =
track.availability === 'server' && offline && cached
? 'local'
: track.availability;
const handlePlayNow = () => {
dispatch(
playNow({
trackId: track.id,
title: track.title,
artistName: track.artistName,
albumTitle: track.albumTitle,
durationMs: track.durationMs,
albumArtUrl: track.albumArtUrl,
}),
);
};
return (
<Row
selected={isActive}
onDoubleClick={() => dispatch(play(track.id))}
style={{
display: 'grid',
gridTemplateColumns: '2rem 2.5rem 1fr auto auto',
gridTemplateColumns: hideArt
? '2.5rem 1fr auto auto'
: '2rem 2.5rem 1fr auto auto',
gap: '0.75rem',
alignItems: 'center',
padding: '0.375rem 0.75rem',
cursor: 'default',
}}
>
{!hideArt && (
<span
style={{
fontSize: '0.75rem',
@@ -58,7 +93,24 @@ export function TrackRow({
>
{isActive && isPlaying ? '▶' : index !== undefined ? index + 1 : ''}
</span>
{artUrl ? (
)}
<div className="track-art">
{hideArt ? (
<div
style={{
width: 36,
height: 36,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.0625rem',
fontWeight: 600,
color: isActive ? 'var(--color-accent)' : 'var(--color-text-3)',
}}
>
{track.trackNumber ?? (index !== undefined ? index + 1 : '')}
</div>
) : artUrl ? (
<img
src={artUrl}
alt=""
@@ -76,6 +128,21 @@ export function TrackRow({
}}
/>
)}
{isActive && (
<div className="cover-playing">
<PlayingIndicator animate={isPlaying} />
</div>
)}
<button
type="button"
className="track-art-play"
onClick={handlePlayNow}
aria-label={t('track.menu.playNow')}
title={t('track.menu.playNow')}
>
<Icon name="play" fill />
</button>
</div>
<div style={{ minWidth: 0 }}>
<div
style={{
@@ -105,8 +172,9 @@ export function TrackRow({
<MetadataStatusBadge
status={track.metadataStatus}
error={track.metadataError}
iconOnly
/>
<AvailabilityBadge availability={track.availability} />
<AvailabilityBadge availability={displayAvailability} iconOnly />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span
+11 -4
View File
@@ -9,16 +9,17 @@ import { TrackRow } from '../../components/track/TrackRow';
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
import { ErrorState } from '../../components/common/ErrorState';
import { EmptyState } from '../../components/common/EmptyState';
import { useAppDispatch } from '../../hooks/useAppDispatch';
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
import { setQueue } from '../../store/slices/queue';
import { formatDuration } from '../../lib/format';
import { getCoverUrl } from '../../api/endpoints/streaming';
import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
export function AlbumDetailPage() {
const { t } = useTranslation();
const { albumId } = useParams<{ albumId: string }>();
const navigate = useNavigate();
const dispatch = useAppDispatch();
const token = useAppSelector((s) => s.auth.accessToken);
const albumQuery = useGetAlbumQuery(albumId ?? '', { skip: !albumId });
const tracksQuery = useGetAlbumTracksQuery(albumId ?? '', { skip: !albumId });
@@ -42,7 +43,13 @@ export function AlbumDetailPage() {
const album = albumQuery.data;
const tracks = tracksQuery.data ?? [];
const artUrl = getCoverUrl(album?.artUrl);
// The album record itself carries no cover; fall back to a track's cover.
const coverTrack = tracks.find((t) => t.hasCover);
const artUrl =
getCoverUrl(album?.artUrl) ??
(token && coverTrack
? getTrackCoverUrl(coverTrack.id, token, true)
: undefined);
const handlePlayAll = () => {
if (!tracks.length || !album) return;
@@ -178,7 +185,7 @@ export function AlbumDetailPage() {
/>
)}
{tracks.map((track, i) => (
<TrackRow key={track.id} track={track} index={i} />
<TrackRow key={track.id} track={track} index={i} hideArt />
))}
</ScrollArea>
</div>
+13 -6
View File
@@ -5,9 +5,9 @@ import {
Tabs,
TabsList,
TabsContent,
SearchField,
ScrollArea,
Card,
TextField,
} from '@olly/modern-sk';
import {
useGetTracksQuery,
@@ -23,6 +23,7 @@ import { setQueue } from '../../store/slices/queue';
import type { Track, Album, Artist } from '../../api/types';
import { getCoverUrl } from '../../api/endpoints/streaming';
import { formatDuration } from '../../lib/format';
import { useDebounce } from 'use-debounce';
export function LibraryPage() {
const { t } = useTranslation();
@@ -30,10 +31,17 @@ export function LibraryPage() {
const dispatch = useAppDispatch();
const [tab, setTab] = useState('tracks');
const [search, setSearch] = useState('');
const [debouncedSearch] = useDebounce(search, 300);
const tracksQuery = useGetTracksQuery(search ? { search } : undefined);
const albumsQuery = useGetAlbumsQuery(search ? { search } : undefined);
const artistsQuery = useGetArtistsQuery(search ? { search } : undefined);
const tracksQuery = useGetTracksQuery(
debouncedSearch ? { search } : undefined,
);
const albumsQuery = useGetAlbumsQuery(
debouncedSearch ? { search } : undefined,
);
const artistsQuery = useGetArtistsQuery(
debouncedSearch ? { search } : undefined,
);
const handlePlayAll = (tracks: Track[]) => {
dispatch(
@@ -68,11 +76,10 @@ export function LibraryPage() {
{t('library.title')}
</h2>
<div style={{ flex: 1, maxWidth: '20rem' }}>
<SearchField
<TextField
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('library.searchPlaceholder')}
icon="⌕"
/>
</div>
</div>
+502 -3
View File
@@ -1,13 +1,512 @@
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Window } from '@olly/modern-sk';
import { Window, Card, Badge } from '@olly/modern-sk';
import { useGetStorageStatsQuery } from '../../api/endpoints/storage';
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
import { EmptyState } from '../../components/common/EmptyState';
import { ErrorState } from '../../components/common/ErrorState';
import { Icon, type IconName } from '../../components/common/Icon';
import {
formatFileSize,
formatCount,
formatLongDuration,
formatDateTime,
} from '../../lib/format';
import type { StorageStats } from '../../api/types';
// modern-sk Badge variants we map metadata-health buckets onto.
const STATUS_VARIANT: Record<string, 'lime' | 'ember' | 'neutral' | 'outline'> =
{
enriched: 'lime',
manual: 'outline',
pending: 'neutral',
failed: 'ember',
};
export function StoragePage() {
const { t } = useTranslation();
const { data, isLoading, isError, refetch } = useGetStorageStatsQuery();
return (
<div style={{ padding: '1.5rem' }}>
<div style={{ padding: '1.5rem', maxWidth: 1100, margin: '0 auto' }}>
<Window title={t('pages.storage')}>
<p style={{ color: 'var(--color-text-2)' }}>{t('common.comingSoon')}</p>
<p style={{ color: 'var(--color-text-2)', marginTop: 0 }}>
{t('storage.subtitle')}
</p>
{isLoading && <LoadingSkeleton rows={6} height={72} />}
{isError && (
<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} />}
</Window>
</div>
);
}
function StorageDashboard({ stats }: { stats: StorageStats }) {
const { t } = useTranslation();
const avgSize = Math.round(stats.totalSize / Math.max(stats.totalTracks, 1));
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
{stats.disk && (
<DiskGauge disk={stats.disk} libraryBytes={stats.totalSize} />
)}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
gap: '0.75rem',
}}
>
<StatTile
icon="vinyl-record"
label={t('storage.tracks')}
value={formatCount(stats.totalTracks)}
/>
<StatTile
icon="vinyl-record"
label={t('storage.artists')}
value={formatCount(stats.totalArtists)}
/>
<StatTile
icon="vinyl-record"
label={t('storage.albums')}
value={formatCount(stats.totalAlbums)}
/>
<StatTile
icon="play"
label={t('storage.playtime')}
value={formatLongDuration(stats.totalDurationSeconds)}
/>
<StatTile
icon="hard-drives"
label={t('storage.footprint')}
value={formatFileSize(stats.totalSize)}
/>
<StatTile
icon="hard-drives"
label={t('storage.avgTrackSize')}
value={formatFileSize(avgSize)}
/>
</div>
{stats.byFormat.length > 0 && <FormatBars stats={stats} />}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
gap: '1rem',
}}
>
<MetadataHealth stats={stats} />
<Sources stats={stats} />
</div>
{stats.topGenres.length > 0 && <TopGenres stats={stats} />}
<FunFacts stats={stats} avgSize={avgSize} />
</div>
);
}
function DiskGauge({
disk,
libraryBytes,
}: {
disk: NonNullable<StorageStats['disk']>;
libraryBytes: number;
}) {
const { t } = useTranslation();
const pct = (n: number) => (disk.total > 0 ? (n / disk.total) * 100 : 0);
// The library is a slice of "used"; the rest of used is everything else on
// the volume. Clamp so a slightly-stale library total never overflows.
const libShare = Math.min(libraryBytes, disk.used);
const otherUsed = Math.max(disk.used - libShare, 0);
const libPercentOfDisk = pct(libraryBytes).toFixed(1);
return (
<Card style={{ padding: '1.25rem' }}>
<SectionTitle icon="hard-drives">{t('storage.disk')}</SectionTitle>
<div
style={{
display: 'flex',
height: 16,
borderRadius: 999,
overflow: 'hidden',
background: 'var(--color-surface-3)',
marginTop: '0.75rem',
}}
>
<div
style={{
width: `${pct(libShare)}%`,
background: 'var(--color-accent)',
transition: 'width 0.5s ease',
}}
/>
<div
style={{
width: `${pct(otherUsed)}%`,
background: 'var(--color-text-3)',
opacity: 0.5,
}}
/>
</div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: '0.5rem',
marginTop: '0.6rem',
fontSize: '0.85rem',
color: 'var(--color-text-2)',
}}
>
<span>
<Dot color="var(--color-accent)" /> {formatFileSize(libraryBytes)}{' '}
{t('storage.footprint').toLowerCase()}
</span>
<span>
{t('storage.diskUsage', {
used: formatFileSize(disk.used),
total: formatFileSize(disk.total),
})}
</span>
<span>{t('storage.diskFree', { free: formatFileSize(disk.free) })}</span>
</div>
<p
style={{
margin: '0.5rem 0 0',
fontSize: '0.8rem',
color: 'var(--color-text-3)',
}}
>
{t('storage.diskLibraryShare', { percent: libPercentOfDisk })}
</p>
</Card>
);
}
function FormatBars({ stats }: { stats: StorageStats }) {
const { t } = useTranslation();
const max = Math.max(...stats.byFormat.map((f) => f.totalSize), 1);
return (
<Card style={{ padding: '1.25rem' }}>
<SectionTitle icon="vinyl-record">{t('storage.formats')}</SectionTitle>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.7rem',
marginTop: '0.75rem',
}}
>
{stats.byFormat.map((f) => (
<div key={f.fileFormat}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: '0.85rem',
marginBottom: '0.25rem',
}}
>
<span
style={{
textTransform: 'uppercase',
letterSpacing: '0.04em',
color: 'var(--color-text-1)',
}}
>
{f.fileFormat}
</span>
<span style={{ color: 'var(--color-text-2)' }}>
{formatCount(f.trackCount)} · {formatFileSize(f.totalSize)}
</span>
</div>
<div
style={{
height: 8,
borderRadius: 999,
background: 'var(--color-surface-3)',
overflow: 'hidden',
}}
>
<div
style={{
width: `${(f.totalSize / max) * 100}%`,
height: '100%',
background: 'var(--color-accent)',
transition: 'width 0.5s ease',
}}
/>
</div>
</div>
))}
</div>
</Card>
);
}
function MetadataHealth({ stats }: { stats: StorageStats }) {
const { t } = useTranslation();
const entries = Object.entries(stats.byMetadataStatus).filter(
([, n]) => n > 0,
);
return (
<Card style={{ padding: '1.25rem' }}>
<SectionTitle icon="check-circle">
{t('storage.metadataHealth')}
</SectionTitle>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
marginTop: '0.75rem',
}}
>
{entries.map(([status, count]) => (
<Badge key={status} variant={STATUS_VARIANT[status] ?? 'neutral'}>
{t(`storage.status.${status}`, { defaultValue: status })} ·{' '}
{formatCount(count)}
</Badge>
))}
</div>
</Card>
);
}
function Sources({ stats }: { stats: StorageStats }) {
const { t } = useTranslation();
const entries = Object.entries(stats.bySource).sort((a, b) => b[1] - a[1]);
return (
<Card style={{ padding: '1.25rem' }}>
<SectionTitle icon="arrow-circle-down">
{t('storage.sources')}
</SectionTitle>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.4rem',
marginTop: '0.75rem',
}}
>
{entries.map(([source, count]) => (
<div
key={source}
style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: '0.9rem',
textTransform: 'capitalize',
}}
>
<span style={{ color: 'var(--color-text-1)' }}>{source}</span>
<span style={{ color: 'var(--color-text-2)' }}>
{formatCount(count)}
</span>
</div>
))}
</div>
</Card>
);
}
function TopGenres({ stats }: { stats: StorageStats }) {
const { t } = useTranslation();
const max = Math.max(...stats.topGenres.map((g) => g.trackCount), 1);
return (
<Card style={{ padding: '1.25rem' }}>
<SectionTitle icon="vinyl-record">{t('storage.topGenres')}</SectionTitle>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
marginTop: '0.75rem',
}}
>
{stats.topGenres.map((g) => {
// Scale chip emphasis by popularity for a tag-cloud feel.
const weight = 0.55 + (g.trackCount / max) * 0.45;
return (
<span
key={g.genre}
style={{
padding: '0.3rem 0.7rem',
borderRadius: 999,
border: '1px solid var(--color-border)',
background: `color-mix(in srgb, var(--color-accent) ${Math.round(
weight * 18,
)}%, transparent)`,
fontSize: '0.85rem',
color: 'var(--color-text-1)',
}}
>
{g.genre}{' '}
<span style={{ color: 'var(--color-text-3)' }}>
{formatCount(g.trackCount)}
</span>
</span>
);
})}
</div>
</Card>
);
}
function FunFacts({
stats,
avgSize,
}: {
stats: StorageStats;
avgSize: number;
}) {
const { t } = useTranslation();
const facts: string[] = [];
if (stats.totalDurationSeconds > 0)
facts.push(
t('storage.factPlaytime', {
duration: formatLongDuration(stats.totalDurationSeconds),
}),
);
facts.push(
t('storage.factFootprint', {
size: formatFileSize(stats.totalSize),
tracks: formatCount(stats.totalTracks),
}),
);
if (stats.topGenres[0])
facts.push(
t('storage.factGenre', {
genre: stats.topGenres[0].genre,
count: stats.topGenres[0].trackCount,
}),
);
facts.push(t('storage.factAvg', { size: formatFileSize(avgSize) }));
const since = formatDateTime(stats.earliestAdded);
if (since) facts.push(t('storage.factSince', { date: since }));
return (
<Card style={{ padding: '1.25rem' }}>
<SectionTitle icon="info">{t('storage.funFacts')}</SectionTitle>
<ul
style={{
margin: '0.75rem 0 0',
paddingLeft: '1.1rem',
display: 'flex',
flexDirection: 'column',
gap: '0.4rem',
color: 'var(--color-text-2)',
fontSize: '0.9rem',
}}
>
{facts.map((f) => (
<li key={f}>{f}</li>
))}
</ul>
</Card>
);
}
// -- small shared bits --------------------------------------------------------
function StatTile({
icon,
label,
value,
}: {
icon: IconName;
label: string;
value: string;
}) {
return (
<Card
style={{
padding: '1rem',
display: 'flex',
flexDirection: 'column',
gap: '0.35rem',
}}
>
<span
style={{
color: 'var(--color-accent)',
fontSize: '1.1rem',
opacity: 0.9,
}}
>
<Icon name={icon} />
</span>
<span
style={{
fontSize: '1.5rem',
fontWeight: 600,
color: 'var(--color-text-1)',
lineHeight: 1.1,
}}
>
{value}
</span>
<span style={{ fontSize: '0.8rem', color: 'var(--color-text-2)' }}>
{label}
</span>
</Card>
);
}
function SectionTitle({
icon,
children,
}: {
icon: IconName;
children: ReactNode;
}) {
return (
<h3
style={{
margin: 0,
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
fontSize: '0.95rem',
fontWeight: 600,
color: 'var(--color-text-1)',
}}
>
<span style={{ color: 'var(--color-accent)' }}>
<Icon name={icon} />
</span>
{children}
</h3>
);
}
function Dot({ color }: { color: string }) {
return (
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: 999,
background: color,
marginRight: 2,
}}
/>
);
}
+9
View File
@@ -27,6 +27,10 @@ export function useAudioPlayer() {
const queue = useAppSelector((s) => s.queue);
const accessToken = useAppSelector((s) => s.auth.accessToken);
const isSetup = useRef(false);
// `ended` is registered once below; read the latest loop flag through a ref
// so the listener doesn't need to be re-bound on every queue change.
const loopRef = useRef(queue.loop);
loopRef.current = queue.loop;
useEffect(() => {
if (isSetup.current) return;
@@ -41,7 +45,12 @@ export function useAudioPlayer() {
dispatch(setDuration(audio.duration || 0));
});
audio.addEventListener('ended', () => {
if (loopRef.current) {
audio.currentTime = 0;
void audio.play();
} else {
dispatch(nextTrack());
}
});
audio.addEventListener('pause', () => {
dispatch(pause());
+29 -1
View File
@@ -1,7 +1,12 @@
import { useState, useEffect } from 'react';
import { getApiBaseUrl } from '../config/runtime-config';
import { useAppDispatch, useAppSelector } from './useAppDispatch';
import {
setConnectionStatus,
type ConnectionStatus,
} from '../store/slices/connection';
type ConnectionStatus = 'connected' | 'connecting' | 'disconnected' | 'error';
export type { ConnectionStatus };
/** Pings `${baseUrl}/health` (defaults to the active instance's base URL). */
export function useConnectionStatus(baseUrl?: string) {
@@ -36,3 +41,26 @@ export function useConnectionStatus(baseUrl?: string) {
return status;
}
/**
* Like `useConnectionStatus`, but also mirrors the result into the
* `connection` slice so other components can read the active instance's
* reachability via `useIsOffline` without running their own poller.
* Mount once (in `Sidebar`, which lives for the app's whole lifetime).
*/
export function useConnectionStatusSync(): ConnectionStatus {
const status = useConnectionStatus();
const dispatch = useAppDispatch();
useEffect(() => {
dispatch(setConnectionStatus(status));
}, [status, dispatch]);
return status;
}
/** Whether the active backend instance is currently unreachable. */
export function useIsOffline(): boolean {
const status = useAppSelector((s) => s.connection.status);
return status === 'disconnected' || status === 'error';
}
+4
View File
@@ -9,6 +9,8 @@ export interface ResolvedQueueEntry {
albumTitle: string;
durationMs: number;
hasCover: boolean;
albumArtUrl?: string;
format?: string;
}
/**
@@ -33,5 +35,7 @@ export function useResolvedQueueEntry(
albumTitle: data?.albumTitle ?? entry.albumTitle,
durationMs: data?.durationMs ?? entry.durationMs,
hasCover: data?.hasCover ?? false,
albumArtUrl: data?.albumArtUrl ?? entry.albumArtUrl,
format: data?.format,
};
}
+47 -6
View File
@@ -120,27 +120,25 @@ const en = {
},
player: {
nothingPlaying: 'Nothing playing',
shuffle: 'Shuffle',
previous: 'Previous',
next: 'Next',
pause: 'Pause',
play: 'Play',
repeat: 'Repeat: {{mode}}',
streaming: 'Streaming · 320 kbps',
local: 'Local · FLAC',
streaming: 'Streaming',
local: 'Local',
queue: 'Play queue',
mute: 'Mute',
unmute: 'Unmute',
},
queue: {
title: 'Play queue',
shuffle: 'Shuffle queue',
loop: 'Repeat current track',
clear: 'Clear queue',
close: 'Close',
from: 'From {{source}}',
radio: 'Radio · {{source}}',
nowPlaying: 'Now playing',
nextUp: 'Next up',
nothingNext: 'Nothing queued next',
empty: 'Queue is empty',
radioActive: 'Radio active',
mixing: '∞ mixing',
@@ -149,6 +147,13 @@ const en = {
loadingMore: 'Loading more from radio…',
doubleClickPlay: 'Double-click to play',
removeFromQueue: 'Remove from queue',
menu: {
options: 'Track options',
playNow: 'Play now',
moveNext: 'Move next',
info: 'Track info',
remove: 'Remove from queue',
},
},
track: {
menu: {
@@ -205,6 +210,42 @@ const en = {
comingSoon: 'Coming soon',
back: 'Back',
},
storage: {
subtitle: 'Everything this instance has tucked away',
emptyTitle: 'Nothing stored yet',
emptyDesc:
'Download or upload some music and your library stats will appear here.',
disk: 'Disk',
diskUsage: '{{used}} of {{total}} used',
diskFree: '{{free}} free',
diskLibraryShare: 'This library is {{percent}}% of the whole disk',
diskUnknown: 'Object storage — no fixed disk to report',
footprint: 'Library footprint',
tracks: 'Tracks',
artists: 'Artists',
albums: 'Albums',
playtime: 'Total playtime',
avgTrackSize: 'Avg. track size',
largestTrack: 'Largest track',
formats: 'Formats',
sources: 'Where it came from',
metadataHealth: 'Metadata health',
topGenres: 'Top genres',
noGenres: 'No genres tagged yet',
funFacts: 'Fun facts',
factPlaytime:
'Hit play and walk away — this library runs for {{duration}} non-stop.',
factFootprint: '{{size}} of music across {{tracks}} tracks.',
factGenre: 'Your most-tagged genre is {{genre}} ({{count}} tracks).',
factAvg: 'The average track weighs in at {{size}}.',
factSince: 'Collecting since {{date}}.',
status: {
enriched: 'Enriched',
manual: 'Manual',
pending: 'Pending',
failed: 'Failed',
},
},
pages: {
admin: 'Admin',
settings: 'Settings',
+47 -6
View File
@@ -122,27 +122,25 @@ const ru: Translations = {
},
player: {
nothingPlaying: 'Ничего не играет',
shuffle: 'Перемешать',
previous: 'Назад',
next: 'Вперёд',
pause: 'Пауза',
play: 'Воспроизвести',
repeat: 'Повтор: {{mode}}',
streaming: 'Стриминг · 320 kbps',
local: 'Локально · FLAC',
streaming: 'Стриминг',
local: 'Локально',
queue: 'Очередь',
mute: 'Выключить звук',
unmute: 'Включить звук',
},
queue: {
title: 'Очередь воспроизведения',
shuffle: 'Перемешать очередь',
loop: 'Повторять текущий трек',
clear: 'Очистить очередь',
close: 'Закрыть',
from: 'Из: {{source}}',
radio: 'Радио · {{source}}',
nowPlaying: 'Сейчас играет',
nextUp: 'Далее',
nothingNext: 'Очередь пуста',
empty: 'Очередь пуста',
radioActive: 'Радио активно',
mixing: '∞ микс',
@@ -151,6 +149,13 @@ const ru: Translations = {
loadingMore: 'Загрузка радио…',
doubleClickPlay: 'Двойной клик для воспроизведения',
removeFromQueue: 'Убрать из очереди',
menu: {
options: 'Параметры трека',
playNow: 'Воспроизвести сейчас',
moveNext: 'Сделать следующим',
info: 'Информация о треке',
remove: 'Убрать из очереди',
},
},
track: {
menu: {
@@ -207,6 +212,42 @@ const ru: Translations = {
comingSoon: 'Скоро',
back: 'Назад',
},
storage: {
subtitle: 'Всё, что хранит этот инстанс',
emptyTitle: 'Пока ничего не сохранено',
emptyDesc:
'Загрузите немного музыки — и здесь появится статистика вашей библиотеки.',
disk: 'Диск',
diskUsage: 'Занято {{used}} из {{total}}',
diskFree: 'Свободно {{free}}',
diskLibraryShare: 'Библиотека занимает {{percent}}% всего диска',
diskUnknown: 'Объектное хранилище — фиксированного диска нет',
footprint: 'Объём библиотеки',
tracks: 'Треки',
artists: 'Исполнители',
albums: 'Альбомы',
playtime: 'Общая длительность',
avgTrackSize: 'Средний размер трека',
largestTrack: 'Самый большой трек',
formats: 'Форматы',
sources: 'Откуда взято',
metadataHealth: 'Состояние метаданных',
topGenres: 'Топ жанров',
noGenres: 'Жанры пока не указаны',
funFacts: 'Интересные факты',
factPlaytime:
'Нажмите play и уходите — библиотека играет {{duration}} без остановки.',
factFootprint: '{{size}} музыки в {{tracks}} треках.',
factGenre: 'Чаще всего встречается жанр {{genre}} ({{count}} треков).',
factAvg: 'Средний трек весит {{size}}.',
factSince: 'Коллекция собирается с {{date}}.',
status: {
enriched: 'Обогащено',
manual: 'Вручную',
pending: 'В ожидании',
failed: 'Ошибка',
},
},
pages: {
admin: 'Администрирование',
settings: 'Настройки',
+14
View File
@@ -26,6 +26,20 @@ export function formatDateTime(iso: string | undefined): string | undefined {
}).format(d);
}
/** Human "X days Y hours" style for big spans (e.g. total library playtime).
* Shows the two most-significant non-zero units; falls back to "0m". */
export function formatLongDuration(seconds: number): string {
if (seconds <= 0) return '0m';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const parts: string[] = [];
if (days) parts.push(`${days}d`);
if (hours) parts.push(`${hours}h`);
if (mins && parts.length < 2) parts.push(`${mins}m`);
return parts.slice(0, 2).join(' ') || '0m';
}
export function formatCount(n: number): string {
if (n < 1000) return String(n);
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;
+2
View File
@@ -1,6 +1,7 @@
import { configureStore } from '@reduxjs/toolkit';
import { api } from '../api';
import authReducer from './slices/auth';
import connectionReducer from './slices/connection';
import playerReducer from './slices/player';
import queueReducer from './slices/queue';
import uiReducer from './slices/ui';
@@ -11,6 +12,7 @@ export const store = configureStore({
reducer: {
[api.reducerPath]: api.reducer,
auth: authReducer,
connection: connectionReducer,
player: playerReducer,
queue: queueReducer,
ui: uiReducer,
+12 -12
View File
@@ -8,14 +8,8 @@
* Server data (library/albums/…) is Tier 2 (see `rtkqPersist.ts`).
*/
import { instanceStorage } from '../config/instances';
import {
queueInitialState,
type QueueState,
} from './slices/queue';
import {
playerInitialState,
type PlayerState,
} from './slices/player';
import { queueInitialState, type QueueState } from './slices/queue';
import { playerInitialState, type PlayerState } from './slices/player';
import type { RootState } from './index';
const QUEUE_KEY = 'queue';
@@ -26,11 +20,17 @@ const PLAYER_KEY = 'player';
// transient UI, so they are intentionally left out.
type PersistedQueue = Pick<
QueueState,
'entries' | 'currentIndex' | 'source' | 'sourceId' | 'sourceName'
| 'entries'
| 'currentIndex'
| 'source'
| 'sourceId'
| 'sourceName'
| 'shuffle'
| 'loop'
>;
type PersistedPlayer = Pick<
PlayerState,
'currentTrackId' | 'position' | 'volume' | 'muted' | 'repeat' | 'shuffle'
'currentTrackId' | 'position' | 'volume' | 'muted'
>;
function pickQueue(state: QueueState): PersistedQueue {
@@ -40,6 +40,8 @@ function pickQueue(state: QueueState): PersistedQueue {
source: state.source,
sourceId: state.sourceId,
sourceName: state.sourceName,
shuffle: state.shuffle,
loop: state.loop,
};
}
@@ -49,8 +51,6 @@ function pickPlayer(state: PlayerState): PersistedPlayer {
position: state.position,
volume: state.volume,
muted: state.muted,
repeat: state.repeat,
shuffle: state.shuffle,
};
}
+28
View File
@@ -0,0 +1,28 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
export type ConnectionStatus =
| 'connected'
| 'connecting'
| 'disconnected'
| 'error';
export interface ConnectionState {
status: ConnectionStatus;
}
export const connectionInitialState: ConnectionState = {
status: 'connecting',
};
export const connectionSlice = createSlice({
name: 'connection',
initialState: connectionInitialState,
reducers: {
setConnectionStatus(state, action: PayloadAction<ConnectionStatus>) {
state.status = action.payload;
},
},
});
export const { setConnectionStatus } = connectionSlice.actions;
export default connectionSlice.reducer;
-14
View File
@@ -1,7 +1,5 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
export type RepeatMode = 'none' | 'one' | 'all';
export interface PlayerState {
currentTrackId: string | null;
isPlaying: boolean;
@@ -9,8 +7,6 @@ export interface PlayerState {
duration: number;
volume: number;
muted: boolean;
repeat: RepeatMode;
shuffle: boolean;
isQueueOpen: boolean;
}
@@ -21,8 +17,6 @@ export const playerInitialState: PlayerState = {
duration: 0,
volume: 0.78,
muted: false,
repeat: 'none',
shuffle: false,
isQueueOpen: false,
};
@@ -58,12 +52,6 @@ export const playerSlice = createSlice({
toggleMute(state) {
state.muted = !state.muted;
},
setRepeat(state, action: PayloadAction<RepeatMode>) {
state.repeat = action.payload;
},
toggleShuffle(state) {
state.shuffle = !state.shuffle;
},
toggleQueue(state) {
state.isQueueOpen = !state.isQueueOpen;
},
@@ -79,8 +67,6 @@ export const {
setDuration,
setVolume,
toggleMute,
setRepeat,
toggleShuffle,
toggleQueue,
} = playerSlice.actions;
export default playerSlice.reducer;
+27 -1
View File
@@ -23,6 +23,8 @@ export interface QueueState {
source: QueueSource;
sourceId: string | null;
sourceName: string | null;
shuffle: boolean;
loop: boolean;
}
export const queueInitialState: QueueState = {
@@ -31,6 +33,8 @@ export const queueInitialState: QueueState = {
source: 'manual',
sourceId: null,
sourceName: null,
shuffle: false,
loop: false,
};
export const queueSlice = createSlice({
@@ -59,6 +63,11 @@ export const queueSlice = createSlice({
addNextInQueue(state, action: PayloadAction<QueueEntry>) {
state.entries.splice(state.currentIndex + 1, 0, action.payload);
},
playNow(state, action: PayloadAction<QueueEntry>) {
const insertAt = state.currentIndex + 1;
state.entries.splice(insertAt, 0, action.payload);
state.currentIndex = insertAt;
},
removeFromQueue(state, action: PayloadAction<number>) {
state.entries.splice(action.payload, 1);
if (action.payload < state.currentIndex) state.currentIndex--;
@@ -77,7 +86,15 @@ export const queueSlice = createSlice({
state.currentIndex = action.payload;
},
nextTrack(state) {
if (state.currentIndex < state.entries.length - 1) state.currentIndex++;
if (state.shuffle && state.entries.length > 1) {
let next = state.currentIndex;
while (next === state.currentIndex) {
next = Math.floor(Math.random() * state.entries.length);
}
state.currentIndex = next;
} else if (state.currentIndex < state.entries.length - 1) {
state.currentIndex++;
}
},
prevTrack(state) {
if (state.currentIndex > 0) state.currentIndex--;
@@ -86,6 +103,12 @@ export const queueSlice = createSlice({
state.entries = [];
state.currentIndex = -1;
},
toggleShuffle(state) {
state.shuffle = !state.shuffle;
},
toggleLoop(state) {
state.loop = !state.loop;
},
},
});
@@ -93,11 +116,14 @@ export const {
setQueue,
addToQueue,
addNextInQueue,
playNow,
removeFromQueue,
moveInQueue,
goToIndex,
nextTrack,
prevTrack,
clearQueue,
toggleShuffle,
toggleLoop,
} = queueSlice.actions;
export default queueSlice.reducer;
+124 -30
View File
@@ -404,6 +404,55 @@
font-size: 10px;
}
/* ---- playing indicator ("hopping bars" equalizer, YTM-style) ---- */
.playing-bars {
display: inline-flex;
align-items: flex-end;
justify-content: center;
gap: 2px;
width: 14px;
height: 14px;
flex-shrink: 0;
}
.playing-bars span {
display: block;
width: 3px;
background: var(--lime);
border-radius: 1px;
height: 30%;
animation: playing-bar-bounce 1s ease-in-out infinite;
}
.playing-bars span:nth-child(1) {
animation-delay: -0.9s;
}
.playing-bars span:nth-child(2) {
animation-delay: -0.3s;
}
.playing-bars span:nth-child(3) {
animation-delay: -0.6s;
}
.playing-bars.paused span {
animation-play-state: paused;
height: 100%;
}
.spin {
animation: spin 1.2s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes playing-bar-bounce {
0%,
100% {
height: 30%;
}
50% {
height: 100%;
}
}
/* ============================================================
PLAYER BAR
============================================================ */
@@ -604,36 +653,6 @@
overflow-y: auto;
padding: 12px 12px 18px;
}
.qd-now {
display: flex;
gap: 11px;
align-items: center;
padding: 10px;
border-radius: var(--r-md);
background: linear-gradient(
180deg,
rgba(190, 242, 100, 0.13),
rgba(190, 242, 100, 0.05)
);
border: 1px solid rgba(190, 242, 100, 0.2);
margin-bottom: 14px;
}
.qd-now .qt {
min-width: 0;
flex: 1;
}
.qd-now .qt .t {
font-size: 13px;
font-weight: 600;
color: var(--fg-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.qd-now .qt .r {
font-size: 11px;
color: var(--fg-3);
}
.qrow {
display: flex;
gap: 11px;
@@ -649,6 +668,24 @@
.qrow:hover {
background: rgba(255, 255, 255, 0.04);
}
.qrow.current {
background: linear-gradient(
180deg,
rgba(190, 242, 100, 0.13),
rgba(190, 242, 100, 0.05)
);
box-shadow: 0 0 0 1px rgba(190, 242, 100, 0.35) inset;
}
.qrow.dragging {
z-index: 1;
cursor: grabbing;
background: rgba(255, 255, 255, 0.06);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}
.qrow.current .qt .t {
color: var(--lime);
font-weight: 600;
}
.qrow .grip {
color: var(--fg-3);
font-size: 15px;
@@ -922,3 +959,60 @@
.sb-sec-link.active {
color: var(--fg-1);
}
/* ============================================================
TRACK ROW — cover art play overlay
============================================================ */
.track-art {
position: relative;
width: 36px;
height: 36px;
flex-shrink: 0;
border-radius: 4px;
overflow: hidden;
}
.track-art-play {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 4px;
background: rgba(0, 0, 0, 0.5);
color: var(--fg-1);
font-size: 16px;
cursor: pointer;
opacity: 0;
transition: opacity var(--dur-quick);
}
.track-art:hover .track-art-play {
opacity: 1;
}
.track-art-play:hover {
color: var(--lime);
}
/* Now-playing overlay shown on a cover when its track is the active one
(track lists and the queue panel both use this). */
.cover-playing {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
}
.track-art:hover .cover-playing {
opacity: 0;
}
/* Queue row cover-art wrapper, sized to match the 36px ArtTile */
.qart {
position: relative;
width: 36px;
height: 36px;
flex-shrink: 0;
border-radius: 6px;
overflow: hidden;
}