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
+16 -10
View File
@@ -20,10 +20,13 @@ import RadioEditModal from '../components/internetRadio/RadioEditModal';
import RadioDirectoryModal from '../components/internetRadio/RadioDirectoryModal';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { VirtualCardGrid } from '../components/VirtualCardGrid';
import { useNavidromeAdminRole, canManageNavidromeRadio } from '../hooks/useNavidromeAdminRole';
export default function InternetRadio() {
const { t } = useTranslation();
const perfFlags = usePerfProbeFlags();
// Navidrome ≥ 0.62: only admins may create/edit/delete radio stations.
const canManage = canManageNavidromeRadio(useNavidromeAdminRole());
const playRadio = usePlayerStore(s => s.playRadio);
const stop = usePlayerStore(s => s.stop);
const currentRadio = usePlayerStore(s => s.currentRadio);
@@ -229,14 +232,16 @@ export default function InternetRadio() {
{/* ── Header ── */}
<div className="playlists-header">
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('radio.title')}</h1>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary" onClick={() => setBrowseOpen(true)}>
<Search size={14} /> {t('radio.browseDirectory')}
</button>
<button className="btn btn-primary" onClick={() => setModalStation('new')}>
<Plus size={15} /> {t('radio.addStation')}
</button>
</div>
{canManage && (
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary" onClick={() => setBrowseOpen(true)}>
<Search size={14} /> {t('radio.browseDirectory')}
</button>
<button className="btn btn-primary" onClick={() => setModalStation('new')}>
<Plus size={15} /> {t('radio.addStation')}
</button>
</div>
)}
</div>
{/* ── Toolbar + Grid ── */}
@@ -272,6 +277,7 @@ export default function InternetRadio() {
deleteConfirmId={deleteConfirmId}
isFavorite={favorites.has(s.id)}
isManual={sortBy === 'manual'}
canManage={canManage}
dropIndicator={dragOver?.id === s.id ? dragOver.side : null}
onPlay={e => handlePlay(e, s)}
onDelete={e => handleDelete(e, s)}
@@ -289,7 +295,7 @@ export default function InternetRadio() {
)}
{/* ── Edit/Create Modal ── */}
{modalStation !== null && (
{canManage && modalStation !== null && (
<RadioEditModal
station={modalStation === 'new' ? null : modalStation}
onClose={() => setModalStation(null)}
@@ -298,7 +304,7 @@ export default function InternetRadio() {
)}
{/* ── Directory Modal ── */}
{browseOpen && (
{canManage && browseOpen && (
<RadioDirectoryModal
onClose={() => setBrowseOpen(false)}
onAdded={reload}