8ae447e08d
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.
200 lines
6.0 KiB
TypeScript
200 lines
6.0 KiB
TypeScript
import { Row } from '@olly/modern-sk';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { TrackContextMenu } from './TrackContextMenu';
|
|
import { AvailabilityBadge } from './AvailabilityBadge';
|
|
import { MetadataStatusBadge } from './MetadataStatusBadge';
|
|
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';
|
|
|
|
interface Props {
|
|
track: Track;
|
|
index?: number;
|
|
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;
|
|
onEditMetadata?: (track: Track) => void;
|
|
onDelete?: (track: Track) => void;
|
|
}
|
|
|
|
export function TrackRow({
|
|
track,
|
|
index,
|
|
showAlbum = false,
|
|
hideArt = false,
|
|
onAddToPlaylist,
|
|
onEditMetadata,
|
|
onDelete,
|
|
}: Props) {
|
|
const { t } = useTranslation();
|
|
const dispatch = useAppDispatch();
|
|
const currentTrackId = useAppSelector((s) => s.player.currentTrackId);
|
|
const isPlaying = useAppSelector((s) => s.player.isPlaying);
|
|
const token = useAppSelector((s) => s.auth.accessToken);
|
|
const isActive = currentTrackId === track.id;
|
|
// Prefer an explicit album art URL; otherwise serve the track's own cover
|
|
// (needs the token in the query string — `<img>` can't send a header).
|
|
const artUrl =
|
|
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({
|
|
trackId: track.id,
|
|
title: track.title,
|
|
artistName: track.artistName,
|
|
albumTitle: track.albumTitle,
|
|
durationMs: track.durationMs,
|
|
albumArtUrl: track.albumArtUrl,
|
|
}),
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Row
|
|
selected={isActive}
|
|
style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: hideArt
|
|
? '2.5rem 1fr auto auto'
|
|
: '2rem 2.5rem 1fr auto auto',
|
|
gap: '0.75rem',
|
|
alignItems: 'center',
|
|
padding: '0.375rem 0.75rem',
|
|
cursor: 'default',
|
|
}}
|
|
>
|
|
{!hideArt && (
|
|
<span
|
|
style={{
|
|
fontSize: '0.75rem',
|
|
color: 'var(--color-text-3)',
|
|
textAlign: 'right',
|
|
}}
|
|
>
|
|
{isActive && isPlaying ? '▶' : index !== undefined ? index + 1 : ''}
|
|
</span>
|
|
)}
|
|
<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
|
|
src={artUrl}
|
|
alt=""
|
|
width={36}
|
|
height={36}
|
|
style={{ borderRadius: 4, objectFit: 'cover' }}
|
|
/>
|
|
) : (
|
|
<div
|
|
style={{
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 4,
|
|
background: 'var(--color-surface-3)',
|
|
}}
|
|
/>
|
|
)}
|
|
{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={{
|
|
fontWeight: isActive ? 600 : 400,
|
|
color: isActive ? 'var(--color-accent)' : 'var(--color-text-1)',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
{track.title}
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontSize: '0.8125rem',
|
|
color: 'var(--color-text-2)',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
{track.artistName}
|
|
{showAlbum && ` · ${track.albumTitle}`}
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
|
<MetadataStatusBadge
|
|
status={track.metadataStatus}
|
|
error={track.metadataError}
|
|
iconOnly
|
|
/>
|
|
<AvailabilityBadge availability={displayAvailability} iconOnly />
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
|
<span
|
|
style={{
|
|
fontSize: '0.8125rem',
|
|
color: 'var(--color-text-3)',
|
|
minWidth: '3rem',
|
|
textAlign: 'right',
|
|
}}
|
|
>
|
|
{formatDuration(track.durationMs)}
|
|
</span>
|
|
<TrackContextMenu
|
|
track={track}
|
|
onAddToPlaylist={onAddToPlaylist}
|
|
onEditMetadata={onEditMetadata}
|
|
onDelete={onDelete}
|
|
/>
|
|
</div>
|
|
</Row>
|
|
);
|
|
}
|