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",
|
"name": "mcma-webui",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"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",
|
"@olly/modern-sk": "^0.1.5",
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@reduxjs/toolkit": "^2.12.0",
|
"@reduxjs/toolkit": "^2.12.0",
|
||||||
@@ -16,7 +19,8 @@
|
|||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-i18next": "^17.0.8",
|
"react-i18next": "^17.0.8",
|
||||||
"react-redux": "^9.3.0",
|
"react-redux": "^9.3.0",
|
||||||
"react-router": "^7.16.0"
|
"react-router": "^7.16.0",
|
||||||
|
"use-debounce": "^10.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rsbuild/core": "^2.0.7",
|
"@rsbuild/core": "^2.0.7",
|
||||||
@@ -551,6 +555,59 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
"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": {
|
"node_modules/use-sidecar": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
||||||
|
|||||||
+5
-1
@@ -13,6 +13,9 @@
|
|||||||
"test:watch": "rstest --watch"
|
"test:watch": "rstest --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"@olly/modern-sk": "^0.1.5",
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@reduxjs/toolkit": "^2.12.0",
|
"@reduxjs/toolkit": "^2.12.0",
|
||||||
@@ -21,7 +24,8 @@
|
|||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-i18next": "^17.0.8",
|
"react-i18next": "^17.0.8",
|
||||||
"react-redux": "^9.3.0",
|
"react-redux": "^9.3.0",
|
||||||
"react-router": "^7.16.0"
|
"react-router": "^7.16.0",
|
||||||
|
"use-debounce": "^10.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rsbuild/core": "^2.0.7",
|
"@rsbuild/core": "^2.0.7",
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import { api } from '../index';
|
import { api } from '../index';
|
||||||
|
import { toStorageStats, type RawStorageStats } from '../mappers';
|
||||||
import type { StorageStats } from '../types';
|
import type { StorageStats } from '../types';
|
||||||
|
|
||||||
// NOTE: the backend `/storage` routes are still unimplemented stubs (no body /
|
// `GET /storage` returns library + disk statistics (§A6). The maintenance
|
||||||
// no schema), and the real paths differ from these placeholders (`GET /storage`,
|
// routes (`/storage/duplicates`, `/storage/broken`, `/storage/missing-metadata`,
|
||||||
// `/storage/duplicates`, `/storage/broken`, `/storage/missing-metadata`,
|
// `POST /storage/cleanup`) are still backend stubs and unused by the UI.
|
||||||
// `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.
|
|
||||||
|
|
||||||
export const storageApi = api.injectEndpoints({
|
export const storageApi = api.injectEndpoints({
|
||||||
endpoints: (build) => ({
|
endpoints: (build) => ({
|
||||||
getStorageStats: build.query<StorageStats, void>({
|
getStorageStats: build.query<StorageStats, void>({
|
||||||
query: () => '/storage/stats',
|
query: () => '/storage',
|
||||||
|
transformResponse: (raw: RawStorageStats) => toStorageStats(raw),
|
||||||
providesTags: ['Storage'],
|
providesTags: ['Storage'],
|
||||||
}),
|
}),
|
||||||
scanStorage: build.mutation<{ jobId: string }, void>({
|
scanStorage: build.mutation<{ jobId: string }, void>({
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type {
|
|||||||
MetadataStatus,
|
MetadataStatus,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
Playlist,
|
Playlist,
|
||||||
|
StorageStats,
|
||||||
Track,
|
Track,
|
||||||
User,
|
User,
|
||||||
} from './types';
|
} from './types';
|
||||||
@@ -200,6 +201,47 @@ export const toPlaylist = (r: RawPlaylist): Playlist => ({
|
|||||||
updatedAt: r.created_at,
|
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
|
* Translate the backend's `{items,total,limit,offset}` envelope into the UI's
|
||||||
* `{items,total,page,pageSize,hasMore}`, mapping each element.
|
* `{items,total,page,pageSize,hasMore}`, mapping each element.
|
||||||
|
|||||||
+33
-5
@@ -96,12 +96,40 @@ export interface UploadResponse {
|
|||||||
already_exists: boolean;
|
already_exists: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StorageStats {
|
export interface StorageFormatBreakdown {
|
||||||
totalBytes: number;
|
fileFormat: string;
|
||||||
usedBytes: number;
|
|
||||||
trackCount: number;
|
trackCount: number;
|
||||||
albumCount: number;
|
totalSize: number;
|
||||||
artistCount: 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 {
|
export interface User {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
ArrowsClockwise,
|
ArrowsClockwise,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Cloud,
|
Cloud,
|
||||||
|
CloudSlash,
|
||||||
DotsSixVertical,
|
DotsSixVertical,
|
||||||
GearSix,
|
GearSix,
|
||||||
HardDrives,
|
HardDrives,
|
||||||
@@ -75,6 +76,7 @@ const ICONS = {
|
|||||||
'speaker-high': SpeakerHigh,
|
'speaker-high': SpeakerHigh,
|
||||||
'speaker-x': SpeakerSimpleX,
|
'speaker-x': SpeakerSimpleX,
|
||||||
cloud: Cloud,
|
cloud: Cloud,
|
||||||
|
'cloud-slash': CloudSlash,
|
||||||
'check-circle': CheckCircle,
|
'check-circle': CheckCircle,
|
||||||
'warning-circle': WarningCircle,
|
'warning-circle': WarningCircle,
|
||||||
'sign-out': SignOut,
|
'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 { Icon, type IconName } from '../common/Icon';
|
||||||
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
||||||
import { usePermissions, type Permission } from '../../hooks/usePermissions';
|
import { usePermissions, type Permission } from '../../hooks/usePermissions';
|
||||||
import { useConnectionStatus } from '../../hooks/useConnectionStatus';
|
import { useConnectionStatusSync } from '../../hooks/useConnectionStatus';
|
||||||
import { logout } from '../../store/slices/auth';
|
import { logout } from '../../store/slices/auth';
|
||||||
import { useGetPlaylistsQuery } from '../../api/endpoints/playlists';
|
import { useGetPlaylistsQuery } from '../../api/endpoints/playlists';
|
||||||
import { getActiveInstance } from '../../config/instances';
|
import { getActiveInstance } from '../../config/instances';
|
||||||
@@ -41,7 +41,7 @@ export function Sidebar() {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, isAdmin, hasPermission } = usePermissions();
|
const { user, isAdmin, hasPermission } = usePermissions();
|
||||||
const status = useConnectionStatus();
|
const status = useConnectionStatusSync();
|
||||||
const { data: playlists } = useGetPlaylistsQuery();
|
const { data: playlists } = useGetPlaylistsQuery();
|
||||||
const instance = getActiveInstance();
|
const instance = getActiveInstance();
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import {
|
|||||||
resume,
|
resume,
|
||||||
toggleMute,
|
toggleMute,
|
||||||
setVolume,
|
setVolume,
|
||||||
toggleShuffle,
|
|
||||||
setRepeat,
|
|
||||||
toggleQueue,
|
toggleQueue,
|
||||||
} from '../../store/slices/player';
|
} from '../../store/slices/player';
|
||||||
import { openTrackInfo } from '../../store/slices/ui';
|
import { openTrackInfo } from '../../store/slices/ui';
|
||||||
@@ -44,6 +42,7 @@ export function PersistentPlayer() {
|
|||||||
: undefined);
|
: undefined);
|
||||||
const seedLabel = current?.albumTitle ?? current?.title ?? '';
|
const seedLabel = current?.albumTitle ?? current?.title ?? '';
|
||||||
const onStream = !cached;
|
const onStream = !cached;
|
||||||
|
const formatLabel = current?.format?.toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="player">
|
<div className="player">
|
||||||
@@ -65,20 +64,13 @@ export function PersistentPlayer() {
|
|||||||
>
|
>
|
||||||
<Icon name={onStream ? 'cloud' : 'check-circle'} fill={!onStream} />
|
<Icon name={onStream ? 'cloud' : 'check-circle'} fill={!onStream} />
|
||||||
{onStream ? t('player.streaming') : t('player.local')}
|
{onStream ? t('player.streaming') : t('player.local')}
|
||||||
|
{formatLabel && ` · ${formatLabel}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pl-center">
|
<div className="pl-center">
|
||||||
<div className="pl-transport">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="pl-tbtn"
|
className="pl-tbtn"
|
||||||
@@ -105,24 +97,6 @@ export function PersistentPlayer() {
|
|||||||
>
|
>
|
||||||
<Icon name="skip-forward" fill />
|
<Icon name="skip-forward" fill />
|
||||||
</button>
|
</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>
|
||||||
<div className="pl-seek">
|
<div className="pl-seek">
|
||||||
<span className="pl-time">
|
<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 { 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 { Icon } from '../common/Icon';
|
||||||
import { ArtTile } from '../common/ArtTile';
|
import { ArtTile } from '../common/ArtTile';
|
||||||
|
import { PlayingIndicator } from '../common/PlayingIndicator';
|
||||||
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||||
import {
|
import {
|
||||||
goToIndex,
|
goToIndex,
|
||||||
removeFromQueue,
|
removeFromQueue,
|
||||||
|
moveInQueue,
|
||||||
clearQueue,
|
clearQueue,
|
||||||
|
toggleShuffle,
|
||||||
|
toggleLoop,
|
||||||
type QueueEntry,
|
type QueueEntry,
|
||||||
} from '../../store/slices/queue';
|
} from '../../store/slices/queue';
|
||||||
import { toggleQueue } from '../../store/slices/player';
|
import { toggleQueue } from '../../store/slices/player';
|
||||||
import { openTrackInfo } from '../../store/slices/ui';
|
import { openTrackInfo } from '../../store/slices/ui';
|
||||||
import { useResolvedQueueEntry } from '../../hooks/useResolvedQueueEntry';
|
import { useResolvedQueueEntry } from '../../hooks/useResolvedQueueEntry';
|
||||||
|
import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
|
||||||
|
|
||||||
export function QueuePanel() {
|
export function QueuePanel() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const queue = useAppSelector((s) => s.queue);
|
const queue = useAppSelector((s) => s.queue);
|
||||||
|
const isPlaying = useAppSelector((s) => s.player.isPlaying);
|
||||||
const isOpen = useAppSelector((s) => s.player.isQueueOpen);
|
const isOpen = useAppSelector((s) => s.player.isQueueOpen);
|
||||||
|
|
||||||
const nowEntry =
|
const hasEntries = queue.entries.length > 0;
|
||||||
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 isRadio = queue.source === 'radio';
|
const isRadio = queue.source === 'radio';
|
||||||
const sourceLabel = queue.sourceName ?? queue.source;
|
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 (
|
return (
|
||||||
<aside className={`qd${isOpen ? '' : ' closed'}`} aria-hidden={!isOpen}>
|
<aside className={`qd${isOpen ? '' : ' closed'}`} aria-hidden={!isOpen}>
|
||||||
<div className="qd-inner">
|
<div className="qd-inner">
|
||||||
@@ -35,6 +73,22 @@ export function QueuePanel() {
|
|||||||
<div className="row">
|
<div className="row">
|
||||||
<h3>{t('queue.title')}</h3>
|
<h3>{t('queue.title')}</h3>
|
||||||
<div style={{ flex: 1 }} />
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="iconbtn sm"
|
className="iconbtn sm"
|
||||||
@@ -68,34 +122,8 @@ export function QueuePanel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="qd-scroll">
|
<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 && (
|
{isRadio && (
|
||||||
<div className="qd-radio">
|
<div className="qd-radio">
|
||||||
<div className="row">
|
<div className="row">
|
||||||
@@ -133,18 +161,36 @@ export function QueuePanel() {
|
|||||||
>
|
>
|
||||||
{t('queue.nextUp')}
|
{t('queue.nextUp')}
|
||||||
</span>
|
</span>
|
||||||
{upNext.length === 0 ? (
|
<DndContext
|
||||||
<div className="qd-empty">{t('queue.nothingNext')}</div>
|
sensors={sensors}
|
||||||
) : (
|
collisionDetection={closestCenter}
|
||||||
upNext.map(({ entry, index }) => (
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={queue.entries.map((_, index) => String(index))}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
{queue.entries.map((entry, index) => (
|
||||||
<QueueRow
|
<QueueRow
|
||||||
key={`${entry.trackId}-${index}`}
|
key={`${entry.trackId}-${index}`}
|
||||||
|
id={String(index)}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
|
isCurrent={index === queue.currentIndex}
|
||||||
|
isPlaying={isPlaying}
|
||||||
onPlay={() => dispatch(goToIndex(index))}
|
onPlay={() => dispatch(goToIndex(index))}
|
||||||
|
onMoveNext={() =>
|
||||||
|
dispatch(
|
||||||
|
moveInQueue({
|
||||||
|
from: index,
|
||||||
|
to: queue.currentIndex + 1,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
onRemove={() => dispatch(removeFromQueue(index))}
|
onRemove={() => dispatch(removeFromQueue(index))}
|
||||||
/>
|
/>
|
||||||
))
|
))}
|
||||||
)}
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
{isRadio && (
|
{isRadio && (
|
||||||
<div className="qd-loadmore">{t('queue.loadingMore')}</div>
|
<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
|
/** A queue row, resolving its display fields against the live Track cache so
|
||||||
* (same read-through as the now-playing entry) so enrichment updates show. */
|
* enrichment updates show. The currently-playing entry is outlined and shows
|
||||||
|
* a playing-bars indicator in place of the drag grip. */
|
||||||
function QueueRow({
|
function QueueRow({
|
||||||
|
id,
|
||||||
entry,
|
entry,
|
||||||
|
isCurrent,
|
||||||
|
isPlaying,
|
||||||
onPlay,
|
onPlay,
|
||||||
|
onMoveNext,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: {
|
}: {
|
||||||
|
id: string;
|
||||||
entry: QueueEntry;
|
entry: QueueEntry;
|
||||||
|
isCurrent: boolean;
|
||||||
|
isPlaying: boolean;
|
||||||
onPlay: () => void;
|
onPlay: () => void;
|
||||||
|
onMoveNext: () => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const token = useAppSelector((s) => s.auth.accessToken);
|
||||||
const resolved = useResolvedQueueEntry(entry);
|
const resolved = useResolvedQueueEntry(entry);
|
||||||
const albumTitle = resolved?.albumTitle ?? entry.albumTitle;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="qrow"
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={`qrow${isCurrent ? ' current' : ''}${isDragging ? ' dragging' : ''}`}
|
||||||
onDoubleClick={onPlay}
|
onDoubleClick={onPlay}
|
||||||
title={t('queue.doubleClickPlay')}
|
title={t('queue.doubleClickPlay')}
|
||||||
>
|
>
|
||||||
<span className="grip">
|
{isCurrent ? (
|
||||||
|
<PlayingIndicator animate={isPlaying} />
|
||||||
|
) : (
|
||||||
|
<span className="grip" {...attributes} {...listeners}>
|
||||||
<Icon name="dots-six-vertical" />
|
<Icon name="dots-six-vertical" />
|
||||||
</span>
|
</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="qt">
|
||||||
<div className="t">{resolved?.title ?? entry.title}</div>
|
<div className="t">{resolved?.title ?? entry.title}</div>
|
||||||
<div className="r">{resolved?.artistName ?? entry.artistName}</div>
|
<div className="r">{resolved?.artistName ?? entry.artistName}</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Menu>
|
||||||
type="button"
|
<MenuTrigger asChild>
|
||||||
className="iconbtn sm"
|
<IconButton
|
||||||
onClick={onRemove}
|
variant="ghost"
|
||||||
title={t('queue.removeFromQueue')}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,88 @@
|
|||||||
import { Badge, Tooltip } from '@olly/modern-sk';
|
import { Badge, Tooltip } from '@olly/modern-sk';
|
||||||
|
import { Icon, type IconName } from '../common/Icon';
|
||||||
import type { TrackAvailability } from '../../api/types';
|
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 {
|
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<
|
const CONFIG: Record<
|
||||||
TrackAvailability,
|
DisplayAvailability,
|
||||||
{
|
{
|
||||||
label: string;
|
label: string;
|
||||||
variant: 'lime' | 'ember' | 'neutral' | 'outline';
|
variant: Variant;
|
||||||
|
icon: IconName;
|
||||||
|
spin?: boolean;
|
||||||
tooltip: string;
|
tooltip: string;
|
||||||
}
|
}
|
||||||
> = {
|
> = {
|
||||||
server: {
|
server: {
|
||||||
label: 'On server',
|
label: 'On server',
|
||||||
variant: 'lime',
|
variant: 'lime',
|
||||||
|
icon: 'cloud',
|
||||||
tooltip: 'File available on server',
|
tooltip: 'File available on server',
|
||||||
},
|
},
|
||||||
|
local: {
|
||||||
|
label: 'Local',
|
||||||
|
variant: 'lime',
|
||||||
|
icon: 'hard-drives',
|
||||||
|
tooltip: 'Cached on this device — playable offline',
|
||||||
|
},
|
||||||
downloading: {
|
downloading: {
|
||||||
label: 'Downloading',
|
label: 'Downloading',
|
||||||
variant: 'neutral',
|
variant: 'neutral',
|
||||||
|
icon: 'arrows-clockwise',
|
||||||
|
spin: true,
|
||||||
tooltip: 'Currently downloading',
|
tooltip: 'Currently downloading',
|
||||||
},
|
},
|
||||||
error: { label: 'Error', variant: 'ember', tooltip: 'Download failed' },
|
error: {
|
||||||
|
label: 'Error',
|
||||||
|
variant: 'ember',
|
||||||
|
icon: 'warning-circle',
|
||||||
|
tooltip: 'Download failed',
|
||||||
|
},
|
||||||
missing: {
|
missing: {
|
||||||
label: 'Missing',
|
label: 'Missing',
|
||||||
variant: 'outline',
|
variant: 'outline',
|
||||||
|
icon: 'cloud-slash',
|
||||||
tooltip: 'File not found on server',
|
tooltip: 'File not found on server',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AvailabilityBadge({ availability }: Props) {
|
export function AvailabilityBadge({ availability, iconOnly }: Props) {
|
||||||
const cfg = CONFIG[availability];
|
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 (
|
return (
|
||||||
<Tooltip content={cfg.tooltip}>
|
<Tooltip content={cfg.tooltip}>
|
||||||
<Badge variant={cfg.variant} dot>
|
<Badge variant={cfg.variant} dot>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Badge, Spinner, Tooltip } from '@olly/modern-sk';
|
import { Badge, Spinner, Tooltip } from '@olly/modern-sk';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Icon, type IconName } from '../common/Icon';
|
||||||
import type { MetadataStatus } from '../../api/types';
|
import type { MetadataStatus } from '../../api/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -9,6 +10,9 @@ interface Props {
|
|||||||
/** When true, render nothing for the normal `enriched` state (keeps dense
|
/** When true, render nothing for the normal `enriched` state (keeps dense
|
||||||
* track lists quiet; the upload screen sets this false to confirm success). */
|
* track lists quiet; the upload screen sets this false to confirm success). */
|
||||||
hideWhenEnriched?: boolean;
|
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';
|
type Variant = 'lime' | 'ember' | 'neutral' | 'outline';
|
||||||
@@ -20,6 +24,19 @@ const VARIANT: Record<MetadataStatus, Variant> = {
|
|||||||
manual: 'outline',
|
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).
|
* Shows a track's metadata-enrichment state (distinct from file availability).
|
||||||
* `pending` carries a spinner; `failed` exposes the backend reason on hover.
|
* `pending` carries a spinner; `failed` exposes the backend reason on hover.
|
||||||
@@ -28,6 +45,7 @@ export function MetadataStatusBadge({
|
|||||||
status,
|
status,
|
||||||
error,
|
error,
|
||||||
hideWhenEnriched = true,
|
hideWhenEnriched = true,
|
||||||
|
iconOnly,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
if (status === 'enriched' && hideWhenEnriched) return null;
|
if (status === 'enriched' && hideWhenEnriched) return null;
|
||||||
@@ -36,6 +54,23 @@ export function MetadataStatusBadge({
|
|||||||
const tooltip =
|
const tooltip =
|
||||||
status === 'failed' && error ? error : t(`metadata.statusHint.${status}`);
|
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 (
|
return (
|
||||||
<Tooltip content={tooltip}>
|
<Tooltip content={tooltip}>
|
||||||
<Badge variant={VARIANT[status]} dot={status !== 'pending'}>
|
<Badge variant={VARIANT[status]} dot={status !== 'pending'}>
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { Row } from '@olly/modern-sk';
|
import { Row } from '@olly/modern-sk';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { TrackContextMenu } from './TrackContextMenu';
|
import { TrackContextMenu } from './TrackContextMenu';
|
||||||
import { AvailabilityBadge } from './AvailabilityBadge';
|
import { AvailabilityBadge } from './AvailabilityBadge';
|
||||||
import { MetadataStatusBadge } from './MetadataStatusBadge';
|
import { MetadataStatusBadge } from './MetadataStatusBadge';
|
||||||
|
import { Icon } from '../common/Icon';
|
||||||
|
import { PlayingIndicator } from '../common/PlayingIndicator';
|
||||||
import { formatDuration } from '../../lib/format';
|
import { formatDuration } from '../../lib/format';
|
||||||
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
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 type { Track } from '../../api/types';
|
||||||
import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
|
import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
|
||||||
|
|
||||||
@@ -12,6 +17,10 @@ interface Props {
|
|||||||
track: Track;
|
track: Track;
|
||||||
index?: number;
|
index?: number;
|
||||||
showAlbum?: boolean;
|
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;
|
onAddToPlaylist?: (track: Track) => void;
|
||||||
onEditMetadata?: (track: Track) => void;
|
onEditMetadata?: (track: Track) => void;
|
||||||
onDelete?: (track: Track) => void;
|
onDelete?: (track: Track) => void;
|
||||||
@@ -21,10 +30,12 @@ export function TrackRow({
|
|||||||
track,
|
track,
|
||||||
index,
|
index,
|
||||||
showAlbum = false,
|
showAlbum = false,
|
||||||
|
hideArt = false,
|
||||||
onAddToPlaylist,
|
onAddToPlaylist,
|
||||||
onEditMetadata,
|
onEditMetadata,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const currentTrackId = useAppSelector((s) => s.player.currentTrackId);
|
const currentTrackId = useAppSelector((s) => s.player.currentTrackId);
|
||||||
const isPlaying = useAppSelector((s) => s.player.isPlaying);
|
const isPlaying = useAppSelector((s) => s.player.isPlaying);
|
||||||
@@ -36,19 +47,43 @@ export function TrackRow({
|
|||||||
getCoverUrl(track.albumArtUrl) ??
|
getCoverUrl(track.albumArtUrl) ??
|
||||||
(token ? getTrackCoverUrl(track.id, token, track.hasCover) : undefined);
|
(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 (
|
return (
|
||||||
<Row
|
<Row
|
||||||
selected={isActive}
|
selected={isActive}
|
||||||
onDoubleClick={() => dispatch(play(track.id))}
|
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
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',
|
gap: '0.75rem',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
padding: '0.375rem 0.75rem',
|
padding: '0.375rem 0.75rem',
|
||||||
cursor: 'default',
|
cursor: 'default',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{!hideArt && (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontSize: '0.75rem',
|
fontSize: '0.75rem',
|
||||||
@@ -58,7 +93,24 @@ export function TrackRow({
|
|||||||
>
|
>
|
||||||
{isActive && isPlaying ? '▶' : index !== undefined ? index + 1 : ''}
|
{isActive && isPlaying ? '▶' : index !== undefined ? index + 1 : ''}
|
||||||
</span>
|
</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
|
<img
|
||||||
src={artUrl}
|
src={artUrl}
|
||||||
alt=""
|
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={{ minWidth: 0 }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -105,8 +172,9 @@ export function TrackRow({
|
|||||||
<MetadataStatusBadge
|
<MetadataStatusBadge
|
||||||
status={track.metadataStatus}
|
status={track.metadataStatus}
|
||||||
error={track.metadataError}
|
error={track.metadataError}
|
||||||
|
iconOnly
|
||||||
/>
|
/>
|
||||||
<AvailabilityBadge availability={track.availability} />
|
<AvailabilityBadge availability={displayAvailability} iconOnly />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -9,16 +9,17 @@ import { TrackRow } from '../../components/track/TrackRow';
|
|||||||
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||||
import { ErrorState } from '../../components/common/ErrorState';
|
import { ErrorState } from '../../components/common/ErrorState';
|
||||||
import { EmptyState } from '../../components/common/EmptyState';
|
import { EmptyState } from '../../components/common/EmptyState';
|
||||||
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||||
import { setQueue } from '../../store/slices/queue';
|
import { setQueue } from '../../store/slices/queue';
|
||||||
import { formatDuration } from '../../lib/format';
|
import { formatDuration } from '../../lib/format';
|
||||||
import { getCoverUrl } from '../../api/endpoints/streaming';
|
import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
|
||||||
|
|
||||||
export function AlbumDetailPage() {
|
export function AlbumDetailPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { albumId } = useParams<{ albumId: string }>();
|
const { albumId } = useParams<{ albumId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const token = useAppSelector((s) => s.auth.accessToken);
|
||||||
|
|
||||||
const albumQuery = useGetAlbumQuery(albumId ?? '', { skip: !albumId });
|
const albumQuery = useGetAlbumQuery(albumId ?? '', { skip: !albumId });
|
||||||
const tracksQuery = useGetAlbumTracksQuery(albumId ?? '', { skip: !albumId });
|
const tracksQuery = useGetAlbumTracksQuery(albumId ?? '', { skip: !albumId });
|
||||||
@@ -42,7 +43,13 @@ export function AlbumDetailPage() {
|
|||||||
|
|
||||||
const album = albumQuery.data;
|
const album = albumQuery.data;
|
||||||
const tracks = tracksQuery.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 = () => {
|
const handlePlayAll = () => {
|
||||||
if (!tracks.length || !album) return;
|
if (!tracks.length || !album) return;
|
||||||
@@ -178,7 +185,7 @@ export function AlbumDetailPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tracks.map((track, i) => (
|
{tracks.map((track, i) => (
|
||||||
<TrackRow key={track.id} track={track} index={i} />
|
<TrackRow key={track.id} track={track} index={i} hideArt />
|
||||||
))}
|
))}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import {
|
|||||||
Tabs,
|
Tabs,
|
||||||
TabsList,
|
TabsList,
|
||||||
TabsContent,
|
TabsContent,
|
||||||
SearchField,
|
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
Card,
|
Card,
|
||||||
|
TextField,
|
||||||
} from '@olly/modern-sk';
|
} from '@olly/modern-sk';
|
||||||
import {
|
import {
|
||||||
useGetTracksQuery,
|
useGetTracksQuery,
|
||||||
@@ -23,6 +23,7 @@ import { setQueue } from '../../store/slices/queue';
|
|||||||
import type { Track, Album, Artist } from '../../api/types';
|
import type { Track, Album, Artist } from '../../api/types';
|
||||||
import { getCoverUrl } from '../../api/endpoints/streaming';
|
import { getCoverUrl } from '../../api/endpoints/streaming';
|
||||||
import { formatDuration } from '../../lib/format';
|
import { formatDuration } from '../../lib/format';
|
||||||
|
import { useDebounce } from 'use-debounce';
|
||||||
|
|
||||||
export function LibraryPage() {
|
export function LibraryPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -30,10 +31,17 @@ export function LibraryPage() {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [tab, setTab] = useState('tracks');
|
const [tab, setTab] = useState('tracks');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [debouncedSearch] = useDebounce(search, 300);
|
||||||
|
|
||||||
const tracksQuery = useGetTracksQuery(search ? { search } : undefined);
|
const tracksQuery = useGetTracksQuery(
|
||||||
const albumsQuery = useGetAlbumsQuery(search ? { search } : undefined);
|
debouncedSearch ? { search } : undefined,
|
||||||
const artistsQuery = useGetArtistsQuery(search ? { search } : undefined);
|
);
|
||||||
|
const albumsQuery = useGetAlbumsQuery(
|
||||||
|
debouncedSearch ? { search } : undefined,
|
||||||
|
);
|
||||||
|
const artistsQuery = useGetArtistsQuery(
|
||||||
|
debouncedSearch ? { search } : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
const handlePlayAll = (tracks: Track[]) => {
|
const handlePlayAll = (tracks: Track[]) => {
|
||||||
dispatch(
|
dispatch(
|
||||||
@@ -68,11 +76,10 @@ export function LibraryPage() {
|
|||||||
{t('library.title')}
|
{t('library.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<div style={{ flex: 1, maxWidth: '20rem' }}>
|
<div style={{ flex: 1, maxWidth: '20rem' }}>
|
||||||
<SearchField
|
<TextField
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
placeholder={t('library.searchPlaceholder')}
|
placeholder={t('library.searchPlaceholder')}
|
||||||
icon="⌕"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,512 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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() {
|
export function StoragePage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading, isError, refetch } = useGetStorageStatsQuery();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '1.5rem' }}>
|
<div style={{ padding: '1.5rem', maxWidth: 1100, margin: '0 auto' }}>
|
||||||
<Window title={t('pages.storage')}>
|
<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>
|
</Window>
|
||||||
</div>
|
</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 queue = useAppSelector((s) => s.queue);
|
||||||
const accessToken = useAppSelector((s) => s.auth.accessToken);
|
const accessToken = useAppSelector((s) => s.auth.accessToken);
|
||||||
const isSetup = useRef(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (isSetup.current) return;
|
if (isSetup.current) return;
|
||||||
@@ -41,7 +45,12 @@ export function useAudioPlayer() {
|
|||||||
dispatch(setDuration(audio.duration || 0));
|
dispatch(setDuration(audio.duration || 0));
|
||||||
});
|
});
|
||||||
audio.addEventListener('ended', () => {
|
audio.addEventListener('ended', () => {
|
||||||
|
if (loopRef.current) {
|
||||||
|
audio.currentTime = 0;
|
||||||
|
void audio.play();
|
||||||
|
} else {
|
||||||
dispatch(nextTrack());
|
dispatch(nextTrack());
|
||||||
|
}
|
||||||
});
|
});
|
||||||
audio.addEventListener('pause', () => {
|
audio.addEventListener('pause', () => {
|
||||||
dispatch(pause());
|
dispatch(pause());
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { getApiBaseUrl } from '../config/runtime-config';
|
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). */
|
/** Pings `${baseUrl}/health` (defaults to the active instance's base URL). */
|
||||||
export function useConnectionStatus(baseUrl?: string) {
|
export function useConnectionStatus(baseUrl?: string) {
|
||||||
@@ -36,3 +41,26 @@ export function useConnectionStatus(baseUrl?: string) {
|
|||||||
|
|
||||||
return status;
|
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;
|
albumTitle: string;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
hasCover: boolean;
|
hasCover: boolean;
|
||||||
|
albumArtUrl?: string;
|
||||||
|
format?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,5 +35,7 @@ export function useResolvedQueueEntry(
|
|||||||
albumTitle: data?.albumTitle ?? entry.albumTitle,
|
albumTitle: data?.albumTitle ?? entry.albumTitle,
|
||||||
durationMs: data?.durationMs ?? entry.durationMs,
|
durationMs: data?.durationMs ?? entry.durationMs,
|
||||||
hasCover: data?.hasCover ?? false,
|
hasCover: data?.hasCover ?? false,
|
||||||
|
albumArtUrl: data?.albumArtUrl ?? entry.albumArtUrl,
|
||||||
|
format: data?.format,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-6
@@ -120,27 +120,25 @@ const en = {
|
|||||||
},
|
},
|
||||||
player: {
|
player: {
|
||||||
nothingPlaying: 'Nothing playing',
|
nothingPlaying: 'Nothing playing',
|
||||||
shuffle: 'Shuffle',
|
|
||||||
previous: 'Previous',
|
previous: 'Previous',
|
||||||
next: 'Next',
|
next: 'Next',
|
||||||
pause: 'Pause',
|
pause: 'Pause',
|
||||||
play: 'Play',
|
play: 'Play',
|
||||||
repeat: 'Repeat: {{mode}}',
|
streaming: 'Streaming',
|
||||||
streaming: 'Streaming · 320 kbps',
|
local: 'Local',
|
||||||
local: 'Local · FLAC',
|
|
||||||
queue: 'Play queue',
|
queue: 'Play queue',
|
||||||
mute: 'Mute',
|
mute: 'Mute',
|
||||||
unmute: 'Unmute',
|
unmute: 'Unmute',
|
||||||
},
|
},
|
||||||
queue: {
|
queue: {
|
||||||
title: 'Play queue',
|
title: 'Play queue',
|
||||||
|
shuffle: 'Shuffle queue',
|
||||||
|
loop: 'Repeat current track',
|
||||||
clear: 'Clear queue',
|
clear: 'Clear queue',
|
||||||
close: 'Close',
|
close: 'Close',
|
||||||
from: 'From {{source}}',
|
from: 'From {{source}}',
|
||||||
radio: 'Radio · {{source}}',
|
radio: 'Radio · {{source}}',
|
||||||
nowPlaying: 'Now playing',
|
|
||||||
nextUp: 'Next up',
|
nextUp: 'Next up',
|
||||||
nothingNext: 'Nothing queued next',
|
|
||||||
empty: 'Queue is empty',
|
empty: 'Queue is empty',
|
||||||
radioActive: 'Radio active',
|
radioActive: 'Radio active',
|
||||||
mixing: '∞ mixing',
|
mixing: '∞ mixing',
|
||||||
@@ -149,6 +147,13 @@ const en = {
|
|||||||
loadingMore: 'Loading more from radio…',
|
loadingMore: 'Loading more from radio…',
|
||||||
doubleClickPlay: 'Double-click to play',
|
doubleClickPlay: 'Double-click to play',
|
||||||
removeFromQueue: 'Remove from queue',
|
removeFromQueue: 'Remove from queue',
|
||||||
|
menu: {
|
||||||
|
options: 'Track options',
|
||||||
|
playNow: 'Play now',
|
||||||
|
moveNext: 'Move next',
|
||||||
|
info: 'Track info',
|
||||||
|
remove: 'Remove from queue',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
track: {
|
track: {
|
||||||
menu: {
|
menu: {
|
||||||
@@ -205,6 +210,42 @@ const en = {
|
|||||||
comingSoon: 'Coming soon',
|
comingSoon: 'Coming soon',
|
||||||
back: 'Back',
|
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: {
|
pages: {
|
||||||
admin: 'Admin',
|
admin: 'Admin',
|
||||||
settings: 'Settings',
|
settings: 'Settings',
|
||||||
|
|||||||
+47
-6
@@ -122,27 +122,25 @@ const ru: Translations = {
|
|||||||
},
|
},
|
||||||
player: {
|
player: {
|
||||||
nothingPlaying: 'Ничего не играет',
|
nothingPlaying: 'Ничего не играет',
|
||||||
shuffle: 'Перемешать',
|
|
||||||
previous: 'Назад',
|
previous: 'Назад',
|
||||||
next: 'Вперёд',
|
next: 'Вперёд',
|
||||||
pause: 'Пауза',
|
pause: 'Пауза',
|
||||||
play: 'Воспроизвести',
|
play: 'Воспроизвести',
|
||||||
repeat: 'Повтор: {{mode}}',
|
streaming: 'Стриминг',
|
||||||
streaming: 'Стриминг · 320 kbps',
|
local: 'Локально',
|
||||||
local: 'Локально · FLAC',
|
|
||||||
queue: 'Очередь',
|
queue: 'Очередь',
|
||||||
mute: 'Выключить звук',
|
mute: 'Выключить звук',
|
||||||
unmute: 'Включить звук',
|
unmute: 'Включить звук',
|
||||||
},
|
},
|
||||||
queue: {
|
queue: {
|
||||||
title: 'Очередь воспроизведения',
|
title: 'Очередь воспроизведения',
|
||||||
|
shuffle: 'Перемешать очередь',
|
||||||
|
loop: 'Повторять текущий трек',
|
||||||
clear: 'Очистить очередь',
|
clear: 'Очистить очередь',
|
||||||
close: 'Закрыть',
|
close: 'Закрыть',
|
||||||
from: 'Из: {{source}}',
|
from: 'Из: {{source}}',
|
||||||
radio: 'Радио · {{source}}',
|
radio: 'Радио · {{source}}',
|
||||||
nowPlaying: 'Сейчас играет',
|
|
||||||
nextUp: 'Далее',
|
nextUp: 'Далее',
|
||||||
nothingNext: 'Очередь пуста',
|
|
||||||
empty: 'Очередь пуста',
|
empty: 'Очередь пуста',
|
||||||
radioActive: 'Радио активно',
|
radioActive: 'Радио активно',
|
||||||
mixing: '∞ микс',
|
mixing: '∞ микс',
|
||||||
@@ -151,6 +149,13 @@ const ru: Translations = {
|
|||||||
loadingMore: 'Загрузка радио…',
|
loadingMore: 'Загрузка радио…',
|
||||||
doubleClickPlay: 'Двойной клик для воспроизведения',
|
doubleClickPlay: 'Двойной клик для воспроизведения',
|
||||||
removeFromQueue: 'Убрать из очереди',
|
removeFromQueue: 'Убрать из очереди',
|
||||||
|
menu: {
|
||||||
|
options: 'Параметры трека',
|
||||||
|
playNow: 'Воспроизвести сейчас',
|
||||||
|
moveNext: 'Сделать следующим',
|
||||||
|
info: 'Информация о треке',
|
||||||
|
remove: 'Убрать из очереди',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
track: {
|
track: {
|
||||||
menu: {
|
menu: {
|
||||||
@@ -207,6 +212,42 @@ const ru: Translations = {
|
|||||||
comingSoon: 'Скоро',
|
comingSoon: 'Скоро',
|
||||||
back: 'Назад',
|
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: {
|
pages: {
|
||||||
admin: 'Администрирование',
|
admin: 'Администрирование',
|
||||||
settings: 'Настройки',
|
settings: 'Настройки',
|
||||||
|
|||||||
@@ -26,6 +26,20 @@ export function formatDateTime(iso: string | undefined): string | undefined {
|
|||||||
}).format(d);
|
}).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 {
|
export function formatCount(n: number): string {
|
||||||
if (n < 1000) return String(n);
|
if (n < 1000) return String(n);
|
||||||
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;
|
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { configureStore } from '@reduxjs/toolkit';
|
import { configureStore } from '@reduxjs/toolkit';
|
||||||
import { api } from '../api';
|
import { api } from '../api';
|
||||||
import authReducer from './slices/auth';
|
import authReducer from './slices/auth';
|
||||||
|
import connectionReducer from './slices/connection';
|
||||||
import playerReducer from './slices/player';
|
import playerReducer from './slices/player';
|
||||||
import queueReducer from './slices/queue';
|
import queueReducer from './slices/queue';
|
||||||
import uiReducer from './slices/ui';
|
import uiReducer from './slices/ui';
|
||||||
@@ -11,6 +12,7 @@ export const store = configureStore({
|
|||||||
reducer: {
|
reducer: {
|
||||||
[api.reducerPath]: api.reducer,
|
[api.reducerPath]: api.reducer,
|
||||||
auth: authReducer,
|
auth: authReducer,
|
||||||
|
connection: connectionReducer,
|
||||||
player: playerReducer,
|
player: playerReducer,
|
||||||
queue: queueReducer,
|
queue: queueReducer,
|
||||||
ui: uiReducer,
|
ui: uiReducer,
|
||||||
|
|||||||
+12
-12
@@ -8,14 +8,8 @@
|
|||||||
* Server data (library/albums/…) is Tier 2 (see `rtkqPersist.ts`).
|
* Server data (library/albums/…) is Tier 2 (see `rtkqPersist.ts`).
|
||||||
*/
|
*/
|
||||||
import { instanceStorage } from '../config/instances';
|
import { instanceStorage } from '../config/instances';
|
||||||
import {
|
import { queueInitialState, type QueueState } from './slices/queue';
|
||||||
queueInitialState,
|
import { playerInitialState, type PlayerState } from './slices/player';
|
||||||
type QueueState,
|
|
||||||
} from './slices/queue';
|
|
||||||
import {
|
|
||||||
playerInitialState,
|
|
||||||
type PlayerState,
|
|
||||||
} from './slices/player';
|
|
||||||
import type { RootState } from './index';
|
import type { RootState } from './index';
|
||||||
|
|
||||||
const QUEUE_KEY = 'queue';
|
const QUEUE_KEY = 'queue';
|
||||||
@@ -26,11 +20,17 @@ const PLAYER_KEY = 'player';
|
|||||||
// transient UI, so they are intentionally left out.
|
// transient UI, so they are intentionally left out.
|
||||||
type PersistedQueue = Pick<
|
type PersistedQueue = Pick<
|
||||||
QueueState,
|
QueueState,
|
||||||
'entries' | 'currentIndex' | 'source' | 'sourceId' | 'sourceName'
|
| 'entries'
|
||||||
|
| 'currentIndex'
|
||||||
|
| 'source'
|
||||||
|
| 'sourceId'
|
||||||
|
| 'sourceName'
|
||||||
|
| 'shuffle'
|
||||||
|
| 'loop'
|
||||||
>;
|
>;
|
||||||
type PersistedPlayer = Pick<
|
type PersistedPlayer = Pick<
|
||||||
PlayerState,
|
PlayerState,
|
||||||
'currentTrackId' | 'position' | 'volume' | 'muted' | 'repeat' | 'shuffle'
|
'currentTrackId' | 'position' | 'volume' | 'muted'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
function pickQueue(state: QueueState): PersistedQueue {
|
function pickQueue(state: QueueState): PersistedQueue {
|
||||||
@@ -40,6 +40,8 @@ function pickQueue(state: QueueState): PersistedQueue {
|
|||||||
source: state.source,
|
source: state.source,
|
||||||
sourceId: state.sourceId,
|
sourceId: state.sourceId,
|
||||||
sourceName: state.sourceName,
|
sourceName: state.sourceName,
|
||||||
|
shuffle: state.shuffle,
|
||||||
|
loop: state.loop,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,8 +51,6 @@ function pickPlayer(state: PlayerState): PersistedPlayer {
|
|||||||
position: state.position,
|
position: state.position,
|
||||||
volume: state.volume,
|
volume: state.volume,
|
||||||
muted: state.muted,
|
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';
|
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
export type RepeatMode = 'none' | 'one' | 'all';
|
|
||||||
|
|
||||||
export interface PlayerState {
|
export interface PlayerState {
|
||||||
currentTrackId: string | null;
|
currentTrackId: string | null;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
@@ -9,8 +7,6 @@ export interface PlayerState {
|
|||||||
duration: number;
|
duration: number;
|
||||||
volume: number;
|
volume: number;
|
||||||
muted: boolean;
|
muted: boolean;
|
||||||
repeat: RepeatMode;
|
|
||||||
shuffle: boolean;
|
|
||||||
isQueueOpen: boolean;
|
isQueueOpen: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,8 +17,6 @@ export const playerInitialState: PlayerState = {
|
|||||||
duration: 0,
|
duration: 0,
|
||||||
volume: 0.78,
|
volume: 0.78,
|
||||||
muted: false,
|
muted: false,
|
||||||
repeat: 'none',
|
|
||||||
shuffle: false,
|
|
||||||
isQueueOpen: false,
|
isQueueOpen: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,12 +52,6 @@ export const playerSlice = createSlice({
|
|||||||
toggleMute(state) {
|
toggleMute(state) {
|
||||||
state.muted = !state.muted;
|
state.muted = !state.muted;
|
||||||
},
|
},
|
||||||
setRepeat(state, action: PayloadAction<RepeatMode>) {
|
|
||||||
state.repeat = action.payload;
|
|
||||||
},
|
|
||||||
toggleShuffle(state) {
|
|
||||||
state.shuffle = !state.shuffle;
|
|
||||||
},
|
|
||||||
toggleQueue(state) {
|
toggleQueue(state) {
|
||||||
state.isQueueOpen = !state.isQueueOpen;
|
state.isQueueOpen = !state.isQueueOpen;
|
||||||
},
|
},
|
||||||
@@ -79,8 +67,6 @@ export const {
|
|||||||
setDuration,
|
setDuration,
|
||||||
setVolume,
|
setVolume,
|
||||||
toggleMute,
|
toggleMute,
|
||||||
setRepeat,
|
|
||||||
toggleShuffle,
|
|
||||||
toggleQueue,
|
toggleQueue,
|
||||||
} = playerSlice.actions;
|
} = playerSlice.actions;
|
||||||
export default playerSlice.reducer;
|
export default playerSlice.reducer;
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export interface QueueState {
|
|||||||
source: QueueSource;
|
source: QueueSource;
|
||||||
sourceId: string | null;
|
sourceId: string | null;
|
||||||
sourceName: string | null;
|
sourceName: string | null;
|
||||||
|
shuffle: boolean;
|
||||||
|
loop: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const queueInitialState: QueueState = {
|
export const queueInitialState: QueueState = {
|
||||||
@@ -31,6 +33,8 @@ export const queueInitialState: QueueState = {
|
|||||||
source: 'manual',
|
source: 'manual',
|
||||||
sourceId: null,
|
sourceId: null,
|
||||||
sourceName: null,
|
sourceName: null,
|
||||||
|
shuffle: false,
|
||||||
|
loop: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const queueSlice = createSlice({
|
export const queueSlice = createSlice({
|
||||||
@@ -59,6 +63,11 @@ export const queueSlice = createSlice({
|
|||||||
addNextInQueue(state, action: PayloadAction<QueueEntry>) {
|
addNextInQueue(state, action: PayloadAction<QueueEntry>) {
|
||||||
state.entries.splice(state.currentIndex + 1, 0, action.payload);
|
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>) {
|
removeFromQueue(state, action: PayloadAction<number>) {
|
||||||
state.entries.splice(action.payload, 1);
|
state.entries.splice(action.payload, 1);
|
||||||
if (action.payload < state.currentIndex) state.currentIndex--;
|
if (action.payload < state.currentIndex) state.currentIndex--;
|
||||||
@@ -77,7 +86,15 @@ export const queueSlice = createSlice({
|
|||||||
state.currentIndex = action.payload;
|
state.currentIndex = action.payload;
|
||||||
},
|
},
|
||||||
nextTrack(state) {
|
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) {
|
prevTrack(state) {
|
||||||
if (state.currentIndex > 0) state.currentIndex--;
|
if (state.currentIndex > 0) state.currentIndex--;
|
||||||
@@ -86,6 +103,12 @@ export const queueSlice = createSlice({
|
|||||||
state.entries = [];
|
state.entries = [];
|
||||||
state.currentIndex = -1;
|
state.currentIndex = -1;
|
||||||
},
|
},
|
||||||
|
toggleShuffle(state) {
|
||||||
|
state.shuffle = !state.shuffle;
|
||||||
|
},
|
||||||
|
toggleLoop(state) {
|
||||||
|
state.loop = !state.loop;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,11 +116,14 @@ export const {
|
|||||||
setQueue,
|
setQueue,
|
||||||
addToQueue,
|
addToQueue,
|
||||||
addNextInQueue,
|
addNextInQueue,
|
||||||
|
playNow,
|
||||||
removeFromQueue,
|
removeFromQueue,
|
||||||
moveInQueue,
|
moveInQueue,
|
||||||
goToIndex,
|
goToIndex,
|
||||||
nextTrack,
|
nextTrack,
|
||||||
prevTrack,
|
prevTrack,
|
||||||
clearQueue,
|
clearQueue,
|
||||||
|
toggleShuffle,
|
||||||
|
toggleLoop,
|
||||||
} = queueSlice.actions;
|
} = queueSlice.actions;
|
||||||
export default queueSlice.reducer;
|
export default queueSlice.reducer;
|
||||||
|
|||||||
+124
-30
@@ -404,6 +404,55 @@
|
|||||||
font-size: 10px;
|
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
|
PLAYER BAR
|
||||||
============================================================ */
|
============================================================ */
|
||||||
@@ -604,36 +653,6 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 12px 12px 18px;
|
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 {
|
.qrow {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 11px;
|
gap: 11px;
|
||||||
@@ -649,6 +668,24 @@
|
|||||||
.qrow:hover {
|
.qrow:hover {
|
||||||
background: rgba(255, 255, 255, 0.04);
|
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 {
|
.qrow .grip {
|
||||||
color: var(--fg-3);
|
color: var(--fg-3);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@@ -922,3 +959,60 @@
|
|||||||
.sb-sec-link.active {
|
.sb-sec-link.active {
|
||||||
color: var(--fg-1);
|
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