Compare commits
11 Commits
d1b2b40ffd
...
808c52484c
| Author | SHA1 | Date | |
|---|---|---|---|
| 808c52484c | |||
| 44c8d1870f | |||
| a8e060d1a8 | |||
| 8ae447e08d | |||
| df8c67b368 | |||
| f5767ff55e | |||
| 3984c7a499 | |||
| b37fabd936 | |||
| 9c70b8a11f | |||
| 5c8f89675d | |||
| df2531171e |
Generated
+70
-1
@@ -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
@@ -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",
|
||||
|
||||
@@ -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>({
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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: 'Настройки',
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user