mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +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:
@@ -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)**
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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(); }}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(/\/$/, '');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -3,7 +3,11 @@ export const nowPlaying = {
|
||||
title: 'Кто сейчас слушает?',
|
||||
loading: 'Загрузка…',
|
||||
nobody: 'Сейчас никто не слушает.',
|
||||
minutesAgo: '{{n}} мин назад',
|
||||
presence: {
|
||||
playing: 'Играет',
|
||||
paused: 'Пауза',
|
||||
idle: 'Неактивен',
|
||||
},
|
||||
nothingPlaying: 'Пока ничего не играет — включите трек.',
|
||||
aboutArtist: 'Об исполнителе',
|
||||
fromAlbum: 'Из этого альбома',
|
||||
|
||||
@@ -3,7 +3,11 @@ export const nowPlaying = {
|
||||
title: '谁正在收听?',
|
||||
loading: '加载中…',
|
||||
nobody: '当前无人收听。',
|
||||
minutesAgo: '{{n}} 分钟前',
|
||||
presence: {
|
||||
playing: '播放中',
|
||||
paused: '已暂停',
|
||||
idle: '空闲',
|
||||
},
|
||||
nothingPlaying: '尚未开始播放。开始播放一首歌曲吧!',
|
||||
aboutArtist: '关于艺术家',
|
||||
fromAlbum: '来自此专辑',
|
||||
|
||||
+16
-10
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user