feat(queue): collapse ReplayGain into a click-to-expand badge

Per @cucadmuh's feedback: the presence of ReplayGain matters more than
the actual numbers. The tech bar now shows a small 'RG' pill on line 1
whenever the track has any RG metadata; hovering reveals the values via
tooltip, clicking persistently expands the second line.

- Source icon stays inline before the format string (was getting
  separated from FLAC by the centred stack layout)
- Pill uses --accent-tinted color-mix so it adapts to every theme
- ChevronDown rotates on expand for a clear affordance
- New persisted state: themeStore.expandReplayGain (default collapsed)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-19 12:34:52 +02:00
parent 6456f13bde
commit e07ef0ebf1
3 changed files with 86 additions and 24 deletions
+40 -21
View File
@@ -1,6 +1,6 @@
import React, { useState, useRef, useMemo } from 'react'; import React, { useState, useRef, useMemo } from 'react';
import { Track, usePlayerStore, songToTrack } from '../store/playerStore'; import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, ArrowUpToLine, Radio, HardDrive } from 'lucide-react'; import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, ArrowUpToLine, Radio, HardDrive, ChevronDown } from 'lucide-react';
import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic'; import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
import { usePlaylistStore } from '../store/playlistStore'; import { usePlaylistStore } from '../store/playlistStore';
import { useCachedUrl } from './CachedImage'; import { useCachedUrl } from './CachedImage';
@@ -8,6 +8,7 @@ import { useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useThemeStore } from '../store/themeStore';
import { useLyricsStore } from '../store/lyricsStore'; import { useLyricsStore } from '../store/lyricsStore';
import { useDragDrop } from '../contexts/DragDropContext'; import { useDragDrop } from '../contexts/DragDropContext';
import LyricsPane from './LyricsPane'; import LyricsPane from './LyricsPane';
@@ -267,6 +268,8 @@ export default function QueuePanel() {
const [showRemainingTime, setShowRemainingTime] = useState(false); const [showRemainingTime, setShowRemainingTime] = useState(false);
const [showCrossfadePopover, setShowCrossfadePopover] = useState(false); const [showCrossfadePopover, setShowCrossfadePopover] = useState(false);
const expandReplayGain = useThemeStore(s => s.expandReplayGain);
const setExpandReplayGain = useThemeStore(s => s.setExpandReplayGain);
const crossfadeBtnRef = useRef<HTMLButtonElement>(null); const crossfadeBtnRef = useRef<HTMLButtonElement>(null);
const crossfadePopoverRef = useRef<HTMLDivElement>(null); const crossfadePopoverRef = useRef<HTMLDivElement>(null);
@@ -454,28 +457,44 @@ export default function QueuePanel() {
const baseLine = baseParts.join(' · '); const baseLine = baseParts.join(' · ');
const rgLine = rgParts.join(' · '); const rgLine = rgParts.join(' · ');
if (!baseLine && !rgLine && !playbackSource) return null; if (!baseLine && !rgLine && !playbackSource) return null;
const showRgLine = expandReplayGain && !!rgLine;
return ( return (
<div className={`queue-current-tech${rgLine ? ' queue-current-tech--two-line' : ''}`}> <div className={`queue-current-tech${showRgLine ? ' queue-current-tech--two-line' : ''}`}>
{playbackSource && (
<span
className="queue-current-tech-source"
data-tooltip={
playbackSource === 'offline'
? t('queue.sourceOffline')
: playbackSource === 'hot'
? t('queue.sourceHot')
: t('queue.sourceStream')
}
aria-hidden
>
{playbackSource === 'offline' && <FolderOpen size={11} strokeWidth={2.25} />}
{playbackSource === 'hot' && <HardDrive size={11} strokeWidth={2.25} />}
{playbackSource === 'stream' && <Waves size={11} strokeWidth={2.25} />}
</span>
)}
<div className="queue-current-tech-stack"> <div className="queue-current-tech-stack">
{baseLine && <span className="queue-current-tech-main">{baseLine}</span>} <div className="queue-current-tech-row">
{rgLine && ( {playbackSource && (
<span
className="queue-current-tech-source"
data-tooltip={
playbackSource === 'offline'
? t('queue.sourceOffline')
: playbackSource === 'hot'
? t('queue.sourceHot')
: t('queue.sourceStream')
}
aria-hidden
>
{playbackSource === 'offline' && <FolderOpen size={11} strokeWidth={2.25} />}
{playbackSource === 'hot' && <HardDrive size={11} strokeWidth={2.25} />}
{playbackSource === 'stream' && <Waves size={11} strokeWidth={2.25} />}
</span>
)}
{baseLine && <span className="queue-current-tech-main">{baseLine}</span>}
{rgLine && (
<button
type="button"
className={`queue-current-tech-rg-badge${showRgLine ? ' queue-current-tech-rg-badge--open' : ''}`}
data-tooltip={`${t('queue.replayGain')} · ${rgLine}`}
aria-expanded={showRgLine}
aria-label={t('queue.replayGain')}
onClick={() => setExpandReplayGain(!expandReplayGain)}
>
RG
<ChevronDown size={9} strokeWidth={2.5} />
</button>
)}
</div>
{showRgLine && (
<span className="queue-current-tech-rg"> <span className="queue-current-tech-rg">
<span className="queue-current-tech-rg-label">{t('queue.replayGain')}</span> <span className="queue-current-tech-rg-label">{t('queue.replayGain')}</span>
{' · '}{rgLine} {' · '}{rgLine}
+4
View File
@@ -24,6 +24,8 @@ interface ThemeState {
setShowBitrate: (v: boolean) => void; setShowBitrate: (v: boolean) => void;
showRemainingTime: boolean; showRemainingTime: boolean;
setShowRemainingTime: (v: boolean) => void; setShowRemainingTime: (v: boolean) => void;
expandReplayGain: boolean;
setExpandReplayGain: (v: boolean) => void;
} }
export function getScheduledTheme(state: Pick<ThemeState, 'enableThemeScheduler' | 'theme' | 'themeDay' | 'themeNight' | 'timeDayStart' | 'timeNightStart'>): string { export function getScheduledTheme(state: Pick<ThemeState, 'enableThemeScheduler' | 'theme' | 'themeDay' | 'themeNight' | 'timeDayStart' | 'timeNightStart'>): string {
@@ -63,6 +65,8 @@ export const useThemeStore = create<ThemeState>()(
setShowBitrate: (v) => set({ showBitrate: v }), setShowBitrate: (v) => set({ showBitrate: v }),
showRemainingTime: false, showRemainingTime: false,
setShowRemainingTime: (v) => set({ showRemainingTime: v }), setShowRemainingTime: (v) => set({ showRemainingTime: v }),
expandReplayGain: false,
setExpandReplayGain: (v) => set({ expandReplayGain: v }),
}), }),
{ {
name: 'psysonic_theme', name: 'psysonic_theme',
+42 -3
View File
@@ -1725,9 +1725,10 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
/* Two-line layout: base info on top, ReplayGain underneath. The stack /* Two-line layout: base info + RG badge on top, optional ReplayGain
wraps both spans so the source icon stays vertically centered next to values underneath when the badge is expanded. The stack wraps both
the whole text block. */ rows so the source icon stays vertically centered next to the whole
text block. */
.queue-current-tech-stack { .queue-current-tech-stack {
min-width: 0; min-width: 0;
display: flex; display: flex;
@@ -1736,6 +1737,44 @@
gap: 1px; gap: 1px;
} }
.queue-current-tech-row {
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
/* Pill-shaped toggle that signals "this track has ReplayGain metadata"
without committing screen space to the numbers — values appear in the
tooltip and on the second line when expanded. */
.queue-current-tech-rg-badge {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 2px;
padding: 1px 5px 1px 6px;
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
background: color-mix(in srgb, var(--accent) 12%, transparent);
color: var(--accent);
border-radius: 999px;
font: inherit;
font-size: 8px;
letter-spacing: 0.06em;
cursor: pointer;
transition: background 0.12s, border-color 0.12s, transform 0.12s;
}
.queue-current-tech-rg-badge:hover {
background: color-mix(in srgb, var(--accent) 22%, transparent);
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
}
.queue-current-tech-rg-badge svg {
transition: transform 0.18s ease;
}
.queue-current-tech-rg-badge--open svg {
transform: rotate(180deg);
}
.queue-current-tech-rg { .queue-current-tech-rg {
min-width: 0; min-width: 0;
max-width: 100%; max-width: 100%;