feat(track): icon-based status badges, detect locally-cached tracks
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled

Replace the labelled availability/metadata badges in track rows with
small icon+tooltip indicators (cloud/hard-drives/warning/etc, derived
from TrackAvailability and MetadataStatus).

Add a `connection` slice fed by a single status poller (Sidebar) so
other components can cheaply check backend reachability. TrackRow uses
this plus the offline audio cache to show "Local" instead of a stale
"On server" when the backend is down but the track is already cached.
This commit is contained in:
Senko-san
2026-06-13 18:00:48 +03:00
parent df8c67b368
commit 8ae447e08d
9 changed files with 174 additions and 9 deletions
+2
View File
@@ -15,6 +15,7 @@ import {
ArrowsClockwise,
CheckCircle,
Cloud,
CloudSlash,
DotsSixVertical,
GearSix,
HardDrives,
@@ -75,6 +76,7 @@ const ICONS = {
'speaker-high': SpeakerHigh,
'speaker-x': SpeakerSimpleX,
cloud: Cloud,
'cloud-slash': CloudSlash,
'check-circle': CheckCircle,
'warning-circle': WarningCircle,
'sign-out': SignOut,
+2 -2
View File
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Icon, type IconName } from '../common/Icon';
import { useAppDispatch } from '../../hooks/useAppDispatch';
import { usePermissions, type Permission } from '../../hooks/usePermissions';
import { useConnectionStatus } from '../../hooks/useConnectionStatus';
import { useConnectionStatusSync } from '../../hooks/useConnectionStatus';
import { logout } from '../../store/slices/auth';
import { useGetPlaylistsQuery } from '../../api/endpoints/playlists';
import { getActiveInstance } from '../../config/instances';
@@ -41,7 +41,7 @@ export function Sidebar() {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const { user, isAdmin, hasPermission } = usePermissions();
const status = useConnectionStatus();
const status = useConnectionStatusSync();
const { data: playlists } = useGetPlaylistsQuery();
const instance = getActiveInstance();
+55 -5
View File
@@ -1,38 +1,88 @@
import { Badge, Tooltip } from '@olly/modern-sk';
import { Icon, type IconName } from '../common/Icon';
import type { TrackAvailability } from '../../api/types';
/** `TrackAvailability` plus a client-derived state: the backend reports
* `server`, but if it's unreachable and the track's audio is already in the
* offline cache, we know better — show `local` instead. */
export type DisplayAvailability = TrackAvailability | 'local';
interface Props {
availability: TrackAvailability;
availability: DisplayAvailability;
/** Render as a small icon + tooltip instead of a labelled badge — used in
* dense track lists (library, album, playlist). */
iconOnly?: boolean;
}
const COLOR_VAR: Record<Variant, string> = {
lime: 'var(--lime)',
ember: 'var(--ember)',
neutral: 'var(--fg-3)',
outline: 'var(--fg-3)',
};
type Variant = 'lime' | 'ember' | 'neutral' | 'outline';
const CONFIG: Record<
TrackAvailability,
DisplayAvailability,
{
label: string;
variant: 'lime' | 'ember' | 'neutral' | 'outline';
variant: Variant;
icon: IconName;
spin?: boolean;
tooltip: string;
}
> = {
server: {
label: 'On server',
variant: 'lime',
icon: 'cloud',
tooltip: 'File available on server',
},
local: {
label: 'Local',
variant: 'lime',
icon: 'hard-drives',
tooltip: 'Cached on this device — playable offline',
},
downloading: {
label: 'Downloading',
variant: 'neutral',
icon: 'arrows-clockwise',
spin: true,
tooltip: 'Currently downloading',
},
error: { label: 'Error', variant: 'ember', tooltip: 'Download failed' },
error: {
label: 'Error',
variant: 'ember',
icon: 'warning-circle',
tooltip: 'Download failed',
},
missing: {
label: 'Missing',
variant: 'outline',
icon: 'cloud-slash',
tooltip: 'File not found on server',
},
};
export function AvailabilityBadge({ availability }: Props) {
export function AvailabilityBadge({ availability, iconOnly }: Props) {
const cfg = CONFIG[availability];
if (iconOnly) {
return (
<Tooltip content={cfg.tooltip}>
<span style={{ display: 'inline-flex' }}>
<Icon
name={cfg.icon}
className={cfg.spin ? 'spin' : undefined}
style={{ color: COLOR_VAR[cfg.variant], fontSize: 15 }}
/>
</span>
</Tooltip>
);
}
return (
<Tooltip content={cfg.tooltip}>
<Badge variant={cfg.variant} dot>
@@ -1,5 +1,6 @@
import { Badge, Spinner, Tooltip } from '@olly/modern-sk';
import { useTranslation } from 'react-i18next';
import { Icon, type IconName } from '../common/Icon';
import type { MetadataStatus } from '../../api/types';
interface Props {
@@ -9,6 +10,9 @@ interface Props {
/** When true, render nothing for the normal `enriched` state (keeps dense
* track lists quiet; the upload screen sets this false to confirm success). */
hideWhenEnriched?: boolean;
/** Render as a small icon + tooltip instead of a labelled badge — used in
* dense track lists (library, album, playlist). */
iconOnly?: boolean;
}
type Variant = 'lime' | 'ember' | 'neutral' | 'outline';
@@ -20,6 +24,19 @@ const VARIANT: Record<MetadataStatus, Variant> = {
manual: 'outline',
};
const COLOR_VAR: Record<Variant, string> = {
lime: 'var(--lime)',
ember: 'var(--ember)',
neutral: 'var(--fg-3)',
outline: 'var(--fg-3)',
};
const ICON: Record<Exclude<MetadataStatus, 'pending'>, IconName> = {
enriched: 'check-circle',
failed: 'warning-circle',
manual: 'push-pin',
};
/**
* Shows a track's metadata-enrichment state (distinct from file availability).
* `pending` carries a spinner; `failed` exposes the backend reason on hover.
@@ -28,6 +45,7 @@ export function MetadataStatusBadge({
status,
error,
hideWhenEnriched = true,
iconOnly,
}: Props) {
const { t } = useTranslation();
if (status === 'enriched' && hideWhenEnriched) return null;
@@ -36,6 +54,23 @@ export function MetadataStatusBadge({
const tooltip =
status === 'failed' && error ? error : t(`metadata.statusHint.${status}`);
if (iconOnly) {
return (
<Tooltip content={tooltip}>
<span style={{ display: 'inline-flex' }}>
{status === 'pending' ? (
<Spinner size="sm" />
) : (
<Icon
name={ICON[status]}
style={{ color: COLOR_VAR[VARIANT[status]], fontSize: 15 }}
/>
)}
</span>
</Tooltip>
);
}
return (
<Tooltip content={tooltip}>
<Badge variant={VARIANT[status]} dot={status !== 'pending'}>
+13 -1
View File
@@ -7,6 +7,8 @@ import { Icon } from '../common/Icon';
import { PlayingIndicator } from '../common/PlayingIndicator';
import { formatDuration } from '../../lib/format';
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
import { useIsOffline } from '../../hooks/useConnectionStatus';
import { useStreamCached } from '../../hooks/useStreamCached';
import { playNow } from '../../store/slices/queue';
import type { Track } from '../../api/types';
import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
@@ -45,6 +47,15 @@ export function TrackRow({
getCoverUrl(track.albumArtUrl) ??
(token ? getTrackCoverUrl(track.id, token, track.hasCover) : undefined);
// The backend reports `server`, but if it's unreachable and this track's
// audio is already in the offline cache, show "Local" instead.
const offline = useIsOffline();
const cached = useStreamCached(offline ? track.id : undefined);
const displayAvailability =
track.availability === 'server' && offline && cached
? 'local'
: track.availability;
const handlePlayNow = () => {
dispatch(
playNow({
@@ -161,8 +172,9 @@ export function TrackRow({
<MetadataStatusBadge
status={track.metadataStatus}
error={track.metadataError}
iconOnly
/>
<AvailabilityBadge availability={track.availability} />
<AvailabilityBadge availability={displayAvailability} iconOnly />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span
+29 -1
View File
@@ -1,7 +1,12 @@
import { useState, useEffect } from 'react';
import { getApiBaseUrl } from '../config/runtime-config';
import { useAppDispatch, useAppSelector } from './useAppDispatch';
import {
setConnectionStatus,
type ConnectionStatus,
} from '../store/slices/connection';
type ConnectionStatus = 'connected' | 'connecting' | 'disconnected' | 'error';
export type { ConnectionStatus };
/** Pings `${baseUrl}/health` (defaults to the active instance's base URL). */
export function useConnectionStatus(baseUrl?: string) {
@@ -36,3 +41,26 @@ export function useConnectionStatus(baseUrl?: string) {
return status;
}
/**
* Like `useConnectionStatus`, but also mirrors the result into the
* `connection` slice so other components can read the active instance's
* reachability via `useIsOffline` without running their own poller.
* Mount once (in `Sidebar`, which lives for the app's whole lifetime).
*/
export function useConnectionStatusSync(): ConnectionStatus {
const status = useConnectionStatus();
const dispatch = useAppDispatch();
useEffect(() => {
dispatch(setConnectionStatus(status));
}, [status, dispatch]);
return status;
}
/** Whether the active backend instance is currently unreachable. */
export function useIsOffline(): boolean {
const status = useAppSelector((s) => s.connection.status);
return status === 'disconnected' || status === 'error';
}
+2
View File
@@ -1,6 +1,7 @@
import { configureStore } from '@reduxjs/toolkit';
import { api } from '../api';
import authReducer from './slices/auth';
import connectionReducer from './slices/connection';
import playerReducer from './slices/player';
import queueReducer from './slices/queue';
import uiReducer from './slices/ui';
@@ -11,6 +12,7 @@ export const store = configureStore({
reducer: {
[api.reducerPath]: api.reducer,
auth: authReducer,
connection: connectionReducer,
player: playerReducer,
queue: queueReducer,
ui: uiReducer,
+28
View File
@@ -0,0 +1,28 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
export type ConnectionStatus =
| 'connected'
| 'connecting'
| 'disconnected'
| 'error';
export interface ConnectionState {
status: ConnectionStatus;
}
export const connectionInitialState: ConnectionState = {
status: 'connecting',
};
export const connectionSlice = createSlice({
name: 'connection',
initialState: connectionInitialState,
reducers: {
setConnectionStatus(state, action: PayloadAction<ConnectionStatus>) {
state.status = action.payload;
},
},
});
export const { setConnectionStatus } = connectionSlice.actions;
export default connectionSlice.reducer;
+8
View File
@@ -435,6 +435,14 @@
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% {