mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat: v1.8.0 — new themes, queue toolbar, lightbox, i18n (fr/nl)
Themes: - Add Poison (phosphor green on charcoal), Nucleo (warm brass light), Classic Winamp (LCD glow, Winamp yellow/orange) themes - Overhaul Psychowave — refined deep violet, no longer WIP - Reorganise ThemePicker into groups: Catppuccin, Nord, Retro, Tokyo Night, Psysonic Themes - Add --volume-accent CSS var for per-theme volume slider colour override Queue: - Full toolbar redesign with centred round buttons (Shuffle/Save/Load/Clear/Gapless/Crossfade) - Crossfade popover with 1–10 s range slider, right-aligned to avoid viewport overflow - Queue header: title + count + duration inline in accent colour, close button removed - Tech info (codec/bitrate) as frosted-glass overlay badge on cover art UI: - CoverLightbox shared component for album cover (AlbumHeader) and artist avatar (ArtistDetail) - NowPlayingDropdown: separate spinning/loading state — button always clickable, icon spins 600 ms min - Settings: "Experimental" badge on Crossfade and Gapless toggles - Help page: 2-column grid layout, new Crossfade & Gapless Q&A entry, updated theme/language entries i18n: - Full French (fr) and Dutch (nl) translations across all namespaces - Language selector sorted alphabetically (nl, en, fr, de) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,7 @@ import ContextMenu from './components/ContextMenu';
|
||||
import DownloadFolderModal from './components/DownloadFolderModal';
|
||||
import TooltipPortal from './components/TooltipPortal';
|
||||
import ConnectionIndicator from './components/ConnectionIndicator';
|
||||
import LastfmIndicator from './components/LastfmIndicator';
|
||||
import OfflineOverlay from './components/OfflineOverlay';
|
||||
import { useConnectionStatus } from './hooks/useConnectionStatus';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
@@ -140,6 +141,7 @@ function AppShell() {
|
||||
<LiveSearch />
|
||||
<div className="spacer" />
|
||||
<ConnectionIndicator status={connStatus} isLan={isLan} serverName={serverName} />
|
||||
<LastfmIndicator />
|
||||
<NowPlayingDropdown />
|
||||
<button
|
||||
className="collapse-btn"
|
||||
|
||||
+19
-7
@@ -1,4 +1,5 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
const API_KEY = '9917fb39049225a13bec225ad6d49054';
|
||||
const API_SECRET = '03817dda02bee87a178aab7581abae3b';
|
||||
@@ -15,13 +16,24 @@ function errMsg(e: unknown): string {
|
||||
|
||||
async function call(params: Record<string, string>, sign = false, get = false): Promise<any> {
|
||||
const entries = Object.entries(params) as [string, string][];
|
||||
return invoke('lastfm_request', {
|
||||
params: entries,
|
||||
sign,
|
||||
get,
|
||||
apiKey: API_KEY,
|
||||
apiSecret: API_SECRET,
|
||||
});
|
||||
try {
|
||||
const result = await invoke('lastfm_request', {
|
||||
params: entries,
|
||||
sign,
|
||||
get,
|
||||
apiKey: API_KEY,
|
||||
apiSecret: API_SECRET,
|
||||
});
|
||||
// Clear session error on any successful authenticated call
|
||||
if (sign) useAuthStore.getState().setLastfmSessionError(false);
|
||||
return result;
|
||||
} catch (e) {
|
||||
// Last.fm error codes 4, 9, 14 = auth/session invalid
|
||||
if (sign && /^Last\.fm (4|9|14)\b/.test(errMsg(e))) {
|
||||
useAuthStore.getState().setLastfmSessionError(true);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function lastfmGetToken(): Promise<string> {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, Star, ExternalLink, X, ChevronLeft, Download, ListPlus, Info } from 'lucide-react';
|
||||
import { SubsonicSong } from '../api/subsonic';
|
||||
import { SubsonicSong, buildCoverArtUrl } from '../api/subsonic';
|
||||
import CachedImage from './CachedImage';
|
||||
import CoverLightbox from './CoverLightbox';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
@@ -49,6 +50,7 @@ function BioModal({ bio, onClose }: { bio: string; onClose: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
interface AlbumInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -97,6 +99,7 @@ export default function AlbumHeader({
|
||||
}: AlbumHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
|
||||
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
|
||||
const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 0);
|
||||
@@ -104,6 +107,13 @@ export default function AlbumHeader({
|
||||
return (
|
||||
<>
|
||||
{bioOpen && bio && <BioModal bio={bio} onClose={onCloseBio} />}
|
||||
{lightboxOpen && info.coverArt && (
|
||||
<CoverLightbox
|
||||
src={buildCoverArtUrl(info.coverArt, 2000)}
|
||||
alt={`${info.name} Cover`}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="album-detail-header">
|
||||
{resolvedCoverUrl && (
|
||||
@@ -121,7 +131,14 @@ export default function AlbumHeader({
|
||||
</button>
|
||||
<div className="album-detail-hero">
|
||||
{coverUrl ? (
|
||||
<CachedImage className="album-detail-cover" src={coverUrl} cacheKey={coverKey} alt={`${info.name} Cover`} />
|
||||
<button
|
||||
className="album-detail-cover-btn"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
data-tooltip="Vergrößern"
|
||||
aria-label={`${info.name} Cover vergrößern`}
|
||||
>
|
||||
<CachedImage className="album-detail-cover" src={coverUrl} cacheKey={coverKey} alt={`${info.name} Cover`} />
|
||||
</button>
|
||||
) : (
|
||||
<div className="album-detail-cover album-cover-placeholder">♪</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ConnectionStatus } from '../hooks/useConnectionStatus';
|
||||
|
||||
interface Props {
|
||||
@@ -9,17 +10,24 @@ interface Props {
|
||||
|
||||
export default function ConnectionIndicator({ status, isLan, serverName }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const label = isLan ? 'LAN' : t('connection.extern');
|
||||
const title =
|
||||
const tooltip =
|
||||
status === 'connected'
|
||||
? t('connection.connected')
|
||||
? t('connection.connectedTo', { server: serverName })
|
||||
: status === 'disconnected'
|
||||
? t('connection.disconnected')
|
||||
? t('connection.disconnectedFrom', { server: serverName })
|
||||
: t('connection.checking');
|
||||
|
||||
return (
|
||||
<div className="connection-indicator" data-tooltip={title} data-tooltip-pos="bottom">
|
||||
<div
|
||||
className="connection-indicator"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate('/settings', { state: { tab: 'server' } })}
|
||||
data-tooltip={tooltip}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<div className={`connection-led connection-led--${status}`} />
|
||||
<div className="connection-meta">
|
||||
<span className="connection-type">{label}</span>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
src: string;
|
||||
alt: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function CoverLightbox({ src, alt, onClose }: Props) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="cover-lightbox-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label={alt}>
|
||||
<button className="cover-lightbox-close" onClick={onClose} aria-label="Close"><X size={20} /></button>
|
||||
<img
|
||||
className="cover-lightbox-img"
|
||||
src={src}
|
||||
alt={alt}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import LastfmIcon from './LastfmIcon';
|
||||
|
||||
export default function LastfmIndicator() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { lastfmSessionKey, lastfmUsername, lastfmSessionError } = useAuthStore();
|
||||
|
||||
if (!lastfmSessionKey) return null;
|
||||
|
||||
const tooltip = lastfmSessionError
|
||||
? t('connection.lastfmSessionInvalid')
|
||||
: t('connection.lastfmConnected', { user: lastfmUsername });
|
||||
|
||||
return (
|
||||
<div
|
||||
className="connection-indicator"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate('/settings', { state: { tab: 'server' } })}
|
||||
data-tooltip={tooltip}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<div
|
||||
className={`connection-led connection-led--${lastfmSessionError ? 'disconnected' : 'connected'}`}
|
||||
/>
|
||||
<div className="connection-meta">
|
||||
<span className="connection-type" style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<LastfmIcon size={11} />
|
||||
Last.fm
|
||||
</span>
|
||||
<span className="connection-server">
|
||||
{lastfmSessionError ? t('connection.lastfmSessionInvalid').split(' —')[0] : `@${lastfmUsername}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export default function NowPlayingDropdown() {
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const ownUsername = useAuthStore(s => s.getActiveServer()?.username ?? '');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [spinning, setSpinning] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const fetchNowPlaying = async () => {
|
||||
@@ -28,6 +29,13 @@ export default function NowPlayingDropdown() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
setSpinning(true);
|
||||
fetchNowPlaying().finally(() => {
|
||||
setTimeout(() => setSpinning(false), 600);
|
||||
});
|
||||
};
|
||||
|
||||
// Poll in background so the badge stays current without opening the dropdown
|
||||
useEffect(() => {
|
||||
fetchNowPlaying();
|
||||
@@ -104,11 +112,11 @@ export default function NowPlayingDropdown() {
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid var(--border-subtle)', paddingBottom: '0.5rem' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '14px', fontWeight: 600 }}>{t('nowPlaying.title')}</h3>
|
||||
<button
|
||||
onClick={fetchNowPlaying}
|
||||
className={`btn btn-ghost ${loading ? 'animate-spin' : ''}`}
|
||||
style={{ width: '28px', height: '28px', padding: 0 }}
|
||||
onClick={handleRefresh}
|
||||
className="btn btn-ghost"
|
||||
style={{ width: '28px', height: '28px', padding: 0, justifyContent: 'center' }}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
<RefreshCw size={14} className={spinning ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function PlayerBar() {
|
||||
}, [setVolume]);
|
||||
|
||||
const volumeStyle = {
|
||||
background: `linear-gradient(to right, var(--ctp-mauve) ${volume * 100}%, var(--ctp-surface2) ${volume * 100}%)`,
|
||||
background: `linear-gradient(to right, var(--volume-accent, var(--accent)) ${volume * 100}%, var(--ctp-surface2) ${volume * 100}%)`,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
+105
-34
@@ -1,10 +1,11 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Track, usePlayerStore } from '../store/playerStore';
|
||||
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle } from 'lucide-react';
|
||||
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves } from 'lucide-react';
|
||||
import { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
@@ -130,6 +131,30 @@ export default function QueuePanel() {
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const contextMenu = usePlayerStore(s => s.contextMenu);
|
||||
|
||||
const crossfadeEnabled = useAuthStore(s => s.crossfadeEnabled);
|
||||
const crossfadeSecs = useAuthStore(s => s.crossfadeSecs);
|
||||
const gaplessEnabled = useAuthStore(s => s.gaplessEnabled);
|
||||
const setCrossfadeEnabled = useAuthStore(s => s.setCrossfadeEnabled);
|
||||
const setCrossfadeSecs = useAuthStore(s => s.setCrossfadeSecs);
|
||||
const setGaplessEnabled = useAuthStore(s => s.setGaplessEnabled);
|
||||
|
||||
const [showCrossfadePopover, setShowCrossfadePopover] = useState(false);
|
||||
const crossfadeBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const crossfadePopoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showCrossfadePopover) return;
|
||||
const handle = (e: MouseEvent) => {
|
||||
if (
|
||||
crossfadeBtnRef.current?.contains(e.target as Node) ||
|
||||
crossfadePopoverRef.current?.contains(e.target as Node)
|
||||
) return;
|
||||
setShowCrossfadePopover(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handle);
|
||||
return () => document.removeEventListener('mousedown', handle);
|
||||
}, [showCrossfadePopover]);
|
||||
|
||||
const [draggedIdx, setDraggedIdx] = useState<number | null>(null);
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||
const isDraggingInternalRef = useRef(false);
|
||||
@@ -255,8 +280,8 @@ export default function QueuePanel() {
|
||||
}}
|
||||
>
|
||||
<div className="queue-header">
|
||||
<div>
|
||||
<h2 style={{ fontSize: '14px', fontWeight: 600, margin: 0 }}>{t('queue.title')}</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '8px', minWidth: 0 }}>
|
||||
<h2 style={{ fontSize: '16px', fontWeight: 700, margin: 0, flexShrink: 0 }}>{t('queue.title')}</h2>
|
||||
{queue.length > 0 && (() => {
|
||||
const totalSecs = queue.reduce((acc, t) => acc + (t.duration || 0), 0);
|
||||
const h = Math.floor(totalSecs / 3600);
|
||||
@@ -266,31 +291,12 @@ export default function QueuePanel() {
|
||||
? `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||
: `${m}:${s.toString().padStart(2, '0')}`;
|
||||
return (
|
||||
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '2px' }}>
|
||||
<span style={{ fontSize: '13px', color: 'var(--accent)', whiteSpace: 'nowrap' }}>
|
||||
{queue.length} {queue.length === 1 ? t('queue.trackSingular') : t('queue.trackPlural')} · {dur}
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<button onClick={() => shuffleQueue()} style={{ color: 'var(--text-muted)', cursor: 'pointer', display: 'flex' }} aria-label={t('queue.shuffle')} data-tooltip={t('queue.shuffle')} disabled={queue.length < 2}>
|
||||
<Shuffle size={14} />
|
||||
</button>
|
||||
<button onClick={handleSave} style={{ color: 'var(--text-muted)', cursor: 'pointer', display: 'flex' }} aria-label={t('queue.savePlaylist')} data-tooltip={t('queue.save')}>
|
||||
<Save size={14} />
|
||||
</button>
|
||||
<button onClick={handleLoad} style={{ color: 'var(--text-muted)', cursor: 'pointer', display: 'flex' }} aria-label={t('queue.loadPlaylist')} data-tooltip={t('queue.load')}>
|
||||
<FolderOpen size={14} />
|
||||
</button>
|
||||
<button onClick={handleClear} style={{ color: 'var(--text-muted)', cursor: 'pointer', display: 'flex' }} aria-label={t('queue.clear')} data-tooltip={t('queue.clear')}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
<div style={{ width: '1px', height: '14px', background: 'var(--border)', margin: '0 4px' }} />
|
||||
<button onClick={toggleQueue} style={{ color: 'var(--text-muted)', cursor: 'pointer', display: 'flex' }} aria-label={t('queue.close')} data-tooltip={t('queue.hide')}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentTrack && (
|
||||
@@ -301,6 +307,13 @@ export default function QueuePanel() {
|
||||
) : (
|
||||
<div className="fallback"><Music size={32} /></div>
|
||||
)}
|
||||
{(currentTrack.bitRate || currentTrack.suffix) && (
|
||||
<div className="queue-current-tech">
|
||||
{currentTrack.bitRate && currentTrack.suffix
|
||||
? `${currentTrack.bitRate} · ${currentTrack.suffix.toUpperCase()}`
|
||||
: currentTrack.suffix?.toUpperCase() ?? ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="queue-current-info">
|
||||
<h3 className="truncate" data-tooltip={currentTrack.title}>{currentTrack.title}</h3>
|
||||
@@ -319,20 +332,78 @@ export default function QueuePanel() {
|
||||
{currentTrack.year && (
|
||||
<div className="queue-current-sub">{currentTrack.year}</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '6px' }}>
|
||||
<div className="queue-current-tech">
|
||||
{currentTrack.bitRate && currentTrack.suffix ? (
|
||||
`${currentTrack.bitRate} kbps · ${currentTrack.suffix.toUpperCase()}`
|
||||
) : (
|
||||
currentTrack.suffix?.toUpperCase() ?? ''
|
||||
)}
|
||||
</div>
|
||||
{renderStars(currentTrack.userRating)}
|
||||
</div>
|
||||
{renderStars(currentTrack.userRating)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="queue-toolbar">
|
||||
<button className="queue-round-btn" onClick={() => shuffleQueue()} disabled={queue.length < 2} data-tooltip={t('queue.shuffle')} aria-label={t('queue.shuffle')}>
|
||||
<Shuffle size={13} />
|
||||
</button>
|
||||
<button className="queue-round-btn" onClick={handleSave} data-tooltip={t('queue.savePlaylist')} aria-label={t('queue.savePlaylist')}>
|
||||
<Save size={13} />
|
||||
</button>
|
||||
<button className="queue-round-btn" onClick={handleLoad} data-tooltip={t('queue.loadPlaylist')} aria-label={t('queue.loadPlaylist')}>
|
||||
<FolderOpen size={13} />
|
||||
</button>
|
||||
<button className="queue-round-btn" onClick={handleClear} data-tooltip={t('queue.clear')} aria-label={t('queue.clear')}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
<div className="queue-toolbar-sep" />
|
||||
<button
|
||||
className={`queue-round-btn${gaplessEnabled ? ' active' : ''}`}
|
||||
onClick={() => setGaplessEnabled(!gaplessEnabled)}
|
||||
data-tooltip={t('queue.gapless')}
|
||||
aria-label={t('queue.gapless')}
|
||||
>
|
||||
<Infinity size={13} />
|
||||
</button>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
ref={crossfadeBtnRef}
|
||||
className={`queue-round-btn${crossfadeEnabled || showCrossfadePopover ? ' active' : ''}`}
|
||||
onClick={() => {
|
||||
if (crossfadeEnabled) {
|
||||
setCrossfadeEnabled(false);
|
||||
setShowCrossfadePopover(false);
|
||||
} else {
|
||||
setCrossfadeEnabled(true);
|
||||
setShowCrossfadePopover(true);
|
||||
}
|
||||
}}
|
||||
data-tooltip={showCrossfadePopover ? undefined : t('queue.crossfade')}
|
||||
aria-label={t('queue.crossfade')}
|
||||
>
|
||||
<Waves size={13} />
|
||||
</button>
|
||||
{showCrossfadePopover && (
|
||||
<div className="crossfade-popover" ref={crossfadePopoverRef}>
|
||||
<div className="crossfade-popover-label">
|
||||
<Waves size={11} />
|
||||
{t('queue.crossfade')}
|
||||
<span className="crossfade-popover-value">{crossfadeSecs}s</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={10}
|
||||
step={0.5}
|
||||
value={crossfadeSecs}
|
||||
onChange={e => {
|
||||
setCrossfadeSecs(Number(e.target.value));
|
||||
setCrossfadeEnabled(true);
|
||||
}}
|
||||
className="crossfade-popover-slider"
|
||||
/>
|
||||
<div className="crossfade-popover-range">
|
||||
<span>1s</span><span>10s</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentTrack && queue.length > 0 && <div className="queue-divider"><span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.nextTracks')}</span></div>}
|
||||
|
||||
<div className="queue-list" ref={queueListRef}>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
const PsysonicLogo = () => (
|
||||
<img src="/logo-psysonic.png" alt="Psysonic Logo" width="36" height="36" style={{ borderRadius: '8px' }} />
|
||||
<img src="/logo-psysonic.png" alt="Psysonic Logo" width="36" height="36" />
|
||||
);
|
||||
|
||||
const navItems = [
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
interface ThemeDef {
|
||||
id: string;
|
||||
label: string;
|
||||
bg: string;
|
||||
card: string;
|
||||
accent: string;
|
||||
}
|
||||
|
||||
const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
|
||||
{
|
||||
group: 'Catppuccin',
|
||||
themes: [
|
||||
{ id: 'mocha', label: 'Mocha', bg: '#1e1e2e', card: '#313244', accent: '#cba6f7' },
|
||||
{ id: 'macchiato', label: 'Macchiato', bg: '#24273a', card: '#363a4f', accent: '#c6a0f6' },
|
||||
{ id: 'frappe', label: 'Frappé', bg: '#303446', card: '#414559', accent: '#ca9ee6' },
|
||||
{ id: 'latte', label: 'Latte', bg: '#eff1f5', card: '#ccd0da', accent: '#8839ef' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Nord',
|
||||
themes: [
|
||||
{ id: 'nord', label: 'Polar Night', bg: '#3b4252', card: '#434c5e', accent: '#88c0d0' },
|
||||
{ id: 'nord-snowstorm', label: 'Snowstorm', bg: '#e5e9f0', card: '#eceff4', accent: '#5e81ac' },
|
||||
{ id: 'nord-frost', label: 'Frost', bg: '#1e2d3d', card: '#243447', accent: '#88c0d0' },
|
||||
{ id: 'nord-aurora', label: 'Aurora', bg: '#3b4252', card: '#434c5e', accent: '#b48ead' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Retro',
|
||||
themes: [
|
||||
{ id: 'gruvbox-dark-hard', label: 'Dark Hard', bg: '#1d2021', card: '#3c3836', accent: '#fabd2f' },
|
||||
{ id: 'gruvbox-dark-medium', label: 'Dark Medium', bg: '#282828', card: '#3c3836', accent: '#fabd2f' },
|
||||
{ id: 'gruvbox-dark-soft', label: 'Dark Soft', bg: '#32302f', card: '#45403d', accent: '#fabd2f' },
|
||||
{ id: 'gruvbox-light-hard', label: 'Light Hard', bg: '#f9f5d7', card: '#f2e5bc', accent: '#b57614' },
|
||||
{ id: 'gruvbox-light-medium', label: 'Light Medium', bg: '#fbf1c7', card: '#f2e5bc', accent: '#b57614' },
|
||||
{ id: 'gruvbox-light-soft', label: 'Light Soft', bg: '#f2e5bc', card: '#ebdbb2', accent: '#b57614' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Tokyo Night',
|
||||
themes: [
|
||||
{ id: 'tokyo-night', label: 'Standard', bg: '#1a1b26', card: '#24283b', accent: '#7aa2f7' },
|
||||
{ id: 'tokyo-night-storm', label: 'Storm', bg: '#24283b', card: '#2f334d', accent: '#7aa2f7' },
|
||||
{ id: 'tokyo-night-light', label: 'Light', bg: '#d5d6db', card: '#e9e9ec', accent: '#34548a' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Psysonic Themes',
|
||||
themes: [
|
||||
{ id: 'classic-winamp', label: 'Classic Winamp', bg: '#2b2b3a', card: '#000000', accent: '#00ff00' },
|
||||
{ id: 'poison', label: 'Poison', bg: '#1f1f1f', card: '#282828', accent: '#1bd655' },
|
||||
{ id: 'nucleo', label: 'Nucleo', bg: '#f5e4c3', card: '#dfc08f', accent: '#9e9a92' },
|
||||
{ id: 'psychowave', label: 'Psychowave', bg: '#161428', card: '#1f1c38', accent: '#a06ae0' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function ThemePicker({ value, onChange }: Props) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
{THEME_GROUPS.map(({ group, themes }) => (
|
||||
<div key={group}>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
color: 'var(--text-muted)',
|
||||
marginBottom: '10px',
|
||||
}}>
|
||||
{group}
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))',
|
||||
gap: '10px',
|
||||
}}>
|
||||
{themes.map((t) => {
|
||||
const isActive = value === t.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => onChange(t.id)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '46px',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
outline: isActive ? '2px solid var(--accent)' : '2px solid transparent',
|
||||
outlineOffset: '2px',
|
||||
position: 'relative',
|
||||
boxShadow: isActive ? '0 0 8px var(--accent-glow, rgba(0,0,0,0.2))' : '0 1px 3px rgba(0,0,0,0.3)',
|
||||
transition: 'outline-color 0.15s, box-shadow 0.15s',
|
||||
}}>
|
||||
{/* main bg */}
|
||||
<div style={{ background: t.bg, height: '55%' }} />
|
||||
{/* card tone */}
|
||||
<div style={{ background: t.card, height: '20%' }} />
|
||||
{/* accent bar */}
|
||||
<div style={{ background: t.accent, height: '25%' }} />
|
||||
{isActive && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '4px',
|
||||
right: '4px',
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
borderRadius: '50%',
|
||||
background: t.accent,
|
||||
border: '1.5px solid rgba(255,255,255,0.7)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<Check size={8} strokeWidth={3} color="white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
color: isActive ? 'var(--text-primary)' : 'var(--text-secondary)',
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
textAlign: 'center',
|
||||
lineHeight: 1.2,
|
||||
wordBreak: 'break-word',
|
||||
}}>
|
||||
{t.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -59,8 +59,7 @@ function drawWaveform(
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const colorBlue = style.getPropertyValue('--ctp-blue').trim() || '#89b4fa';
|
||||
const colorMauve = style.getPropertyValue('--ctp-mauve').trim() || '#cba6f7';
|
||||
const colorAccent = style.getPropertyValue('--accent').trim() || '#cba6f7';
|
||||
const colorBuffered = style.getPropertyValue('--ctp-overlay0').trim() || '#6c7086';
|
||||
const colorUnplayed = style.getPropertyValue('--ctp-surface1').trim() || '#313244';
|
||||
|
||||
@@ -97,14 +96,11 @@ function drawWaveform(
|
||||
ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH);
|
||||
}
|
||||
|
||||
// Pass 3 — played (gradient + glow)
|
||||
// Pass 3 — played (accent color + glow)
|
||||
if (progress > 0) {
|
||||
const grad = ctx.createLinearGradient(0, 0, progress * w, 0);
|
||||
grad.addColorStop(0, colorBlue);
|
||||
grad.addColorStop(1, colorMauve);
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = grad;
|
||||
ctx.shadowColor = colorMauve;
|
||||
ctx.fillStyle = colorAccent;
|
||||
ctx.shadowColor = colorAccent;
|
||||
ctx.shadowBlur = 5;
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
if (i / BAR_COUNT >= progress) break;
|
||||
|
||||
+1043
-17
File diff suppressed because it is too large
Load Diff
+73
-40
@@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, search, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import CoverLightbox from '../components/CoverLightbox';
|
||||
import { ArrowLeft, Users, ExternalLink, Star, Play, Shuffle, Radio } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
@@ -49,6 +50,8 @@ export default function ArtistDetail() {
|
||||
const [openedLink, setOpenedLink] = useState<string | null>(null);
|
||||
const [similarArtists, setSimilarArtists] = useState<SubsonicArtist[]>([]);
|
||||
const [similarLoading, setSimilarLoading] = useState(false);
|
||||
const [featuredLoading, setFeaturedLoading] = useState(false);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
|
||||
const playTrack = usePlayerStore(state => state.playTrack);
|
||||
const enqueue = usePlayerStore(state => state.enqueue);
|
||||
@@ -58,45 +61,18 @@ export default function ArtistDetail() {
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
let ownAlbumIds: Set<string>;
|
||||
setFeaturedAlbums([]);
|
||||
getArtist(id).then(artistData => {
|
||||
setArtist(artistData.artist);
|
||||
setAlbums(artistData.albums);
|
||||
setIsStarred(!!artistData.artist.starred);
|
||||
ownAlbumIds = new Set(artistData.albums.map(a => a.id));
|
||||
return Promise.all([
|
||||
getArtistInfo(id).catch(() => null),
|
||||
getTopSongs(artistData.artist.name).catch(() => []),
|
||||
search(artistData.artist.name, { songCount: 500, artistCount: 0, albumCount: 0 }).catch(() => ({ songs: [], albums: [], artists: [] })),
|
||||
]);
|
||||
}).then(([artistInfo, songsData, searchResults]) => {
|
||||
}).then(([artistInfo, songsData]) => {
|
||||
if (artistInfo !== undefined) setInfo(artistInfo as SubsonicArtistInfo | null);
|
||||
if (songsData !== undefined) setTopSongs(songsData as SubsonicSong[]);
|
||||
|
||||
const featuredSongs = (searchResults.songs ?? []).filter(
|
||||
song => song.artistId === id && !ownAlbumIds.has(song.albumId)
|
||||
);
|
||||
const albumMap = new Map<string, SubsonicAlbum>();
|
||||
featuredSongs.forEach(song => {
|
||||
if (!albumMap.has(song.albumId)) {
|
||||
albumMap.set(song.albumId, {
|
||||
id: song.albumId,
|
||||
name: song.album,
|
||||
artist: song.albumArtist ?? '',
|
||||
artistId: '',
|
||||
coverArt: song.coverArt,
|
||||
songCount: 1,
|
||||
duration: song.duration,
|
||||
year: song.year,
|
||||
});
|
||||
} else {
|
||||
const a = albumMap.get(song.albumId)!;
|
||||
a.songCount++;
|
||||
a.duration += song.duration;
|
||||
}
|
||||
});
|
||||
setFeaturedAlbums([...albumMap.values()]);
|
||||
|
||||
setLoading(false);
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
@@ -104,6 +80,41 @@ export default function ArtistDetail() {
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
// "Also Featured On" — loaded in background after main content renders
|
||||
useEffect(() => {
|
||||
if (!id || !artist) return;
|
||||
const ownAlbumIds = new Set(albums.map(a => a.id));
|
||||
setFeaturedLoading(true);
|
||||
search(artist.name, { songCount: 500, artistCount: 0, albumCount: 0 })
|
||||
.catch(() => ({ songs: [], albums: [], artists: [] }))
|
||||
.then(searchResults => {
|
||||
const featuredSongs = (searchResults.songs ?? []).filter(
|
||||
song => song.artistId === id && !ownAlbumIds.has(song.albumId)
|
||||
);
|
||||
const albumMap = new Map<string, SubsonicAlbum>();
|
||||
featuredSongs.forEach(song => {
|
||||
if (!albumMap.has(song.albumId)) {
|
||||
albumMap.set(song.albumId, {
|
||||
id: song.albumId,
|
||||
name: song.album,
|
||||
artist: song.albumArtist ?? '',
|
||||
artistId: '',
|
||||
coverArt: song.coverArt,
|
||||
songCount: 1,
|
||||
duration: song.duration,
|
||||
year: song.year,
|
||||
});
|
||||
} else {
|
||||
const a = albumMap.get(song.albumId)!;
|
||||
a.songCount++;
|
||||
a.duration += song.duration;
|
||||
}
|
||||
});
|
||||
setFeaturedAlbums([...albumMap.values()]);
|
||||
setFeaturedLoading(false);
|
||||
});
|
||||
}, [artist?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!artist || !lastfmIsConfigured()) return;
|
||||
setSimilarArtists([]);
|
||||
@@ -213,16 +224,30 @@ export default function ArtistDetail() {
|
||||
<ArrowLeft size={16} /> <span>{t('artistDetail.back')}</span>
|
||||
</button>
|
||||
|
||||
{lightboxOpen && (
|
||||
<CoverLightbox
|
||||
src={buildCoverArtUrl(coverId, 2000)}
|
||||
alt={artist.name}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="artist-detail-header">
|
||||
<div className="artist-detail-avatar">
|
||||
{coverId ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(coverId, 300)}
|
||||
cacheKey={coverArtCacheKey(coverId, 300)}
|
||||
alt={artist.name}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
<button
|
||||
className="artist-detail-avatar-btn"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
aria-label={`${artist.name} Bild vergrößern`}
|
||||
>
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(coverId, 300)}
|
||||
cacheKey={coverArtCacheKey(coverId, 300)}
|
||||
alt={artist.name}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<Users size={64} color="var(--text-muted)" />
|
||||
)}
|
||||
@@ -389,14 +414,22 @@ export default function ArtistDetail() {
|
||||
)}
|
||||
|
||||
{/* Also Featured On */}
|
||||
{featuredAlbums.length > 0 && (
|
||||
{(featuredLoading || featuredAlbums.length > 0) && (
|
||||
<>
|
||||
<h2 className="section-title" style={{ marginTop: '2rem', marginBottom: '1rem' }}>
|
||||
{t('artistDetail.featuredOn')}
|
||||
</h2>
|
||||
<div className="album-grid-wrap">
|
||||
{featuredAlbums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
</div>
|
||||
{featuredLoading ? (
|
||||
<div className="album-grid-wrap">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} style={{ flex: '0 0 clamp(140px, 15vw, 180px)', borderRadius: '8px', background: 'var(--bg-card)', aspectRatio: '1', opacity: 0.5 }} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="album-grid-wrap" style={{ animation: 'fadeIn 0.3s ease' }}>
|
||||
{featuredAlbums.map(a => <AlbumCard key={a.id} album={a} />)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+2
-1
@@ -45,6 +45,7 @@ export default function Help() {
|
||||
{ q: t('help.q22'), a: t('help.a22') },
|
||||
{ q: t('help.q23'), a: t('help.a23') },
|
||||
{ q: t('help.q24'), a: t('help.a24') },
|
||||
{ q: t('help.q29'), a: t('help.a29') },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -100,7 +101,7 @@ export default function Help() {
|
||||
<div className="content-body animate-fade-in">
|
||||
<h1 className="page-title" style={{ marginBottom: '2rem' }}>{t('help.title')}</h1>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '1.25rem', alignItems: 'start' }}>
|
||||
{sections.map((section, si) => (
|
||||
<section key={si} className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
|
||||
+89
-96
@@ -1,15 +1,16 @@
|
||||
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { version as appVersion } from '../../package.json';
|
||||
import changelogRaw from '../../CHANGELOG.md?raw';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
|
||||
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play
|
||||
} from 'lucide-react';
|
||||
import { open as openUrl } from '@tauri-apps/plugin-shell';
|
||||
import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmIsConfigured, lastfmGetUserInfo, LastfmUserInfo } from '../api/lastfm';
|
||||
import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, LastfmUserInfo } from '../api/lastfm';
|
||||
import LastfmIcon from '../components/LastfmIcon';
|
||||
import CustomSelect from '../components/CustomSelect';
|
||||
import ThemePicker from '../components/ThemePicker';
|
||||
import { useAuthStore, ServerProfile } from '../store/authStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import { pingWithCredentials } from '../api/subsonic';
|
||||
@@ -83,9 +84,10 @@ export default function Settings() {
|
||||
const auth = useAuthStore();
|
||||
const theme = useThemeStore();
|
||||
const navigate = useNavigate();
|
||||
const { state: routeState } = useLocation();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>('playback');
|
||||
const [activeTab, setActiveTab] = useState<Tab>((routeState as { tab?: Tab } | null)?.tab ?? 'playback');
|
||||
const [connStatus, setConnStatus] = useState<Record<string, 'idle' | 'testing' | 'ok' | 'error'>>({});
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [newGenre, setNewGenre] = useState('');
|
||||
@@ -287,7 +289,12 @@ export default function Settings() {
|
||||
{/* Crossfade */}
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.crossfade')}</div>
|
||||
<div style={{ fontWeight: 500, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
{t('settings.crossfade')}
|
||||
<span style={{ fontSize: 10, fontWeight: 600, padding: '1px 6px', borderRadius: 4, background: 'var(--accent)', color: 'var(--ctp-base)', opacity: 0.85, letterSpacing: '0.04em', textTransform: 'uppercase' }}>
|
||||
{t('settings.experimental')}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.crossfadeDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.crossfade')}>
|
||||
@@ -318,7 +325,12 @@ export default function Settings() {
|
||||
{/* Gapless */}
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.gapless')}</div>
|
||||
<div style={{ fontWeight: 500, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
{t('settings.gapless')}
|
||||
<span style={{ fontSize: 10, fontWeight: 600, padding: '1px 6px', borderRadius: 4, background: 'var(--accent)', color: 'var(--ctp-base)', opacity: 0.85, letterSpacing: '0.04em', textTransform: 'uppercase' }}>
|
||||
{t('settings.experimental')}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.gaplessDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.gapless')}>
|
||||
@@ -329,80 +341,6 @@ export default function Settings() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Last.fm */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Music2 size={18} />
|
||||
<h2>{t('settings.lfmTitle')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
{auth.lastfmSessionKey ? (
|
||||
/* ── Connected state ── */
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.75rem 1rem', borderRadius: '10px', background: 'color-mix(in srgb, var(--accent) 8%, transparent)', border: '1px solid color-mix(in srgb, var(--accent) 20%, transparent)' }}>
|
||||
<div style={{ flexShrink: 0, color: '#e31c23' }}><LastfmIcon size={20} /></div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>@{auth.lastfmUsername}</div>
|
||||
{lfmUserInfo && (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2, display: 'flex', gap: '0.75rem' }}>
|
||||
<span>{t('settings.lfmScrobbles', { n: lfmUserInfo.playcount.toLocaleString() })}</span>
|
||||
<span>{t('settings.lfmMemberSince', { year: new Date(lfmUserInfo.registeredAt * 1000).getFullYear() })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12, padding: '4px 10px', flexShrink: 0 }}
|
||||
onClick={() => auth.disconnectLastfm()}
|
||||
>
|
||||
{t('settings.lfmDisconnect')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.scrobbleEnabled')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.scrobbleDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.scrobbleEnabled')}>
|
||||
<input type="checkbox" checked={auth.scrobblingEnabled} onChange={e => auth.setScrobblingEnabled(e.target.checked)} id="scrobbling-toggle" />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
) : lfmState === 'waiting' ? (
|
||||
/* ── Waiting for browser auth — auto-polling ── */
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
<div className="spinner" style={{ width: 16, height: 16, borderWidth: 2 }} />
|
||||
{t('settings.lfmConnecting')}
|
||||
</div>
|
||||
<button className="btn btn-ghost" style={{ alignSelf: 'flex-start', fontSize: 12 }}
|
||||
onClick={() => { setLfmState('idle'); setLfmPendingToken(null); }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* ── Not connected ── */
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.5 }}>
|
||||
{t('settings.lfmConnectDesc')}
|
||||
</p>
|
||||
{lfmState === 'error' && (
|
||||
<p style={{ fontSize: 12, color: 'var(--danger)' }}>{lfmError}</p>
|
||||
)}
|
||||
{lastfmIsConfigured() ? (
|
||||
<button className="btn btn-primary" style={{ alignSelf: 'flex-start' }} onClick={startLastfmConnect}>
|
||||
{t('settings.lfmConnect')}
|
||||
</button>
|
||||
) : (
|
||||
<p style={{ fontSize: 12, color: 'var(--warning)' }}>
|
||||
VITE_LASTFM_API_KEY / VITE_LASTFM_API_SECRET not set in build environment.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -530,23 +468,7 @@ export default function Settings() {
|
||||
<h2>{t('settings.theme')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div className="form-group" style={{ maxWidth: '300px' }}>
|
||||
<CustomSelect
|
||||
value={theme.theme}
|
||||
onChange={v => theme.setTheme(v as any)}
|
||||
options={[
|
||||
{ value: 'mocha', label: 'Catppuccin Mocha' },
|
||||
{ value: 'macchiato', label: 'Catppuccin Macchiato' },
|
||||
{ value: 'frappe', label: 'Catppuccin Frappé' },
|
||||
{ value: 'latte', label: 'Catppuccin Latte' },
|
||||
{ value: 'nord', label: 'Nord · Polar Night' },
|
||||
{ value: 'nord-snowstorm', label: 'Nord · Snowstorm' },
|
||||
{ value: 'nord-frost', label: 'Nord · Frost' },
|
||||
{ value: 'nord-aurora', label: 'Nord · Aurora' },
|
||||
{ value: 'psychowave', label: 'Psychowave' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<ThemePicker value={theme.theme} onChange={v => theme.setTheme(v as any)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -561,7 +483,9 @@ export default function Settings() {
|
||||
value={i18n.language}
|
||||
onChange={v => i18n.changeLanguage(v)}
|
||||
options={[
|
||||
{ value: 'nl', label: t('settings.languageNl') },
|
||||
{ value: 'en', label: t('settings.languageEn') },
|
||||
{ value: 'fr', label: t('settings.languageFr') },
|
||||
{ value: 'de', label: t('settings.languageDe') },
|
||||
]}
|
||||
/>
|
||||
@@ -653,6 +577,75 @@ export default function Settings() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Last.fm */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<LastfmIcon size={18} />
|
||||
<h2>{t('settings.lfmTitle')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
{auth.lastfmSessionKey ? (
|
||||
/* ── Connected state ── */
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.75rem 1rem', borderRadius: '10px', background: 'color-mix(in srgb, var(--accent) 8%, transparent)', border: '1px solid color-mix(in srgb, var(--accent) 20%, transparent)' }}>
|
||||
<div style={{ flexShrink: 0, color: '#e31c23' }}><LastfmIcon size={20} /></div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>@{auth.lastfmUsername}</div>
|
||||
{lfmUserInfo && (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2, display: 'flex', gap: '0.75rem' }}>
|
||||
<span>{t('settings.lfmScrobbles', { n: lfmUserInfo.playcount.toLocaleString() })}</span>
|
||||
<span>{t('settings.lfmMemberSince', { year: new Date(lfmUserInfo.registeredAt * 1000).getFullYear() })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12, padding: '4px 10px', flexShrink: 0 }}
|
||||
onClick={() => auth.disconnectLastfm()}
|
||||
>
|
||||
{t('settings.lfmDisconnect')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.scrobbleEnabled')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.scrobbleDesc')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.scrobbleEnabled')}>
|
||||
<input type="checkbox" checked={auth.scrobblingEnabled} onChange={e => auth.setScrobblingEnabled(e.target.checked)} id="scrobbling-toggle" />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
) : lfmState === 'waiting' ? (
|
||||
/* ── Waiting for browser auth — auto-polling ── */
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
<div className="spinner" style={{ width: 16, height: 16, borderWidth: 2 }} />
|
||||
{t('settings.lfmConnecting')}
|
||||
</div>
|
||||
<button className="btn btn-ghost" style={{ alignSelf: 'flex-start', fontSize: 12 }}
|
||||
onClick={() => { setLfmState('idle'); setLfmPendingToken(null); }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* ── Not connected ── */
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.5 }}>
|
||||
{t('settings.lfmConnectDesc')}
|
||||
</p>
|
||||
{lfmState === 'error' && (
|
||||
<p style={{ fontSize: 12, color: 'var(--danger)' }}>{lfmError}</p>
|
||||
)}
|
||||
<button className="btn btn-primary" style={{ alignSelf: 'flex-start' }} onClick={startLastfmConnect}>
|
||||
{t('settings.lfmConnect')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Downloads + Tray */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
|
||||
@@ -37,6 +37,7 @@ interface AuthState {
|
||||
isLoggedIn: boolean;
|
||||
isConnecting: boolean;
|
||||
connectionError: string | null;
|
||||
lastfmSessionError: boolean;
|
||||
|
||||
// Actions
|
||||
addServer: (profile: Omit<ServerProfile, 'id'>) => string;
|
||||
@@ -49,6 +50,7 @@ interface AuthState {
|
||||
setLastfm: (apiKey: string, apiSecret: string, sessionKey: string, username: string) => void;
|
||||
connectLastfm: (sessionKey: string, username: string) => void;
|
||||
disconnectLastfm: () => void;
|
||||
setLastfmSessionError: (v: boolean) => void;
|
||||
setMinimizeToTray: (v: boolean) => void;
|
||||
setScrobblingEnabled: (v: boolean) => void;
|
||||
setMaxCacheMb: (v: number) => void;
|
||||
@@ -94,6 +96,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
isLoggedIn: false,
|
||||
isConnecting: false,
|
||||
connectionError: null,
|
||||
lastfmSessionError: false,
|
||||
|
||||
addServer: (profile) => {
|
||||
const id = generateId();
|
||||
@@ -132,7 +135,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
set({ lastfmSessionKey: sessionKey, lastfmUsername: username }),
|
||||
|
||||
disconnectLastfm: () =>
|
||||
set({ lastfmSessionKey: '', lastfmUsername: '' }),
|
||||
set({ lastfmSessionKey: '', lastfmUsername: '', lastfmSessionError: false }),
|
||||
|
||||
setLastfmSessionError: (v) => set({ lastfmSessionError: v }),
|
||||
|
||||
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
|
||||
setScrobblingEnabled: (v) => set({ scrobblingEnabled: v }),
|
||||
|
||||
@@ -160,9 +160,9 @@ function handleAudioProgress(current_time: number, duration: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// Gapless preload: buffer next track when 30s remain
|
||||
// Pre-buffer / pre-chain next track when 30 s remain.
|
||||
const { gaplessEnabled } = useAuthStore.getState();
|
||||
if (gaplessEnabled && dur - current_time < 30 && dur - current_time > 0) {
|
||||
if (dur - current_time < 30 && dur - current_time > 0) {
|
||||
const { queue, queueIndex, repeatMode } = store;
|
||||
const nextIdx = queueIndex + 1;
|
||||
const nextTrack = repeatMode === 'one'
|
||||
@@ -170,7 +170,30 @@ function handleAudioProgress(current_time: number, duration: number) {
|
||||
: (nextIdx < queue.length ? queue[nextIdx] : (repeatMode === 'all' ? queue[0] : null));
|
||||
if (nextTrack && nextTrack.id !== track.id) {
|
||||
const nextUrl = buildStreamUrl(nextTrack.id);
|
||||
invoke('audio_preload', { url: nextUrl, durationHint: nextTrack.duration }).catch(() => {});
|
||||
if (gaplessEnabled) {
|
||||
// Gapless ON: decode + chain directly into the Sink now, 30 s in
|
||||
// advance. By the time the track boundary arrives, the next source is
|
||||
// already live — no IPC round-trip at the gap point.
|
||||
const authState = useAuthStore.getState();
|
||||
const replayGainDb = authState.replayGainEnabled
|
||||
? (authState.replayGainMode === 'album'
|
||||
? nextTrack.replayGainAlbumDb
|
||||
: nextTrack.replayGainTrackDb) ?? null
|
||||
: null;
|
||||
const replayGainPeak = authState.replayGainEnabled
|
||||
? (nextTrack.replayGainPeak ?? null)
|
||||
: null;
|
||||
invoke('audio_chain_preload', {
|
||||
url: nextUrl,
|
||||
volume: store.volume,
|
||||
durationHint: nextTrack.duration,
|
||||
replayGainDb,
|
||||
replayGainPeak,
|
||||
}).catch(() => {});
|
||||
} else {
|
||||
// Gapless OFF: just pre-download bytes so audio_play finds them cached.
|
||||
invoke('audio_preload', { url: nextUrl, durationHint: nextTrack.duration }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,16 +241,18 @@ export function initAudioListeners(): () => void {
|
||||
// Sync Last.fm loved tracks cache on startup.
|
||||
usePlayerStore.getState().syncLastfmLovedTracks();
|
||||
|
||||
// Initial sync of crossfade settings to Rust audio engine on startup.
|
||||
const { crossfadeEnabled, crossfadeSecs } = useAuthStore.getState();
|
||||
// Initial sync of audio settings to Rust engine on startup.
|
||||
const { crossfadeEnabled, crossfadeSecs, gaplessEnabled } = useAuthStore.getState();
|
||||
invoke('audio_set_crossfade', { enabled: crossfadeEnabled, secs: crossfadeSecs }).catch(() => {});
|
||||
invoke('audio_set_gapless', { enabled: gaplessEnabled }).catch(() => {});
|
||||
|
||||
// Keep crossfade settings in sync whenever auth store changes.
|
||||
// Keep audio settings in sync whenever auth store changes.
|
||||
const unsubAuth = useAuthStore.subscribe((state) => {
|
||||
invoke('audio_set_crossfade', {
|
||||
enabled: state.crossfadeEnabled,
|
||||
secs: state.crossfadeSecs,
|
||||
}).catch(() => {});
|
||||
invoke('audio_set_gapless', { enabled: state.gaplessEnabled }).catch(() => {});
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave';
|
||||
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | 'classic-winamp' | 'poison' | 'nucleo' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'tokyo-night' | 'tokyo-night-storm' | 'tokyo-night-light';
|
||||
|
||||
interface ThemeState {
|
||||
theme: Theme;
|
||||
|
||||
+98
-20
@@ -106,7 +106,7 @@
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ctp-lavender);
|
||||
color: var(--accent);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
@@ -148,7 +148,8 @@
|
||||
}
|
||||
|
||||
.hero-play-btn:hover {
|
||||
background: var(--ctp-lavender);
|
||||
background: var(--accent);
|
||||
filter: brightness(1.1);
|
||||
transform: scale(1.02);
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
@@ -630,13 +631,74 @@
|
||||
padding: var(--space-4) 0 var(--space-6);
|
||||
}
|
||||
|
||||
.album-detail-cover {
|
||||
.album-detail-cover-btn {
|
||||
padding: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
flex-shrink: 0;
|
||||
cursor: zoom-in;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
width: clamp(120px, 15vw, 200px);
|
||||
height: clamp(120px, 15vw, 200px);
|
||||
}
|
||||
|
||||
.album-detail-cover-btn:hover .album-detail-cover {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
.album-detail-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: var(--radius-lg);
|
||||
object-fit: cover;
|
||||
box-shadow: var(--shadow-lg);
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
/* ─── Cover Lightbox ─── */
|
||||
.cover-lightbox-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9000;
|
||||
background: rgba(0, 0, 0, 0.88);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 8vh;
|
||||
cursor: zoom-out;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.cover-lightbox-img {
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 32px 80px rgba(0, 0, 0, 0.8);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.cover-lightbox-close {
|
||||
position: fixed;
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.cover-lightbox-close:hover {
|
||||
background: rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
|
||||
.album-cover-placeholder {
|
||||
@@ -1179,6 +1241,21 @@
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.artist-detail-avatar-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: zoom-in;
|
||||
transition: transform 0.2s ease, filter 0.2s ease;
|
||||
}
|
||||
.artist-detail-avatar-btn:hover {
|
||||
transform: scale(1.04);
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.artist-detail-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -1422,7 +1499,7 @@
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
width: 100%;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
padding: 10px var(--space-4);
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
@@ -1455,7 +1532,7 @@
|
||||
}
|
||||
|
||||
.help-answer {
|
||||
padding: var(--space-4) var(--space-5) var(--space-5) calc(var(--space-5) + 3px);
|
||||
padding: 10px var(--space-4) var(--space-4) calc(var(--space-4) + 3px);
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.65;
|
||||
@@ -1730,11 +1807,11 @@
|
||||
}
|
||||
|
||||
@keyframes ken-burns {
|
||||
0% { transform: scale(1.1) translate(-8%, -8%); }
|
||||
25% { transform: scale(1.15) translate(8%, -8%); }
|
||||
50% { transform: scale(1.1) translate(8%, 8%); }
|
||||
75% { transform: scale(1.15) translate(-8%, 8%); }
|
||||
100% { transform: scale(1.1) translate(-8%, -8%); }
|
||||
0% { transform: scale(1.05) translate(-4%, -1%); }
|
||||
25% { transform: scale(1.08) translate( 4%, -1.5%); }
|
||||
50% { transform: scale(1.06) translate( 3%, 1%); }
|
||||
75% { transform: scale(1.09) translate(-3%, 1.5%); }
|
||||
100% { transform: scale(1.05) translate(-4%, -1%); }
|
||||
}
|
||||
|
||||
/* ── Blurred background ── */
|
||||
@@ -1742,9 +1819,9 @@
|
||||
position: absolute;
|
||||
inset: -30%;
|
||||
background-size: cover;
|
||||
background-position: center center;
|
||||
background-position: center 20%;
|
||||
filter: blur(6px) brightness(0.25) saturate(1.6);
|
||||
animation: ken-burns 90s ease-in-out infinite;
|
||||
animation: ken-burns 120s linear infinite;
|
||||
z-index: 0;
|
||||
will-change: transform;
|
||||
pointer-events: none;
|
||||
@@ -1802,7 +1879,7 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ctp-lavender);
|
||||
color: var(--accent);
|
||||
margin: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
@@ -1971,7 +2048,7 @@
|
||||
}
|
||||
|
||||
.fs-btn.active {
|
||||
color: var(--ctp-lavender);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.fs-btn-sm {
|
||||
@@ -1982,15 +2059,16 @@
|
||||
.fs-btn-play {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
background: linear-gradient(135deg, var(--ctp-mauve), var(--ctp-lavender));
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.fs-btn-play:hover {
|
||||
background: linear-gradient(135deg, var(--ctp-lavender), var(--ctp-mauve));
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
transform: scale(1.07);
|
||||
filter: brightness(1.12);
|
||||
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
@@ -3011,7 +3089,7 @@
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border: 1px solid var(--border-dropdown, var(--border));
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
@@ -3043,9 +3121,9 @@
|
||||
}
|
||||
.custom-select-dropdown {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border: 1px solid var(--border-dropdown, var(--border));
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
box-shadow: 0 8px 32px var(--shadow-dropdown, rgba(0, 0, 0, 0.4));
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
+133
-10
@@ -62,7 +62,7 @@
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
background: linear-gradient(135deg, var(--ctp-mauve), var(--ctp-blue));
|
||||
background: linear-gradient(135deg, var(--accent), var(--ctp-blue));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
@@ -439,7 +439,7 @@
|
||||
.player-btn-primary {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
background: linear-gradient(135deg, var(--ctp-mauve), var(--ctp-lavender));
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.4);
|
||||
@@ -447,10 +447,11 @@
|
||||
}
|
||||
|
||||
.player-btn-primary:hover {
|
||||
background: linear-gradient(135deg, var(--ctp-lavender), var(--ctp-mauve));
|
||||
background: var(--accent);
|
||||
color: var(--ctp-crust);
|
||||
transform: scale(1.06);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4), var(--shadow-glow);
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
|
||||
/* Waveform seekbar section */
|
||||
@@ -581,6 +582,121 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.queue-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.queue-action-btn:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.queue-action-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.queue-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 0 var(--space-3);
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--bg-app);
|
||||
}
|
||||
|
||||
.crossfade-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
right: 0;
|
||||
width: 170px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 12px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.crossfade-popover-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.crossfade-popover-value {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
font-family: monospace;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.crossfade-popover-slider {
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.crossfade-popover-range {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.queue-toolbar-sep {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: var(--border-subtle);
|
||||
margin: 0 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.queue-round-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.queue-round-btn:hover:not(:disabled) {
|
||||
background: var(--border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.queue-round-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
.queue-round-btn.active {
|
||||
color: var(--ctp-base);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.queue-current-track {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
display: flex;
|
||||
@@ -602,6 +718,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.queue-current-cover img {
|
||||
@@ -635,15 +752,21 @@
|
||||
}
|
||||
|
||||
.queue-current-tech {
|
||||
font-size: 10px;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
font-size: 9px;
|
||||
font-family: monospace;
|
||||
letter-spacing: 0.05em;
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-muted);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
display: inline-block;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
backdrop-filter: blur(4px);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
padding: 3px 6px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.queue-divider {
|
||||
|
||||
+784
-56
@@ -253,9 +253,11 @@
|
||||
--accent-glow: rgba(136, 192, 208, 0.3);
|
||||
--text-primary: #eceff4;
|
||||
--text-secondary:#e5e9f0;
|
||||
--text-muted: #8894a4;
|
||||
--border: #434c5e;
|
||||
--border-subtle: #3b4252;
|
||||
--text-muted: #8894a4;
|
||||
--border: #434c5e;
|
||||
--border-subtle: #3b4252;
|
||||
--border-dropdown: #576070;
|
||||
--shadow-dropdown: rgba(0, 0, 0, 0.55);
|
||||
--positive: #a3be8c;
|
||||
--warning: #ebcb8b;
|
||||
--danger: #bf616a;
|
||||
@@ -361,9 +363,11 @@
|
||||
--accent-glow: rgba(136, 192, 208, 0.35);
|
||||
--text-primary: #eceff4;
|
||||
--text-secondary:#e5e9f0;
|
||||
--text-muted: #7898b4;
|
||||
--border: #2d3f52;
|
||||
--border-subtle: #253545;
|
||||
--text-muted: #7898b4;
|
||||
--border: #2d3f52;
|
||||
--border-subtle: #253545;
|
||||
--border-dropdown: #3d546e;
|
||||
--shadow-dropdown: rgba(0, 0, 0, 0.6);
|
||||
--positive: #a3be8c;
|
||||
--warning: #ebcb8b;
|
||||
--danger: #bf616a;
|
||||
@@ -414,67 +418,789 @@
|
||||
--accent-glow: rgba(180, 142, 173, 0.3);
|
||||
--text-primary: #eceff4;
|
||||
--text-secondary:#e5e9f0;
|
||||
--text-muted: #8894a4;
|
||||
--border: #434c5e;
|
||||
--border-subtle: #3b4252;
|
||||
--text-muted: #8894a4;
|
||||
--border: #434c5e;
|
||||
--border-subtle: #3b4252;
|
||||
--border-dropdown: #576070;
|
||||
--shadow-dropdown: rgba(0, 0, 0, 0.55);
|
||||
--positive: #a3be8c;
|
||||
--warning: #ebcb8b;
|
||||
--danger: #bf616a;
|
||||
}
|
||||
|
||||
/* ─── Poison ─── */
|
||||
[data-theme='poison'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%231bd655%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
/* ── Palette — charcoal grays + phosphor green ── */
|
||||
--ctp-crust: #0e0e0e;
|
||||
--ctp-mantle: #161616;
|
||||
--ctp-base: #1f1f1f;
|
||||
--ctp-surface0: #282828;
|
||||
--ctp-surface1: #323232;
|
||||
--ctp-surface2: #3e3e3e;
|
||||
--ctp-overlay0: #525252;
|
||||
--ctp-overlay1: #686868;
|
||||
--ctp-overlay2: #848484;
|
||||
--ctp-text: #d0d0cc;
|
||||
--ctp-subtext1: #a8a8a4;
|
||||
--ctp-subtext0: #787874;
|
||||
/* Phosphor green accent — the Winamp LED display color */
|
||||
--ctp-mauve: #1bd655;
|
||||
--ctp-lavender: #28e860;
|
||||
--ctp-green: #1bd655;
|
||||
--ctp-teal: #1ab8a8;
|
||||
--ctp-sky: #58a8e0;
|
||||
--ctp-blue: #4888d0;
|
||||
--ctp-sapphire: #3870b8;
|
||||
--ctp-pink: #c86888;
|
||||
--ctp-flamingo: #b85858;
|
||||
--ctp-rosewater: #c88080;
|
||||
--ctp-yellow: #c8a830;
|
||||
--ctp-peach: #c07838;
|
||||
--ctp-maroon: #a03030;
|
||||
--ctp-red: #c03030;
|
||||
|
||||
/* ── Semantic tokens ── */
|
||||
--bg-app: #1f1f1f;
|
||||
--bg-sidebar: #161616;
|
||||
--bg-card: #282828;
|
||||
--bg-hover: #323232;
|
||||
--bg-player: #0e0e0e;
|
||||
--bg-glass: rgba(22, 22, 22, 0.90);
|
||||
--accent: #1bd655;
|
||||
--accent-dim: rgba(27, 214, 85, 0.12);
|
||||
--accent-glow: rgba(27, 214, 85, 0.35);
|
||||
--text-primary: #d0d0cc;
|
||||
--text-secondary:#a8a8a4;
|
||||
--text-muted: #686866;
|
||||
--border: #3a3a38;
|
||||
--border-subtle: #282826;
|
||||
--positive: #1bd655;
|
||||
--warning: #c8a830;
|
||||
--danger: #c03030;
|
||||
}
|
||||
|
||||
/* ── Winamp: LED display glow + tactile buttons ── */
|
||||
[data-theme='poison'] .player-track-name {
|
||||
text-shadow: 0 0 8px rgba(27, 214, 85, 0.7), 0 0 2px rgba(27, 214, 85, 0.4);
|
||||
color: #1bd655;
|
||||
}
|
||||
|
||||
[data-theme='poison'] .player-track-artist {
|
||||
color: #1bd655;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
[data-theme='poison'] .btn {
|
||||
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
[data-theme='poison'] .player-btn,
|
||||
[data-theme='poison'] .player-btn-primary {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.12), inset 0 -1px 0 rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* ─── Classic Winamp — Winamp 2.x Base Skin ─── */
|
||||
[data-theme='classic-winamp'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23c8c8d0%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
/* ── Palette ── */
|
||||
--ctp-crust: #000000;
|
||||
--ctp-mantle: #1e1e28;
|
||||
--ctp-base: #2b2b3a;
|
||||
--ctp-surface0: #353545;
|
||||
--ctp-surface1: #404055;
|
||||
--ctp-surface2: #4e4e65;
|
||||
--ctp-overlay0: #5a5a75;
|
||||
--ctp-overlay1: #6e6e88;
|
||||
--ctp-overlay2: #8a8a96;
|
||||
--ctp-text: #c8c8d0;
|
||||
--ctp-subtext1: #a0a0ac;
|
||||
--ctp-subtext0: #8a8a96;
|
||||
/* Winamp orange accent + iconic green glow */
|
||||
--ctp-mauve: #d4cc46;
|
||||
--ctp-lavender: #e0d860;
|
||||
--ctp-green: #00ff00;
|
||||
--ctp-teal: #00cc88;
|
||||
--ctp-sky: #5ab8e0;
|
||||
--ctp-blue: #4898d0;
|
||||
--ctp-sapphire: #3878b8;
|
||||
--ctp-pink: #c06888;
|
||||
--ctp-flamingo: #b85858;
|
||||
--ctp-rosewater: #c88080;
|
||||
--ctp-yellow: #e8c050;
|
||||
--ctp-peach: #d08840;
|
||||
--ctp-maroon: #a03030;
|
||||
--ctp-red: #c03030;
|
||||
|
||||
/* ── Semantic tokens ── */
|
||||
--bg-app: #2b2b3a;
|
||||
--bg-sidebar: #1e1e28;
|
||||
--bg-card: #353545;
|
||||
--bg-hover: #404055;
|
||||
--bg-player: #000000;
|
||||
--bg-glass: rgba(43, 43, 58, 0.90);
|
||||
--accent: #d4cc46;
|
||||
--accent-dim: rgba(212, 204, 70, 0.15);
|
||||
--accent-glow: rgba(212, 204, 70, 0.35);
|
||||
--volume-accent: #de9b35;
|
||||
--text-primary: #c8c8d0;
|
||||
--text-secondary:#a0a0ac;
|
||||
--text-muted: #6e6e88;
|
||||
--border: #5a5a75;
|
||||
--border-subtle: #353545;
|
||||
--positive: #00cc66;
|
||||
--warning: #d4cc46;
|
||||
--danger: #c03030;
|
||||
}
|
||||
|
||||
/* ── Classic Winamp: LCD display + bevel buttons ── */
|
||||
[data-theme='classic-winamp'] .player-track-name {
|
||||
color: #00ff00;
|
||||
text-shadow: 0 0 6px rgba(0, 255, 0, 0.65), 0 0 2px rgba(0, 255, 0, 0.4);
|
||||
font-family: 'Courier New', 'Lucida Console', monospace;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
[data-theme='classic-winamp'] .player-track-artist {
|
||||
color: #00cc00;
|
||||
font-family: 'Courier New', 'Lucida Console', monospace;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
[data-theme='classic-winamp'] .btn {
|
||||
box-shadow: inset 1px 1px 0 rgba(255,255,255,0.18), inset -1px -1px 0 rgba(0,0,0,0.55), 0 1px 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
[data-theme='classic-winamp'] .player-btn,
|
||||
[data-theme='classic-winamp'] .player-btn-primary {
|
||||
box-shadow: inset 1px 1px 0 rgba(255,255,255,0.18), inset -1px -1px 0 rgba(0,0,0,0.55), 0 2px 5px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
/* ─── Nucleo — Classic HiFi / Winamp Brass ─── */
|
||||
[data-theme='nucleo'] {
|
||||
color-scheme: light;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%238c7356%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
/* ── Palette ── */
|
||||
--ctp-crust: #c8a458;
|
||||
--ctp-mantle: #dfc08f;
|
||||
--ctp-base: #e9d3ac;
|
||||
--ctp-surface0: #eedbb8;
|
||||
--ctp-surface1: #f5e4c3;
|
||||
--ctp-surface2: #f9ecd4;
|
||||
--ctp-overlay0: #b09060;
|
||||
--ctp-overlay1: #987848;
|
||||
--ctp-overlay2: #805c30;
|
||||
--ctp-text: #2c1e10;
|
||||
--ctp-subtext1: #5a3e20;
|
||||
--ctp-subtext0: #8c7356;
|
||||
/* Accents — silver metal + muted brass tones */
|
||||
--ctp-mauve: #9e9a92; /* silver — primary accent */
|
||||
--ctp-lavender: #b8b4ac; /* lighter silver */
|
||||
--ctp-pink: #c06880;
|
||||
--ctp-flamingo: #c06050;
|
||||
--ctp-rosewater: #d08070;
|
||||
--ctp-blue: #5878a8; /* muted steel blue */
|
||||
--ctp-sapphire: #4868a0;
|
||||
--ctp-sky: #6898b8;
|
||||
--ctp-teal: #487878;
|
||||
--ctp-green: #587838; /* muted olive */
|
||||
--ctp-yellow: #a88020; /* dark burnished gold */
|
||||
--ctp-peach: #b87040;
|
||||
--ctp-maroon: #903030;
|
||||
--ctp-red: #a03828;
|
||||
|
||||
/* ── Semantic tokens ── */
|
||||
--bg-app: #f5e4c3;
|
||||
--bg-sidebar: #e9d3ac;
|
||||
--bg-card: #eedbb8;
|
||||
--bg-hover: #e3cc9e;
|
||||
--bg-player: #dfc08f;
|
||||
--bg-glass: rgba(245, 228, 195, 0.90);
|
||||
--accent: #9e9a92;
|
||||
--accent-dim: rgba(158, 154, 146, 0.15);
|
||||
--accent-glow: rgba(158, 154, 146, 0.35);
|
||||
--text-primary: #2c1e10;
|
||||
--text-secondary:#5a3e20;
|
||||
--text-muted: #8c7356;
|
||||
--border: #b89a69;
|
||||
--border-subtle: #dfc08f;
|
||||
--positive: #587838;
|
||||
--warning: #a88020;
|
||||
--danger: #a03828;
|
||||
}
|
||||
|
||||
/* ── Nucleo: skeuomorphic detail layer ── */
|
||||
[data-theme='nucleo'] .sidebar {
|
||||
background-image: repeating-linear-gradient(
|
||||
90deg,
|
||||
rgba(255,255,255,0.06) 0px,
|
||||
rgba(255,255,255,0.06) 1px,
|
||||
transparent 1px,
|
||||
transparent 5px
|
||||
);
|
||||
}
|
||||
|
||||
[data-theme='nucleo'] .player-bar {
|
||||
background-image: repeating-linear-gradient(
|
||||
90deg,
|
||||
rgba(255,255,255,0.05) 0px,
|
||||
rgba(255,255,255,0.05) 1px,
|
||||
transparent 1px,
|
||||
transparent 5px
|
||||
);
|
||||
}
|
||||
|
||||
[data-theme='nucleo'] .player-track-name {
|
||||
text-shadow: 0 0 10px rgba(255, 244, 219, 0.75), 0 0 2px rgba(255, 244, 219, 0.4);
|
||||
}
|
||||
|
||||
[data-theme='nucleo'] .btn {
|
||||
box-shadow: 0 2px 4px rgba(44, 30, 16, 0.20), inset 0 1px 0 rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
[data-theme='nucleo'] .btn-surface {
|
||||
background: #c8a860;
|
||||
color: #2c1e10;
|
||||
}
|
||||
[data-theme='nucleo'] .btn-surface:hover { background: #b89448; }
|
||||
|
||||
[data-theme='nucleo'] .btn-ghost:hover {
|
||||
background: #c8a860;
|
||||
color: #2c1e10;
|
||||
}
|
||||
|
||||
[data-theme='nucleo'] .player-btn,
|
||||
[data-theme='nucleo'] .player-btn-primary {
|
||||
box-shadow: 0 2px 5px rgba(44, 30, 16, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
/* ─── Psychowave — Synthwave / Retrowave ─── */
|
||||
[data-theme='psychowave'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23c9a8ff%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23b09ee0%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
/* ── Palette ── */
|
||||
--ctp-crust: #120720;
|
||||
--ctp-mantle: #160a28;
|
||||
--ctp-base: #1c0b35;
|
||||
--ctp-surface0: #2a1050;
|
||||
--ctp-surface1: #381860;
|
||||
--ctp-surface2: #4a2878;
|
||||
--ctp-overlay0: #5e3d90;
|
||||
--ctp-overlay1: #7b50b8;
|
||||
--ctp-overlay2: #9868d8;
|
||||
/* Soft lavender text */
|
||||
--ctp-text: #f0e6ff;
|
||||
--ctp-subtext1: #c9a8ff;
|
||||
--ctp-subtext0: #a882e0;
|
||||
/* Neon accents */
|
||||
--ctp-mauve: #ff2d78; /* hot pink — primary accent & button gradient start */
|
||||
--ctp-lavender: #bf5fff; /* electric purple — button gradient end */
|
||||
--ctp-pink: #ff2d78;
|
||||
--ctp-flamingo: #ff6b9d;
|
||||
--ctp-rosewater: #ff8fc0;
|
||||
--ctp-blue: #00d4ff; /* electric cyan */
|
||||
--ctp-sapphire: #007aff;
|
||||
--ctp-sky: #00bfff;
|
||||
--ctp-teal: #00e5cc;
|
||||
--ctp-green: #39ff14; /* neon green */
|
||||
--ctp-yellow: #ffd700; /* neon gold */
|
||||
--ctp-peach: #ff8c42;
|
||||
--ctp-maroon: #ff4d6d;
|
||||
--ctp-red: #ff2d55;
|
||||
--ctp-crust: #0d0c1a;
|
||||
--ctp-mantle: #110f22;
|
||||
--ctp-base: #161428;
|
||||
--ctp-surface0: #1f1c38;
|
||||
--ctp-surface1: #2a2750;
|
||||
--ctp-surface2: #383468;
|
||||
--ctp-overlay0: #4a4580;
|
||||
--ctp-overlay1: #6460a8;
|
||||
--ctp-overlay2: #8078c8;
|
||||
/* Text */
|
||||
--ctp-text: #e6e0f6;
|
||||
--ctp-subtext1: #b09ee0;
|
||||
--ctp-subtext0: #8878c0;
|
||||
/* Accents — muted synthwave palette */
|
||||
--ctp-mauve: #a06ae0; /* soft violet — primary accent */
|
||||
--ctp-lavender: #7878d8; /* muted indigo — gradient end */
|
||||
--ctp-pink: #c060a8; /* dusty rose */
|
||||
--ctp-flamingo: #c07090;
|
||||
--ctp-rosewater: #c89090;
|
||||
--ctp-blue: #58a8e8; /* steel blue */
|
||||
--ctp-sapphire: #4880c8;
|
||||
--ctp-sky: #60c0d8;
|
||||
--ctp-teal: #40a8a8;
|
||||
--ctp-green: #58c878; /* soft mint green */
|
||||
--ctp-yellow: #c8a848; /* muted gold */
|
||||
--ctp-peach: #c87848;
|
||||
--ctp-maroon: #c05868;
|
||||
--ctp-red: #b04060;
|
||||
|
||||
/* ── Semantic tokens ── */
|
||||
--bg-app: #1c0b35;
|
||||
--bg-sidebar: #160a28;
|
||||
--bg-card: #2a1050;
|
||||
--bg-hover: #381860;
|
||||
--bg-player: #120720;
|
||||
--bg-glass: rgba(28, 11, 53, 0.82);
|
||||
--accent: #ff2d78;
|
||||
--accent-dim: rgba(255, 45, 120, 0.15);
|
||||
--accent-glow: rgba(255, 45, 120, 0.45);
|
||||
--text-primary: #f0e6ff;
|
||||
--text-secondary:#c9a8ff;
|
||||
--text-muted: #9b72d0;
|
||||
--border: #4a2878;
|
||||
--border-subtle: #2a1050;
|
||||
--positive: #39ff14;
|
||||
--warning: #ffd700;
|
||||
--danger: #ff2d55;
|
||||
--bg-app: #161428;
|
||||
--bg-sidebar: #110f22;
|
||||
--bg-card: #1f1c38;
|
||||
--bg-hover: #2a2750;
|
||||
--bg-player: #0d0c1a;
|
||||
--bg-glass: rgba(22, 20, 40, 0.85);
|
||||
--accent: #a06ae0;
|
||||
--accent-dim: rgba(160, 106, 224, 0.12);
|
||||
--accent-glow: rgba(160, 106, 224, 0.30);
|
||||
--text-primary: #e6e0f6;
|
||||
--text-secondary:#b09ee0;
|
||||
--text-muted: #706898;
|
||||
--border: #383468;
|
||||
--border-subtle: #1f1c38;
|
||||
--positive: #58c878;
|
||||
--warning: #c8a848;
|
||||
--danger: #b04060;
|
||||
}
|
||||
|
||||
/* ─── Gruvbox Dark Hard ─── */
|
||||
[data-theme='gruvbox-dark-hard'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23ebdbb2%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
--ctp-crust: #1d2021;
|
||||
--ctp-mantle: #1d2021;
|
||||
--ctp-base: #282828;
|
||||
--ctp-surface0: #3c3836;
|
||||
--ctp-surface1: #504945;
|
||||
--ctp-surface2: #665c54;
|
||||
--ctp-overlay0: #7c6f64;
|
||||
--ctp-overlay1: #928374;
|
||||
--ctp-overlay2: #a89984;
|
||||
--ctp-text: #ebdbb2;
|
||||
--ctp-subtext1: #d5c4a1;
|
||||
--ctp-subtext0: #bdae93;
|
||||
--ctp-mauve: #d3869b;
|
||||
--ctp-lavender: #d3869b;
|
||||
--ctp-pink: #d3869b;
|
||||
--ctp-flamingo: #fb4934;
|
||||
--ctp-rosewater: #fb4934;
|
||||
--ctp-blue: #83a598;
|
||||
--ctp-sapphire: #83a598;
|
||||
--ctp-sky: #8ec07c;
|
||||
--ctp-teal: #8ec07c;
|
||||
--ctp-green: #b8bb26;
|
||||
--ctp-yellow: #fabd2f;
|
||||
--ctp-peach: #fe8019;
|
||||
--ctp-maroon: #fb4934;
|
||||
--ctp-red: #cc241d;
|
||||
|
||||
--bg-app: #1d2021;
|
||||
--bg-sidebar: #141617;
|
||||
--bg-card: #3c3836;
|
||||
--bg-hover: #504945;
|
||||
--bg-player: #1d2021;
|
||||
--bg-glass: rgba(29, 32, 33, 0.82);
|
||||
--accent: #fabd2f;
|
||||
--accent-dim: rgba(250, 189, 47, 0.15);
|
||||
--accent-glow: rgba(250, 189, 47, 0.3);
|
||||
--text-primary: #ebdbb2;
|
||||
--text-secondary:#d5c4a1;
|
||||
--text-muted: #928374;
|
||||
--border: #504945;
|
||||
--border-subtle: #3c3836;
|
||||
--border-dropdown: #665c54;
|
||||
--shadow-dropdown: rgba(0, 0, 0, 0.6);
|
||||
--positive: #b8bb26;
|
||||
--warning: #fabd2f;
|
||||
--danger: #fb4934;
|
||||
}
|
||||
|
||||
/* ─── Gruvbox Dark Medium ─── */
|
||||
[data-theme='gruvbox-dark-medium'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23ebdbb2%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
--ctp-crust: #1d2021;
|
||||
--ctp-mantle: #1d2021;
|
||||
--ctp-base: #282828;
|
||||
--ctp-surface0: #3c3836;
|
||||
--ctp-surface1: #504945;
|
||||
--ctp-surface2: #665c54;
|
||||
--ctp-overlay0: #7c6f64;
|
||||
--ctp-overlay1: #928374;
|
||||
--ctp-overlay2: #a89984;
|
||||
--ctp-text: #ebdbb2;
|
||||
--ctp-subtext1: #d5c4a1;
|
||||
--ctp-subtext0: #bdae93;
|
||||
--ctp-mauve: #d3869b;
|
||||
--ctp-lavender: #d3869b;
|
||||
--ctp-pink: #d3869b;
|
||||
--ctp-flamingo: #fb4934;
|
||||
--ctp-rosewater: #fb4934;
|
||||
--ctp-blue: #83a598;
|
||||
--ctp-sapphire: #83a598;
|
||||
--ctp-sky: #8ec07c;
|
||||
--ctp-teal: #8ec07c;
|
||||
--ctp-green: #b8bb26;
|
||||
--ctp-yellow: #fabd2f;
|
||||
--ctp-peach: #fe8019;
|
||||
--ctp-maroon: #fb4934;
|
||||
--ctp-red: #cc241d;
|
||||
|
||||
--bg-app: #282828;
|
||||
--bg-sidebar: #1d2021;
|
||||
--bg-card: #3c3836;
|
||||
--bg-hover: #504945;
|
||||
--bg-player: #1d2021;
|
||||
--bg-glass: rgba(40, 40, 40, 0.82);
|
||||
--accent: #fabd2f;
|
||||
--accent-dim: rgba(250, 189, 47, 0.15);
|
||||
--accent-glow: rgba(250, 189, 47, 0.3);
|
||||
--text-primary: #ebdbb2;
|
||||
--text-secondary:#d5c4a1;
|
||||
--text-muted: #928374;
|
||||
--border: #504945;
|
||||
--border-subtle: #3c3836;
|
||||
--border-dropdown: #665c54;
|
||||
--shadow-dropdown: rgba(0, 0, 0, 0.55);
|
||||
--positive: #b8bb26;
|
||||
--warning: #fabd2f;
|
||||
--danger: #fb4934;
|
||||
}
|
||||
|
||||
/* ─── Gruvbox Dark Soft ─── */
|
||||
[data-theme='gruvbox-dark-soft'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23ebdbb2%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
--ctp-crust: #1d2021;
|
||||
--ctp-mantle: #282828;
|
||||
--ctp-base: #32302f;
|
||||
--ctp-surface0: #3c3836;
|
||||
--ctp-surface1: #504945;
|
||||
--ctp-surface2: #665c54;
|
||||
--ctp-overlay0: #7c6f64;
|
||||
--ctp-overlay1: #928374;
|
||||
--ctp-overlay2: #a89984;
|
||||
--ctp-text: #ebdbb2;
|
||||
--ctp-subtext1: #d5c4a1;
|
||||
--ctp-subtext0: #bdae93;
|
||||
--ctp-mauve: #d3869b;
|
||||
--ctp-lavender: #d3869b;
|
||||
--ctp-pink: #d3869b;
|
||||
--ctp-flamingo: #fb4934;
|
||||
--ctp-rosewater: #fb4934;
|
||||
--ctp-blue: #83a598;
|
||||
--ctp-sapphire: #83a598;
|
||||
--ctp-sky: #8ec07c;
|
||||
--ctp-teal: #8ec07c;
|
||||
--ctp-green: #b8bb26;
|
||||
--ctp-yellow: #fabd2f;
|
||||
--ctp-peach: #fe8019;
|
||||
--ctp-maroon: #fb4934;
|
||||
--ctp-red: #cc241d;
|
||||
|
||||
--bg-app: #32302f;
|
||||
--bg-sidebar: #282828;
|
||||
--bg-card: #45403d;
|
||||
--bg-hover: #504945;
|
||||
--bg-player: #282828;
|
||||
--bg-glass: rgba(50, 48, 47, 0.82);
|
||||
--accent: #fabd2f;
|
||||
--accent-dim: rgba(250, 189, 47, 0.15);
|
||||
--accent-glow: rgba(250, 189, 47, 0.3);
|
||||
--text-primary: #ebdbb2;
|
||||
--text-secondary:#d5c4a1;
|
||||
--text-muted: #928374;
|
||||
--border: #504945;
|
||||
--border-subtle: #3c3836;
|
||||
--border-dropdown: #665c54;
|
||||
--shadow-dropdown: rgba(0, 0, 0, 0.5);
|
||||
--positive: #b8bb26;
|
||||
--warning: #fabd2f;
|
||||
--danger: #fb4934;
|
||||
}
|
||||
|
||||
/* ─── Gruvbox Light Hard ─── */
|
||||
[data-theme='gruvbox-light-hard'] {
|
||||
color-scheme: light;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23504945%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
--ctp-crust: #ebdbb2;
|
||||
--ctp-mantle: #f2e5bc;
|
||||
--ctp-base: #f9f5d7;
|
||||
--ctp-surface0: #f2e5bc;
|
||||
--ctp-surface1: #ebdbb2;
|
||||
--ctp-surface2: #d5c4a1;
|
||||
--ctp-overlay0: #bdae93;
|
||||
--ctp-overlay1: #a89984;
|
||||
--ctp-overlay2: #928374;
|
||||
--ctp-text: #3c3836;
|
||||
--ctp-subtext1: #504945;
|
||||
--ctp-subtext0: #665c54;
|
||||
--ctp-mauve: #8f3f71;
|
||||
--ctp-lavender: #8f3f71;
|
||||
--ctp-pink: #8f3f71;
|
||||
--ctp-flamingo: #9d0006;
|
||||
--ctp-rosewater: #cc241d;
|
||||
--ctp-blue: #076678;
|
||||
--ctp-sapphire: #076678;
|
||||
--ctp-sky: #427b58;
|
||||
--ctp-teal: #427b58;
|
||||
--ctp-green: #79740e;
|
||||
--ctp-yellow: #b57614;
|
||||
--ctp-peach: #af3a03;
|
||||
--ctp-maroon: #9d0006;
|
||||
--ctp-red: #cc241d;
|
||||
|
||||
--bg-app: #f9f5d7;
|
||||
--bg-sidebar: #f2e5bc;
|
||||
--bg-card: #f2e5bc;
|
||||
--bg-hover: #ebdbb2;
|
||||
--bg-player: #f2e5bc;
|
||||
--bg-glass: rgba(249, 245, 215, 0.9);
|
||||
--accent: #b57614;
|
||||
--accent-dim: rgba(181, 118, 20, 0.15);
|
||||
--accent-glow: rgba(181, 118, 20, 0.25);
|
||||
--text-primary: #3c3836;
|
||||
--text-secondary:#504945;
|
||||
--text-muted: #7c6f64;
|
||||
--border: #d5c4a1;
|
||||
--border-subtle: #ebdbb2;
|
||||
--positive: #79740e;
|
||||
--warning: #b57614;
|
||||
--danger: #9d0006;
|
||||
}
|
||||
|
||||
/* ─── Gruvbox Light Medium ─── */
|
||||
[data-theme='gruvbox-light-medium'] {
|
||||
color-scheme: light;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23504945%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
--ctp-crust: #ebdbb2;
|
||||
--ctp-mantle: #f2e5bc;
|
||||
--ctp-base: #fbf1c7;
|
||||
--ctp-surface0: #f2e5bc;
|
||||
--ctp-surface1: #ebdbb2;
|
||||
--ctp-surface2: #d5c4a1;
|
||||
--ctp-overlay0: #bdae93;
|
||||
--ctp-overlay1: #a89984;
|
||||
--ctp-overlay2: #928374;
|
||||
--ctp-text: #3c3836;
|
||||
--ctp-subtext1: #504945;
|
||||
--ctp-subtext0: #665c54;
|
||||
--ctp-mauve: #8f3f71;
|
||||
--ctp-lavender: #8f3f71;
|
||||
--ctp-pink: #8f3f71;
|
||||
--ctp-flamingo: #9d0006;
|
||||
--ctp-rosewater: #cc241d;
|
||||
--ctp-blue: #076678;
|
||||
--ctp-sapphire: #076678;
|
||||
--ctp-sky: #427b58;
|
||||
--ctp-teal: #427b58;
|
||||
--ctp-green: #79740e;
|
||||
--ctp-yellow: #b57614;
|
||||
--ctp-peach: #af3a03;
|
||||
--ctp-maroon: #9d0006;
|
||||
--ctp-red: #cc241d;
|
||||
|
||||
--bg-app: #fbf1c7;
|
||||
--bg-sidebar: #f2e5bc;
|
||||
--bg-card: #f2e5bc;
|
||||
--bg-hover: #ebdbb2;
|
||||
--bg-player: #f2e5bc;
|
||||
--bg-glass: rgba(251, 241, 199, 0.9);
|
||||
--accent: #b57614;
|
||||
--accent-dim: rgba(181, 118, 20, 0.15);
|
||||
--accent-glow: rgba(181, 118, 20, 0.25);
|
||||
--text-primary: #3c3836;
|
||||
--text-secondary:#504945;
|
||||
--text-muted: #7c6f64;
|
||||
--border: #d5c4a1;
|
||||
--border-subtle: #ebdbb2;
|
||||
--positive: #79740e;
|
||||
--warning: #b57614;
|
||||
--danger: #9d0006;
|
||||
}
|
||||
|
||||
/* ─── Gruvbox Light Soft ─── */
|
||||
[data-theme='gruvbox-light-soft'] {
|
||||
color-scheme: light;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23504945%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
--ctp-crust: #ebdbb2;
|
||||
--ctp-mantle: #f2e5bc;
|
||||
--ctp-base: #f2e5bc;
|
||||
--ctp-surface0: #ebdbb2;
|
||||
--ctp-surface1: #d5c4a1;
|
||||
--ctp-surface2: #bdae93;
|
||||
--ctp-overlay0: #a89984;
|
||||
--ctp-overlay1: #928374;
|
||||
--ctp-overlay2: #7c6f64;
|
||||
--ctp-text: #3c3836;
|
||||
--ctp-subtext1: #504945;
|
||||
--ctp-subtext0: #665c54;
|
||||
--ctp-mauve: #8f3f71;
|
||||
--ctp-lavender: #8f3f71;
|
||||
--ctp-pink: #8f3f71;
|
||||
--ctp-flamingo: #9d0006;
|
||||
--ctp-rosewater: #cc241d;
|
||||
--ctp-blue: #076678;
|
||||
--ctp-sapphire: #076678;
|
||||
--ctp-sky: #427b58;
|
||||
--ctp-teal: #427b58;
|
||||
--ctp-green: #79740e;
|
||||
--ctp-yellow: #b57614;
|
||||
--ctp-peach: #af3a03;
|
||||
--ctp-maroon: #9d0006;
|
||||
--ctp-red: #cc241d;
|
||||
|
||||
--bg-app: #f2e5bc;
|
||||
--bg-sidebar: #ebdbb2;
|
||||
--bg-card: #ebdbb2;
|
||||
--bg-hover: #d5c4a1;
|
||||
--bg-player: #ebdbb2;
|
||||
--bg-glass: rgba(242, 229, 188, 0.9);
|
||||
--accent: #b57614;
|
||||
--accent-dim: rgba(181, 118, 20, 0.15);
|
||||
--accent-glow: rgba(181, 118, 20, 0.25);
|
||||
--text-primary: #3c3836;
|
||||
--text-secondary:#504945;
|
||||
--text-muted: #7c6f64;
|
||||
--border: #d5c4a1;
|
||||
--border-subtle: #ebdbb2;
|
||||
--positive: #79740e;
|
||||
--warning: #b57614;
|
||||
--danger: #9d0006;
|
||||
}
|
||||
|
||||
/* ─── Tokyo Night ─── */
|
||||
[data-theme='tokyo-night'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23a9b1d6%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
--ctp-crust: #16161e;
|
||||
--ctp-mantle: #1a1b26;
|
||||
--ctp-base: #1a1b26;
|
||||
--ctp-surface0: #24283b;
|
||||
--ctp-surface1: #2f334d;
|
||||
--ctp-surface2: #3b3f5c;
|
||||
--ctp-overlay0: #414868;
|
||||
--ctp-overlay1: #565f89;
|
||||
--ctp-overlay2: #6272a4;
|
||||
--ctp-text: #c0caf5;
|
||||
--ctp-subtext1: #a9b1d6;
|
||||
--ctp-subtext0: #9aa5ce;
|
||||
--ctp-mauve: #bb9af7;
|
||||
--ctp-lavender: #c0caf5;
|
||||
--ctp-pink: #f7768e;
|
||||
--ctp-flamingo: #ff9e64;
|
||||
--ctp-rosewater: #f7768e;
|
||||
--ctp-blue: #7aa2f7;
|
||||
--ctp-sapphire: #7dcfff;
|
||||
--ctp-sky: #7dcfff;
|
||||
--ctp-teal: #2ac3de;
|
||||
--ctp-green: #9ece6a;
|
||||
--ctp-yellow: #e0af68;
|
||||
--ctp-peach: #ff9e64;
|
||||
--ctp-maroon: #f7768e;
|
||||
--ctp-red: #f7768e;
|
||||
|
||||
--bg-app: #1a1b26;
|
||||
--bg-sidebar: #16161e;
|
||||
--bg-card: #24283b;
|
||||
--bg-hover: #2f334d;
|
||||
--bg-player: #16161e;
|
||||
--bg-glass: rgba(26, 27, 38, 0.82);
|
||||
--accent: #7aa2f7;
|
||||
--accent-dim: rgba(122, 162, 247, 0.15);
|
||||
--accent-glow: rgba(122, 162, 247, 0.3);
|
||||
--text-primary: #c0caf5;
|
||||
--text-secondary:#a9b1d6;
|
||||
--text-muted: #565f89;
|
||||
--border: #2f334d;
|
||||
--border-subtle: #24283b;
|
||||
--border-dropdown: #3d425e;
|
||||
--shadow-dropdown: rgba(0, 0, 0, 0.55);
|
||||
--positive: #9ece6a;
|
||||
--warning: #e0af68;
|
||||
--danger: #f7768e;
|
||||
}
|
||||
|
||||
/* ─── Tokyo Night Storm ─── */
|
||||
[data-theme='tokyo-night-storm'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23a9b1d6%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
--ctp-crust: #1f2335;
|
||||
--ctp-mantle: #1f2335;
|
||||
--ctp-base: #24283b;
|
||||
--ctp-surface0: #2f334d;
|
||||
--ctp-surface1: #3b3f5c;
|
||||
--ctp-surface2: #414868;
|
||||
--ctp-overlay0: #565f89;
|
||||
--ctp-overlay1: #6272a4;
|
||||
--ctp-overlay2: #737aa2;
|
||||
--ctp-text: #c0caf5;
|
||||
--ctp-subtext1: #a9b1d6;
|
||||
--ctp-subtext0: #9aa5ce;
|
||||
--ctp-mauve: #bb9af7;
|
||||
--ctp-lavender: #c0caf5;
|
||||
--ctp-pink: #f7768e;
|
||||
--ctp-flamingo: #ff9e64;
|
||||
--ctp-rosewater: #f7768e;
|
||||
--ctp-blue: #7aa2f7;
|
||||
--ctp-sapphire: #7dcfff;
|
||||
--ctp-sky: #7dcfff;
|
||||
--ctp-teal: #2ac3de;
|
||||
--ctp-green: #9ece6a;
|
||||
--ctp-yellow: #e0af68;
|
||||
--ctp-peach: #ff9e64;
|
||||
--ctp-maroon: #f7768e;
|
||||
--ctp-red: #f7768e;
|
||||
|
||||
--bg-app: #24283b;
|
||||
--bg-sidebar: #1f2335;
|
||||
--bg-card: #2f334d;
|
||||
--bg-hover: #3b3f5c;
|
||||
--bg-player: #1f2335;
|
||||
--bg-glass: rgba(36, 40, 59, 0.82);
|
||||
--accent: #7aa2f7;
|
||||
--accent-dim: rgba(122, 162, 247, 0.15);
|
||||
--accent-glow: rgba(122, 162, 247, 0.3);
|
||||
--text-primary: #c0caf5;
|
||||
--text-secondary:#a9b1d6;
|
||||
--text-muted: #565f89;
|
||||
--border: #3b3f5c;
|
||||
--border-subtle: #2f334d;
|
||||
--border-dropdown: #414868;
|
||||
--shadow-dropdown: rgba(0, 0, 0, 0.55);
|
||||
--positive: #9ece6a;
|
||||
--warning: #e0af68;
|
||||
--danger: #f7768e;
|
||||
}
|
||||
|
||||
/* ─── Tokyo Night Light ─── */
|
||||
[data-theme='tokyo-night-light'] {
|
||||
color-scheme: light;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23343b58%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
|
||||
--ctp-crust: #cbccd1;
|
||||
--ctp-mantle: #d5d6db;
|
||||
--ctp-base: #d5d6db;
|
||||
--ctp-surface0: #e9e9ec;
|
||||
--ctp-surface1: #c4c5cc;
|
||||
--ctp-surface2: #b4b5be;
|
||||
--ctp-overlay0: #a0a1ab;
|
||||
--ctp-overlay1: #9699a3;
|
||||
--ctp-overlay2: #8488a0;
|
||||
--ctp-text: #343b58;
|
||||
--ctp-subtext1: #565a6e;
|
||||
--ctp-subtext0: #6b6f85;
|
||||
--ctp-mauve: #5a4a78;
|
||||
--ctp-lavender: #34548a;
|
||||
--ctp-pink: #8f2f5c;
|
||||
--ctp-flamingo: #965027;
|
||||
--ctp-rosewater: #8c4351;
|
||||
--ctp-blue: #34548a;
|
||||
--ctp-sapphire: #0f4b6e;
|
||||
--ctp-sky: #0f4b6e;
|
||||
--ctp-teal: #0f4b6e;
|
||||
--ctp-green: #485e30;
|
||||
--ctp-yellow: #8f5e15;
|
||||
--ctp-peach: #965027;
|
||||
--ctp-maroon: #8c4351;
|
||||
--ctp-red: #8c4351;
|
||||
|
||||
--bg-app: #d5d6db;
|
||||
--bg-sidebar: #cbccd1;
|
||||
--bg-card: #e9e9ec;
|
||||
--bg-hover: #c4c5cc;
|
||||
--bg-player: #cbccd1;
|
||||
--bg-glass: rgba(213, 214, 219, 0.9);
|
||||
--accent: #34548a;
|
||||
--accent-dim: rgba(52, 84, 138, 0.15);
|
||||
--accent-glow: rgba(52, 84, 138, 0.25);
|
||||
--text-primary: #343b58;
|
||||
--text-secondary:#565a6e;
|
||||
--text-muted: #9699a3;
|
||||
--border: #c4c5cc;
|
||||
--border-subtle: #d5d6db;
|
||||
--positive: #485e30;
|
||||
--warning: #8f5e15;
|
||||
--danger: #8c4351;
|
||||
}
|
||||
|
||||
/* ─── Global Base Settings ─── */
|
||||
@@ -525,6 +1251,8 @@
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
min-width: 1280px;
|
||||
min-height: 720px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -616,7 +1344,7 @@ body.is-dragging * {
|
||||
color: var(--ctp-crust);
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-primary:hover { background: var(--ctp-lavender); transform: translateY(-1px); box-shadow: var(--shadow-glow); }
|
||||
.btn-primary:hover { background: var(--accent); filter: brightness(1.1); transform: translateY(-1px); box-shadow: var(--shadow-glow); }
|
||||
.btn-primary:active { transform: translateY(0); }
|
||||
|
||||
.btn-ghost {
|
||||
|
||||
Reference in New Issue
Block a user