From c1403f8bd6cacebad3b9ca96953de212a7f58c81 Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Sun, 14 Jun 2026 01:30:25 +0200 Subject: [PATCH] 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) --- CHANGELOG.md | 15 ++++++++ src/api/nowPlayingPresence.test.ts | 29 ++++++++++++++++ src/api/nowPlayingPresence.ts | 34 +++++++++++++++++++ src/components/NowPlayingDropdown.tsx | 28 +++++++++------ src/components/internetRadio/RadioCard.tsx | 34 +++++++++++-------- src/hooks/useNavidromeAdminRole.test.ts | 14 +++++++- src/hooks/useNavidromeAdminRole.ts | 11 ++++++ src/locales/de/nowPlaying.ts | 6 +++- src/locales/en/nowPlaying.ts | 6 +++- src/locales/es/nowPlaying.ts | 6 +++- src/locales/fr/nowPlaying.ts | 6 +++- src/locales/nb/nowPlaying.ts | 6 +++- src/locales/nl/nowPlaying.ts | 6 +++- src/locales/ro/nowPlaying.ts | 6 +++- src/locales/ru/nowPlaying.ts | 6 +++- src/locales/zh/nowPlaying.ts | 6 +++- src/pages/InternetRadio.tsx | 26 ++++++++------ src/styles/components/index.css | 1 + .../components/now-playing-presence.css | 29 ++++++++++++++++ 19 files changed, 231 insertions(+), 44 deletions(-) create mode 100644 src/api/nowPlayingPresence.test.ts create mode 100644 src/api/nowPlayingPresence.ts create mode 100644 src/styles/components/now-playing-presence.css diff --git a/CHANGELOG.md b/CHANGELOG.md index 31bfb0e5..58a69865 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)** diff --git a/src/api/nowPlayingPresence.test.ts b/src/api/nowPlayingPresence.test.ts new file mode 100644 index 00000000..50efeb96 --- /dev/null +++ b/src/api/nowPlayingPresence.test.ts @@ -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'); + }); +}); diff --git a/src/api/nowPlayingPresence.ts b/src/api/nowPlayingPresence.ts new file mode 100644 index 00000000..b2ca65fa --- /dev/null +++ b/src/api/nowPlayingPresence.ts @@ -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'; +} diff --git a/src/components/NowPlayingDropdown.tsx b/src/components/NowPlayingDropdown.tsx index 366a0189..8a94a37d 100644 --- a/src/components/NowPlayingDropdown.tsx +++ b/src/components/NowPlayingDropdown.tsx @@ -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() { ) : (
- {visible.map((stream, idx) => ( + {visible.map((stream, idx) => { + const presence = nowPlayingPresence(stream); + const presenceLabel = t(`nowPlaying.presence.${presence}`); + return (
{ if (stream.albumId) { setIsOpen(false); navigate(`/album/${stream.albumId}`); } }} @@ -217,19 +221,22 @@ export default function NowPlayingDropdown() { )}
-
{stream.title}
+
+ + {stream.title} +
{stream.artist}
{stream.username} ({stream.playerName || 'Web'})
- {stream.minutesAgo > 0 && ( -
- - {t('nowPlaying.minutesAgo', { n: stream.minutesAgo })} -
- )} {(() => { const posSec = livePositionSec(stream); if (posSec === undefined || stream.duration <= 0) return null; @@ -261,7 +268,8 @@ export default function NowPlayingDropdown() {
- ))} + ); + })} )} , diff --git a/src/components/internetRadio/RadioCard.tsx b/src/components/internetRadio/RadioCard.tsx index 9d54f30e..3cc994ba 100644 --- a/src/components/internetRadio/RadioCard.tsx +++ b/src/components/internetRadio/RadioCard.tsx @@ -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({ -
- -
+ {canManage && ( +
+ +
+ )} {/* Info */}
{s.name}
- + {canManage && ( + + )} - -
+ {canManage && ( +
+ + +
+ )}
{/* ── 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 && ( setModalStation(null)} @@ -298,7 +304,7 @@ export default function InternetRadio() { )} {/* ── Directory Modal ── */} - {browseOpen && ( + {canManage && browseOpen && ( setBrowseOpen(false)} onAdded={reload} diff --git a/src/styles/components/index.css b/src/styles/components/index.css index 73fdead6..3e2ed9ee 100644 --- a/src/styles/components/index.css +++ b/src/styles/components/index.css @@ -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'; diff --git a/src/styles/components/now-playing-presence.css b/src/styles/components/now-playing-presence.css new file mode 100644 index 00000000..99b0a852 --- /dev/null +++ b/src/styles/components/now-playing-presence.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; +}