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
+15
View File
@@ -149,6 +149,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Each strategy button has a short tooltip; **Advanced** mode adds an optional **Fine adjustment** toggle (0.01× / 0.01 st steps) in **Settings → Audio**.
### Now Playing — live status dot in "Who is listening?"
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1086](https://github.com/Psychotoxical/psysonic/pull/1086)**
* Each listener in the **Who is listening?** popover now shows a small status dot — playing, paused, or idle — derived from the live playback report, replacing the previous "minutes ago" line. The status is also read out for screen readers and on hover, so it is never conveyed by colour alone.
## Changed
### Dependencies — npm and Rust refresh
@@ -219,6 +227,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Fixed
### Internet Radio — station management limited to server admins
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1086](https://github.com/Psychotoxical/psysonic/pull/1086)**
* Navidrome 0.62 made creating, editing and deleting radio stations admin-only, so those actions failed for standard accounts. Add, edit and delete controls are now hidden for non-admin Navidrome users; playback and favourites stay available to everyone. Other server types are unaffected.
### Music Network — self-hosted scrobbling reaches the server
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1085](https://github.com/Psychotoxical/psysonic/pull/1085)**
+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;
}