feat: Last.fm beta, Similar Artists, Statistics Last.fm stats, TooltipPortal, CustomSelect, Psychowave theme (v1.7.0)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-03-20 18:35:26 +01:00
parent 9400a5fb2b
commit 2ba7845c79
31 changed files with 1379 additions and 365 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
: t('connection.checking');
return (
<div className="connection-indicator" title={title}>
<div className="connection-indicator" data-tooltip={title} data-tooltip-pos="bottom">
<div className={`connection-led connection-led--${status}`} />
<div className="connection-meta">
<span className="connection-type">{label}</span>
+33 -2
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { Play, ListPlus, Radio, Star, Download, ChevronRight, User, Disc3 } from 'lucide-react';
import { Play, ListPlus, Radio, Star, Download, ChevronRight, User, Disc3, Heart } from 'lucide-react';
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
import { usePlayerStore, Track } from '../store/playerStore';
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum } from '../api/subsonic';
import { useNavigate } from 'react-router-dom';
@@ -20,7 +21,7 @@ function sanitizeFilename(name: string): string {
export default function ContextMenu() {
const { t } = useTranslation();
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack } = usePlayerStore();
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong } = usePlayerStore();
const auth = useAuthStore();
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const navigate = useNavigate();
@@ -150,6 +151,21 @@ export default function ContextMenu() {
<div className="context-menu-item" onClick={() => handleAction(() => star(song.id, 'song'))}>
<Star size={14} /> {t('contextMenu.favorite')}
</div>
{auth.lastfmSessionKey && (() => {
const loveKey = `${song.title}::${song.artist}`;
const loved = lastfmLovedCache[loveKey] ?? false;
return (
<div className="context-menu-item" onClick={() => handleAction(() => {
const newLoved = !loved;
setLastfmLovedForSong(song.title, song.artist, newLoved);
if (newLoved) lastfmLoveTrack(song, auth.lastfmSessionKey);
else lastfmUnloveTrack(song, auth.lastfmSessionKey);
})}>
<Heart size={14} fill={loved ? 'currentColor' : 'none'} style={{ color: loved ? '#e31c23' : undefined }} />
{loved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
</div>
);
})()}
</>
);
})()}
@@ -211,6 +227,21 @@ export default function ContextMenu() {
<div className="context-menu-item" onClick={() => handleAction(() => star(song.id, 'song'))}>
<Star size={14} /> {t('contextMenu.favorite')}
</div>
{auth.lastfmSessionKey && (() => {
const loveKey = `${song.title}::${song.artist}`;
const loved = lastfmLovedCache[loveKey] ?? false;
return (
<div className="context-menu-item" onClick={() => handleAction(() => {
const newLoved = !loved;
setLastfmLovedForSong(song.title, song.artist, newLoved);
if (newLoved) lastfmLoveTrack(song, auth.lastfmSessionKey);
else lastfmUnloveTrack(song, auth.lastfmSessionKey);
})}>
<Heart size={14} fill={loved ? 'currentColor' : 'none'} style={{ color: loved ? '#e31c23' : undefined }} />
{loved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
</div>
);
})()}
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
<Radio size={14} /> {t('contextMenu.startRadio')}
</div>
+115
View File
@@ -0,0 +1,115 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ChevronDown } from 'lucide-react';
export interface SelectOption {
value: string;
label: string;
group?: string; // group label — shown as non-selectable header when it changes
disabled?: boolean;
}
interface Props {
value: string;
options: SelectOption[];
onChange: (value: string) => void;
className?: string;
style?: React.CSSProperties;
}
export default function CustomSelect({ value, options, onChange, className = '', style }: Props) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const [dropStyle, setDropStyle] = useState<React.CSSProperties>({});
const selected = options.find(o => o.value === value);
useLayoutEffect(() => {
if (!open || !triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
const MARGIN = 6;
const maxH = 240;
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
const spaceAbove = rect.top - MARGIN;
const useAbove = spaceBelow < 80 && spaceAbove > spaceBelow;
setDropStyle({
position: 'fixed',
left: rect.left,
width: rect.width,
...(useAbove
? { bottom: window.innerHeight - rect.top + MARGIN }
: { top: rect.bottom + MARGIN }),
maxHeight: Math.min(maxH, useAbove ? spaceAbove : spaceBelow),
zIndex: 99998,
});
}, [open]);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (
!triggerRef.current?.contains(e.target as Node) &&
!listRef.current?.contains(e.target as Node)
) setOpen(false);
};
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [open]);
return (
<>
<button
ref={triggerRef}
type="button"
className={`custom-select-trigger ${className}`}
style={style}
onClick={() => setOpen(v => !v)}
aria-haspopup="listbox"
aria-expanded={open}
>
<span className="custom-select-label">{selected?.label ?? value}</span>
<ChevronDown size={14} className={`custom-select-chevron ${open ? 'open' : ''}`} />
</button>
{open && createPortal(
<div
ref={listRef}
className="custom-select-dropdown"
style={dropStyle}
role="listbox"
>
{options.reduce<React.ReactNode[]>((acc, opt, i) => {
const prevGroup = i > 0 ? options[i - 1].group : undefined;
if (opt.group && opt.group !== prevGroup) {
acc.push(
<div key={`group-${opt.group}`} className="custom-select-group-label">
{opt.group}
</div>
);
}
acc.push(
<div
key={opt.value}
className={`custom-select-option ${opt.value === value ? 'selected' : ''} ${opt.disabled ? 'disabled' : ''}`}
role="option"
aria-selected={opt.value === value}
onMouseDown={() => { if (!opt.disabled) { onChange(opt.value); setOpen(false); } }}
>
{opt.label}
</div>
);
return acc;
}, [])}
</div>,
document.body
)}
</>
);
}
+13 -17
View File
@@ -1,6 +1,7 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Save, Trash2, RotateCcw } from 'lucide-react';
import CustomSelect from './CustomSelect';
import { useEqStore, EQ_BANDS, BUILTIN_PRESETS } from '../store/eqStore';
import { useThemeStore } from '../store/themeStore';
@@ -240,31 +241,26 @@ export default function Equalizer() {
</label>
<div className="eq-preset-row">
<select
className="input eq-preset-select"
<CustomSelect
className="eq-preset-select"
value={selectValue}
onChange={e => applyPreset(e.target.value)}
>
{activePreset === null && <option value="__custom__">{t('settings.eqPresetCustom')}</option>}
<optgroup label={t('settings.eqPresetBuiltin')}>
{BUILTIN_PRESETS.map(p => <option key={p.name} value={p.name}>{p.name}</option>)}
</optgroup>
{customPresets.length > 0 && (
<optgroup label={t('settings.eqPresetCustomGroup')}>
{customPresets.map(p => <option key={p.name} value={p.name}>{p.name}</option>)}
</optgroup>
)}
</select>
onChange={v => applyPreset(v)}
options={[
...(activePreset === null ? [{ value: '__custom__', label: t('settings.eqPresetCustom'), disabled: true }] : []),
...BUILTIN_PRESETS.map(p => ({ value: p.name, label: p.name, group: t('settings.eqPresetBuiltin') })),
...customPresets.map(p => ({ value: p.name, label: p.name, group: t('settings.eqPresetCustomGroup') })),
]}
/>
{isCustomSaved && (
<button className="eq-ctrl-btn" onClick={() => deleteCustomPreset(activePreset!)} title={t('settings.eqDeletePreset')}>
<button className="eq-ctrl-btn" onClick={() => deleteCustomPreset(activePreset!)} data-tooltip={t('settings.eqDeletePreset')}>
<Trash2 size={13} />
</button>
)}
<button className="eq-ctrl-btn" onClick={() => applyPreset('Flat')} title={t('settings.eqResetBands')}>
<button className="eq-ctrl-btn" onClick={() => applyPreset('Flat')} data-tooltip={t('settings.eqResetBands')}>
<RotateCcw size={13} />
</button>
<button className="eq-ctrl-btn" onClick={() => setShowSave(v => !v)} title={t('settings.eqSavePreset')}>
<button className="eq-ctrl-btn" onClick={() => setShowSave(v => !v)} data-tooltip={t('settings.eqSavePreset')}>
<Save size={13} />
</button>
</div>
-3
View File
@@ -193,9 +193,6 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
<>
<FsBg url={bgUrl} />
<div className="fs-bg-overlay" aria-hidden="true" />
<div className="fs-orb fs-orb-1" aria-hidden="true" />
<div className="fs-orb fs-orb-2" aria-hidden="true" />
<div className="fs-orb fs-orb-3" aria-hidden="true" />
</>
)}
+7
View File
@@ -0,0 +1,7 @@
export default function LastfmIcon({ size = 16 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M11.344 16.143l-.917-2.494s-1.485 1.662-3.716 1.662c-1.97 0-3.373-1.714-3.373-4.46 0-3.514 1.773-4.777 3.52-4.777 2.508 0 3.306 1.625 3.997 3.714l.918 2.88c.918 2.8 2.642 5.047 7.615 5.047 3.563 0 5.98-1.094 5.98-3.972 0-2.326-1.327-3.53-3.797-4.11l-1.836-.41c-1.27-.29-1.645-.82-1.645-1.693 0-.987.778-1.56 2.047-1.56 1.384 0 2.132.52 2.245 1.756l2.878-.347C24.883 5.116 23.3 4 20.5 4c-3.26 0-4.945 1.537-4.945 3.824 0 1.843.91 3.008 3.2 3.562l1.947.46c1.404.327 1.97.874 1.97 1.894 0 1.13-.988 1.593-2.948 1.593-2.858 0-4.052-1.497-4.742-3.634l-.943-2.887C13.22 6.162 11.73 4 7.897 4 3.847 4 1 6.61 1 11.022c0 4.235 2.617 6.638 6.19 6.638 2.566 0 4.154-1.517 4.154-1.517z"/>
</svg>
);
}
+15 -1
View File
@@ -1,9 +1,10 @@
import React, { useCallback, useMemo, useState } from 'react';
import {
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import CachedImage from './CachedImage';
import WaveformSeek from './WaveformSeek';
@@ -26,7 +27,9 @@ export default function PlayerBar() {
currentTrack, isPlaying, currentTime, volume,
togglePlay, next, previous, setVolume,
stop, toggleRepeat, repeatMode, toggleFullscreen,
lastfmLoved, toggleLastfmLove,
} = usePlayerStore();
const { lastfmSessionKey } = useAuthStore();
const duration = currentTrack?.duration ?? 0;
const coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]);
@@ -86,6 +89,17 @@ export default function PlayerBar() {
{currentTrack?.artist ?? '—'}
</div>
</div>
{currentTrack && lastfmSessionKey && (
<button
className="player-btn player-btn-sm player-love-btn"
onClick={toggleLastfmLove}
aria-label={lastfmLoved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
data-tooltip={lastfmLoved ? t('contextMenu.lfmUnlove') : t('contextMenu.lfmLove')}
style={{ color: lastfmLoved ? '#e31c23' : 'var(--text-muted)', flexShrink: 0 }}
>
<Heart size={15} fill={lastfmLoved ? '#e31c23' : 'none'} />
</button>
)}
</div>
{/* Transport Controls */}
+15 -10
View File
@@ -38,7 +38,7 @@ function UpdateToast({ isCollapsed, latestVersion }: { isCollapsed: boolean; lat
if (isCollapsed) {
return (
<div className="update-toast-icon" style={{ marginTop: 'auto' }} title={`${t('sidebar.updateAvailable')}: ${latestVersion}`}>
<div className="update-toast-icon" style={{ marginTop: 'auto' }} data-tooltip={`${t('sidebar.updateAvailable')}: ${latestVersion}`} data-tooltip-pos="bottom">
<ArrowUpCircle size={20} />
</div>
);
@@ -100,9 +100,10 @@ export default function Sidebar({
<aside className={`sidebar animate-slide-in ${isCollapsed ? 'collapsed' : ''}`}>
<div className="sidebar-brand">
<button
className="collapse-btn"
onClick={toggleCollapse}
title={isCollapsed ? t('sidebar.expand') : t('sidebar.collapse')}
className="collapse-btn"
onClick={toggleCollapse}
data-tooltip={isCollapsed ? t('sidebar.expand') : t('sidebar.collapse')}
data-tooltip-pos="bottom"
style={{ padding: 0 }}
>
{isCollapsed ? <PanelLeft size={24} /> : <PanelLeftClose size={24} />}
@@ -119,7 +120,8 @@ export default function Sidebar({
to={item.to}
end={item.to === '/'}
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
title={isCollapsed ? t(item.labelKey) : undefined}
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
data-tooltip-pos="bottom"
>
<item.icon size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t(item.labelKey)}</span>}
@@ -130,7 +132,8 @@ export default function Sidebar({
<NavLink
to="/now-playing"
className={({ isActive }) => `nav-link nav-link-nowplaying ${isActive ? 'active' : ''}`}
title={isCollapsed ? t('sidebar.nowPlaying') : undefined}
data-tooltip={isCollapsed ? t('sidebar.nowPlaying') : undefined}
data-tooltip-pos="bottom"
style={{ marginTop: 'auto' }}
>
<span className="nav-np-icon-wrap">
@@ -145,8 +148,8 @@ export default function Sidebar({
<NavLink
to="/statistics"
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
style={isCollapsed && !latestVersion ? { marginTop: 'auto' } : undefined}
title={isCollapsed ? t('sidebar.statistics') : undefined}
data-tooltip={isCollapsed ? t('sidebar.statistics') : undefined}
data-tooltip-pos="bottom"
>
<BarChart3 size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t('sidebar.statistics')}</span>}
@@ -154,7 +157,8 @@ export default function Sidebar({
<NavLink
to="/help"
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
title={isCollapsed ? t('sidebar.help') : undefined}
data-tooltip={isCollapsed ? t('sidebar.help') : undefined}
data-tooltip-pos="bottom"
>
<HelpCircle size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t('sidebar.help')}</span>}
@@ -162,7 +166,8 @@ export default function Sidebar({
<NavLink
to="/settings"
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
title={isCollapsed ? t('sidebar.settings') : undefined}
data-tooltip={isCollapsed ? t('sidebar.settings') : undefined}
data-tooltip-pos="bottom"
>
<Settings size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t('sidebar.settings')}</span>}
+94
View File
@@ -0,0 +1,94 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
interface TooltipState {
text: string;
anchorRect: DOMRect;
preferBottom: boolean;
wrap: boolean;
}
export default function TooltipPortal() {
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const boxRef = useRef<HTMLDivElement>(null);
const [style, setStyle] = useState<React.CSSProperties>({ opacity: 0 });
useEffect(() => {
const onOver = (e: MouseEvent) => {
const target = (e.target as HTMLElement).closest('[data-tooltip]') as HTMLElement | null;
if (!target) return;
const text = target.getAttribute('data-tooltip');
if (!text) return;
setTooltip({
text,
anchorRect: target.getBoundingClientRect(),
preferBottom: target.getAttribute('data-tooltip-pos') === 'bottom',
wrap: target.hasAttribute('data-tooltip-wrap'),
});
};
const onOut = (e: MouseEvent) => {
const target = (e.target as HTMLElement).closest('[data-tooltip]');
if (target) setTooltip(null);
};
document.addEventListener('mouseover', onOver);
document.addEventListener('mouseout', onOut);
return () => {
document.removeEventListener('mouseover', onOver);
document.removeEventListener('mouseout', onOut);
};
}, []);
useLayoutEffect(() => {
if (!tooltip || !boxRef.current) { setStyle({ opacity: 0 }); return; }
const box = boxRef.current.getBoundingClientRect();
const { anchorRect, preferBottom } = tooltip;
const GAP = 7;
const MARGIN = 8;
// Decide top or bottom
const spaceAbove = anchorRect.top - GAP - box.height;
const useBottom = preferBottom || spaceAbove < MARGIN;
let top = useBottom
? anchorRect.bottom + GAP
: anchorRect.top - GAP - box.height;
// Clamp vertically
top = Math.max(MARGIN, Math.min(top, window.innerHeight - box.height - MARGIN));
// Centre horizontally, clamp to viewport
let left = anchorRect.left + anchorRect.width / 2 - box.width / 2;
left = Math.max(MARGIN, Math.min(left, window.innerWidth - box.width - MARGIN));
setStyle({ opacity: 1, top, left });
}, [tooltip]);
if (!tooltip) return null;
return createPortal(
<div
ref={boxRef}
style={{
position: 'fixed',
zIndex: 99999,
background: 'var(--bg-card)',
color: 'var(--text-primary)',
border: '1px solid var(--border-subtle)',
borderRadius: 'var(--radius-sm)',
padding: '4px 8px',
fontSize: '12px',
fontWeight: 500,
boxShadow: '0 4px 12px rgba(0,0,0,0.6)',
pointerEvents: 'none',
whiteSpace: tooltip.wrap ? 'pre-line' : 'nowrap',
maxWidth: tooltip.wrap ? '220px' : undefined,
transition: 'opacity 0.15s ease',
...style,
}}
>
{tooltip.text}
</div>,
document.body
);
}