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';
}