feat(track): icon-based status badges, detect locally-cached tracks
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:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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'}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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% {
|
||||
|
||||
Reference in New Issue
Block a user