mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +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:
@@ -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>,
|
||||
|
||||
@@ -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(); }}
|
||||
|
||||
Reference in New Issue
Block a user