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
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { nowPlayingPresence, NOW_PLAYING_IDLE_MINUTES } from './nowPlayingPresence';
import type { SubsonicNowPlaying, PlaybackReportState } from './subsonicTypes';
// The function only reads `state` and `minutesAgo`; cast a minimal fixture.
function entry(partial: { state?: PlaybackReportState; minutesAgo?: number }): SubsonicNowPlaying {
return { minutesAgo: 0, ...partial } as SubsonicNowPlaying;
}
describe('nowPlayingPresence', () => {
it('maps live playbackReport state authoritatively', () => {
expect(nowPlayingPresence(entry({ state: 'playing' }))).toBe('playing');
expect(nowPlayingPresence(entry({ state: 'starting' }))).toBe('playing');
expect(nowPlayingPresence(entry({ state: 'paused' }))).toBe('paused');
expect(nowPlayingPresence(entry({ state: 'stopped' }))).toBe('idle');
});
it('live state wins over a stale minutesAgo', () => {
// A paused session last reported minutes ago is still "paused", not idle.
expect(nowPlayingPresence(entry({ state: 'paused', minutesAgo: 99 }))).toBe('paused');
expect(nowPlayingPresence(entry({ state: 'playing', minutesAgo: 99 }))).toBe('playing');
});
it('falls back to recency for legacy entries without a state', () => {
expect(nowPlayingPresence(entry({ minutesAgo: 0 }))).toBe('playing');
expect(nowPlayingPresence(entry({ minutesAgo: NOW_PLAYING_IDLE_MINUTES }))).toBe('playing');
expect(nowPlayingPresence(entry({ minutesAgo: NOW_PLAYING_IDLE_MINUTES + 1 }))).toBe('idle');
});
});
+34
View File
@@ -0,0 +1,34 @@
import type { SubsonicNowPlaying } from './subsonicTypes';
/**
* Derived liveness of a now-playing entry, surfaced as the indicator dot in the
* "Who is listening?" popover. Unifies the two sources the server can provide:
* the OpenSubsonic `playbackReport` transport state (Navidrome ≥ 0.62, when
* present) and the classic `getNowPlaying` `minutesAgo` recency (legacy
* fallback). This replaces rendering the raw "Nm ago" line.
*/
export type NowPlayingPresence = 'playing' | 'paused' | 'idle';
/**
* Minutes since last activity beyond which a legacy entry (one without
* playbackReport transport state) is treated as idle rather than actively
* playing. Entries that carry a live `state` ignore this entirely.
*/
export const NOW_PLAYING_IDLE_MINUTES = 5;
/** Resolve the liveness an entry should display. Live `state` is authoritative;
* otherwise recency (`minutesAgo`) decides playing vs idle. */
export function nowPlayingPresence(entry: SubsonicNowPlaying): NowPlayingPresence {
// playbackReport extension: trust the reported transport state.
switch (entry.state) {
case 'playing':
case 'starting':
return 'playing';
case 'paused':
return 'paused';
case 'stopped':
return 'idle';
}
// Legacy getNowPlaying: only recency is known. Recent = playing, stale = idle.
return entry.minutesAgo > NOW_PLAYING_IDLE_MINUTES ? 'idle' : 'playing';
}
+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(); }}
+13 -1
View File
@@ -8,7 +8,7 @@ vi.mock('@/api/navidromeAdmin', () => ({
}));
import { ndLogin } from '@/api/navidromeAdmin';
import { useNavidromeAdminRole } from './useNavidromeAdminRole';
import { useNavidromeAdminRole, canManageNavidromeRadio } from './useNavidromeAdminRole';
beforeEach(() => {
resetAuthStore();
@@ -112,3 +112,15 @@ describe('useNavidromeAdminRole', () => {
await waitFor(() => expect(result.current).toBe('error'));
});
});
describe('canManageNavidromeRadio', () => {
it('blocks only a confirmed standard Navidrome user', () => {
expect(canManageNavidromeRadio('user')).toBe(false);
});
it('allows admins, non-Navidrome servers, and transient/unknown states', () => {
for (const role of ['admin', 'na', 'idle', 'checking', 'error'] as const) {
expect(canManageNavidromeRadio(role)).toBe(true);
}
});
});
+11
View File
@@ -5,6 +5,17 @@ import { isNavidromeServer } from '../utils/server/subsonicServerIdentity';
export type NavidromeAdminRole = 'idle' | 'checking' | 'admin' | 'user' | 'na' | 'error';
/**
* Navidrome ≥ 0.62 restricts internet-radio management (create/update/delete) to
* admins (GHSA-jw24-qqrj-633c). Block those actions only for a *confirmed*
* standard Navidrome user; everything else — admin, non-Navidrome servers
* (`'na'`), and transient/unknown states — stays allowed, with the server as the
* final authority. Non-Navidrome servers never carried this restriction.
*/
export function canManageNavidromeRadio(role: NavidromeAdminRole): boolean {
return role !== 'user';
}
function normalizeServerUrl(url: string): string {
const withScheme = url.startsWith('http') ? url : `http://${url}`;
return withScheme.replace(/\/$/, '');
+5 -1
View File
@@ -3,7 +3,11 @@ export const nowPlaying = {
title: 'Wer hört was?',
loading: 'Lädt…',
nobody: 'Gerade hört niemand Musik.',
minutesAgo: 'vor {{n}}m',
presence: {
playing: 'Spielt',
paused: 'Pausiert',
idle: 'Inaktiv',
},
nothingPlaying: 'Noch nichts am Laufen. Leg einen Track auf!',
aboutArtist: 'Über den Künstler',
fromAlbum: 'Aus diesem Album',
+5 -1
View File
@@ -3,7 +3,11 @@ export const nowPlaying = {
title: 'Who is listening?',
loading: 'Loading…',
nobody: 'Nobody is currently listening.',
minutesAgo: '{{n}}m ago',
presence: {
playing: 'Playing',
paused: 'Paused',
idle: 'Idle',
},
nothingPlaying: 'Nothing playing yet. Start a track!',
aboutArtist: 'About the Artist',
fromAlbum: 'From this Album',
+5 -1
View File
@@ -3,7 +3,11 @@ export const nowPlaying = {
title: '¿Quién está escuchando?',
loading: 'Cargando…',
nobody: 'Nadie está escuchando actualmente.',
minutesAgo: 'hace {{n}}m',
presence: {
playing: 'Reproduciendo',
paused: 'En pausa',
idle: 'Inactivo',
},
nothingPlaying: 'Aún no se está reproduciendo nada. ¡Empieza una canción!',
aboutArtist: 'Sobre el Artista',
fromAlbum: 'De este Álbum',
+5 -1
View File
@@ -3,7 +3,11 @@ export const nowPlaying = {
title: 'Qui écoute ?',
loading: 'Chargement…',
nobody: 'Personne n\'écoute en ce moment.',
minutesAgo: 'il y a {{n}} min',
presence: {
playing: 'En lecture',
paused: 'En pause',
idle: 'Inactif',
},
nothingPlaying: 'Rien en cours. Lancez un morceau !',
aboutArtist: "À propos de l'artiste",
fromAlbum: 'De cet album',
+5 -1
View File
@@ -3,7 +3,11 @@ export const nowPlaying = {
title: 'Hvem lytter?',
loading: 'Laster…',
nobody: 'Ingen lyttere for øyeblikket.',
minutesAgo: '{{n}}m siden',
presence: {
playing: 'Spiller',
paused: 'Pauset',
idle: 'Inaktiv',
},
nothingPlaying: 'Ingenting spiller akkurat å. Start et spor!',
aboutArtist: 'Om artisten',
fromAlbum: 'Fra dette albumet',
+5 -1
View File
@@ -3,7 +3,11 @@ export const nowPlaying = {
title: 'Wie luistert er?',
loading: 'Laden…',
nobody: 'Er luistert momenteel niemand.',
minutesAgo: '{{n}} min geleden',
presence: {
playing: 'Speelt',
paused: 'Gepauzeerd',
idle: 'Inactief',
},
nothingPlaying: 'Nog niets bezig. Start een nummer!',
aboutArtist: 'Over de artiest',
fromAlbum: 'Van dit album',
+5 -1
View File
@@ -3,7 +3,11 @@ export const nowPlaying = {
title: 'Cine ascultă?',
loading: 'Se încarcă…',
nobody: 'Nimeni nu ascultă momentan.',
minutesAgo: 'acum {{n}}m',
presence: {
playing: 'Redă',
paused: 'Pe pauză',
idle: 'Inactiv',
},
nothingPlaying: 'Nu se redă nimic încă. Începe o piesă!',
aboutArtist: 'Despre Artist',
fromAlbum: 'Din acest Album',
+5 -1
View File
@@ -3,7 +3,11 @@ export const nowPlaying = {
title: 'Кто сейчас слушает?',
loading: 'Загрузка…',
nobody: 'Сейчас никто не слушает.',
minutesAgo: '{{n}} мин назад',
presence: {
playing: 'Играет',
paused: 'Пауза',
idle: 'Неактивен',
},
nothingPlaying: 'Пока ничего не играет — включите трек.',
aboutArtist: 'Об исполнителе',
fromAlbum: 'Из этого альбома',
+5 -1
View File
@@ -3,7 +3,11 @@ export const nowPlaying = {
title: '谁正在收听?',
loading: '加载中…',
nobody: '当前无人收听。',
minutesAgo: '{{n}} 分钟前',
presence: {
playing: '播放中',
paused: '已暂停',
idle: '空闲',
},
nothingPlaying: '尚未开始播放。开始播放一首歌曲吧!',
aboutArtist: '关于艺术家',
fromAlbum: '来自此专辑',
+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}
+1
View File
@@ -36,6 +36,7 @@
@import './playlists-page.css';
@import './statistics-page.css';
@import './connection-indicator.css';
@import './now-playing-presence.css';
@import './offline-overlay.css';
@import './now-playing-page.css';
@import './now-playing-page-2.css';
@@ -0,0 +1,29 @@
/* ─ Now Playing presence LED ─
* Liveness dot in the "Who is listening?" popover. Mirrors the connection LED
* status colours; the derived presence comes from `nowPlayingPresence()`.
* Status is also exposed as the dot's aria-label / tooltip so it is never
* conveyed by colour alone. */
.now-playing-led {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
background: var(--text-muted);
transition: background 0.4s ease, box-shadow 0.4s ease;
}
.now-playing-led--playing {
background: #a6e3a1;
box-shadow: 0 0 6px 2px rgba(166, 227, 161, 0.5);
}
.now-playing-led--paused {
background: #f9e2af;
box-shadow: 0 0 5px 1px rgba(249, 226, 175, 0.45);
}
.now-playing-led--idle {
background: var(--text-muted);
box-shadow: none;
opacity: 0.5;
}