mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user