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:
@@ -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