feat: now-playing liveness dot + admin-gated radio management (#1086)

* feat(now-playing): liveness indicator dot in the listening popover

Replace the raw "Nm ago" line in the "Who is listening?" popover with a
derived presence dot (green playing / amber paused / dim idle). The presence
is computed in one tested helper that unifies the playbackReport transport
state with the legacy getNowPlaying recency, instead of formatting a raw
timestamp inline. The dot carries the localized status as an aria-label and
tooltip so it is not conveyed by colour alone.

* feat(radio): gate station create/edit/delete behind Navidrome admin role

Navidrome >= 0.62 restricts internet-radio management to admins
(GHSA-jw24-qqrj-633c); non-admin requests fail. Hide Add Station, Search
Directory, the per-card edit chip and delete button for confirmed standard
Navidrome users via a canManageNavidromeRadio() helper on the existing
useNavidromeAdminRole framework. Admins, non-Navidrome servers and transient
states stay unrestricted; playback and favourites remain available to all.

* docs(changelog): now-playing status dot + admin-gated radio (#1086)
This commit is contained in:
Psychotoxical
2026-06-14 01:30:25 +02:00
committed by GitHub
parent 0b7d9eae2d
commit c1403f8bd6
19 changed files with 231 additions and 44 deletions
+18 -10
View File
@@ -4,7 +4,8 @@ import { getNowPlaying } from '../api/subsonicScrobble';
import type { SubsonicNowPlaying } from '../api/subsonicTypes';
import React, { useState, useEffect, useRef, useCallback, useLayoutEffect } from 'react';
import { createPortal } from 'react-dom';
import { PlayCircle, Pause, User, Clock, Radio, RefreshCw } from 'lucide-react';
import { PlayCircle, Pause, User, Radio, RefreshCw } from 'lucide-react';
import { nowPlayingPresence } from '../api/nowPlayingPresence';
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
@@ -192,7 +193,10 @@ export default function NowPlayingDropdown() {
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{visible.map((stream, idx) => (
{visible.map((stream, idx) => {
const presence = nowPlayingPresence(stream);
const presenceLabel = t(`nowPlaying.presence.${presence}`);
return (
<div
key={`${stream.id}-${idx}`}
onClick={() => { if (stream.albumId) { setIsOpen(false); navigate(`/album/${stream.albumId}`); } }}
@@ -217,19 +221,22 @@ export default function NowPlayingDropdown() {
)}
</div>
<div style={{ minWidth: 0, flex: 1, display: 'flex', flexDirection: 'column', gap: '2px' }}>
<div className="truncate" style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-primary)' }}>{stream.title}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0 }}>
<span
className={`now-playing-led now-playing-led--${presence}`}
role="img"
aria-label={presenceLabel}
data-tooltip={presenceLabel}
data-tooltip-pos="bottom"
/>
<span className="truncate" style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-primary)' }}>{stream.title}</span>
</div>
<div className="truncate" style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>{stream.artist}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px', marginTop: '2px', fontSize: '11px', color: 'var(--text-secondary)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', minWidth: 0 }}>
<User size={10} style={{ flexShrink: 0 }} />
<span className="truncate">{stream.username} ({stream.playerName || 'Web'})</span>
</div>
{stream.minutesAgo > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', minWidth: 0 }}>
<Clock size={10} style={{ flexShrink: 0 }} />
<span className="truncate">{t('nowPlaying.minutesAgo', { n: stream.minutesAgo })}</span>
</div>
)}
{(() => {
const posSec = livePositionSec(stream);
if (posSec === undefined || stream.duration <= 0) return null;
@@ -261,7 +268,8 @@ export default function NowPlayingDropdown() {
</div>
</div>
</div>
))}
);
})}
</div>
)}
</div>,
+20 -14
View File
@@ -16,6 +16,8 @@ interface RadioCardProps {
deleteConfirmId: string | null;
isFavorite: boolean;
isManual: boolean;
/** Navidrome ≥ 0.62 only lets admins manage stations — hides edit/delete. */
canManage: boolean;
dropIndicator: 'before' | 'after' | null;
onPlay: (e: React.MouseEvent) => void;
onDelete: (e: React.MouseEvent) => void;
@@ -28,7 +30,7 @@ interface RadioCardProps {
}
export default function RadioCard({
s, isActive, isPlaying, deleteConfirmId, isFavorite, isManual, dropIndicator,
s, isActive, isPlaying, deleteConfirmId, isFavorite, isManual, canManage, dropIndicator,
onPlay, onDelete, onEdit, onFavoriteToggle, onDragEnter, onDragLeave,
onDropOnto, onCardMouseLeave,
}: RadioCardProps) {
@@ -117,25 +119,29 @@ export default function RadioCard({
</button>
</div>
<div className="playlist-card-actions">
<button
className={`playlist-card-action playlist-card-action--delete ${deleteConfirmId === s.id ? 'playlist-card-action--delete-confirm' : ''}`}
onClick={onDelete}
data-tooltip={deleteConfirmId === s.id ? t('radio.confirmDelete') : t('radio.deleteStation')}
data-tooltip-pos="bottom"
>
{deleteConfirmId === s.id ? <Trash2 size={12} /> : <X size={12} />}
</button>
</div>
{canManage && (
<div className="playlist-card-actions">
<button
className={`playlist-card-action playlist-card-action--delete ${deleteConfirmId === s.id ? 'playlist-card-action--delete-confirm' : ''}`}
onClick={onDelete}
data-tooltip={deleteConfirmId === s.id ? t('radio.confirmDelete') : t('radio.deleteStation')}
data-tooltip-pos="bottom"
>
{deleteConfirmId === s.id ? <Trash2 size={12} /> : <X size={12} />}
</button>
</div>
)}
</div>
{/* Info */}
<div className="album-card-info">
<div className="album-card-title">{s.name}</div>
<div className="album-card-artist" style={{ display: 'flex', gap: '0.35rem', alignItems: 'center' }}>
<button className="radio-card-chip" onClick={onEdit}>
{t('radio.editStation')}
</button>
{canManage && (
<button className="radio-card-chip" onClick={onEdit}>
{t('radio.editStation')}
</button>
)}
<button
className={`player-btn player-btn-sm radio-favorite-btn${isFavorite ? ' active' : ''}`}
onClick={e => { e.stopPropagation(); onFavoriteToggle(); }}