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,
|
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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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'}>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { Icon } from '../common/Icon';
|
|||||||
import { PlayingIndicator } from '../common/PlayingIndicator';
|
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 { useIsOffline } from '../../hooks/useConnectionStatus';
|
||||||
|
import { useStreamCached } from '../../hooks/useStreamCached';
|
||||||
import { playNow } from '../../store/slices/queue';
|
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';
|
||||||
@@ -45,6 +47,15 @@ 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 = () => {
|
const handlePlayNow = () => {
|
||||||
dispatch(
|
dispatch(
|
||||||
playNow({
|
playNow({
|
||||||
@@ -161,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
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
animation-play-state: paused;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
.spin {
|
||||||
|
animation: spin 1.2s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@keyframes playing-bar-bounce {
|
@keyframes playing-bar-bounce {
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
|
|||||||
Reference in New Issue
Block a user