From 8ae447e08dd36630610ad2de6a7fd3aa8aa1d58c Mon Sep 17 00:00:00 2001 From: Senko-san Date: Sat, 13 Jun 2026 18:00:48 +0300 Subject: [PATCH] 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. --- src/components/common/Icon.tsx | 2 + src/components/layout/Sidebar.tsx | 4 +- src/components/track/AvailabilityBadge.tsx | 60 ++++++++++++++++++-- src/components/track/MetadataStatusBadge.tsx | 35 ++++++++++++ src/components/track/TrackRow.tsx | 14 ++++- src/hooks/useConnectionStatus.ts | 30 +++++++++- src/store/index.ts | 2 + src/store/slices/connection.ts | 28 +++++++++ src/styles/shell.css | 8 +++ 9 files changed, 174 insertions(+), 9 deletions(-) create mode 100644 src/store/slices/connection.ts diff --git a/src/components/common/Icon.tsx b/src/components/common/Icon.tsx index 19fda55..e00051d 100644 --- a/src/components/common/Icon.tsx +++ b/src/components/common/Icon.tsx @@ -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, diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 0fa6c25..ec15c54 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -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(); diff --git a/src/components/track/AvailabilityBadge.tsx b/src/components/track/AvailabilityBadge.tsx index 9d61ee8..df37d35 100644 --- a/src/components/track/AvailabilityBadge.tsx +++ b/src/components/track/AvailabilityBadge.tsx @@ -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 = { + 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 ( + + + + + + ); + } + return ( diff --git a/src/components/track/MetadataStatusBadge.tsx b/src/components/track/MetadataStatusBadge.tsx index f141961..36dce07 100644 --- a/src/components/track/MetadataStatusBadge.tsx +++ b/src/components/track/MetadataStatusBadge.tsx @@ -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 = { manual: 'outline', }; +const COLOR_VAR: Record = { + lime: 'var(--lime)', + ember: 'var(--ember)', + neutral: 'var(--fg-3)', + outline: 'var(--fg-3)', +}; + +const ICON: Record, 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 ( + + + {status === 'pending' ? ( + + ) : ( + + )} + + + ); + } + return ( diff --git a/src/components/track/TrackRow.tsx b/src/components/track/TrackRow.tsx index dd92fb2..f4d80a9 100644 --- a/src/components/track/TrackRow.tsx +++ b/src/components/track/TrackRow.tsx @@ -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({ - +
{ + 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'; +} diff --git a/src/store/index.ts b/src/store/index.ts index 90f0286..46fcd26 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -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, diff --git a/src/store/slices/connection.ts b/src/store/slices/connection.ts new file mode 100644 index 0000000..67bd034 --- /dev/null +++ b/src/store/slices/connection.ts @@ -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) { + state.status = action.payload; + }, + }, +}); + +export const { setConnectionStatus } = connectionSlice.actions; +export default connectionSlice.reducer; diff --git a/src/styles/shell.css b/src/styles/shell.css index 52efa5d..cc182f1 100644 --- a/src/styles/shell.css +++ b/src/styles/shell.css @@ -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% {