mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat: v1.11.0 — Movies themes, Settings polish, Gapless stable
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -167,7 +167,7 @@ export async function getAlbumList(
|
||||
offset = 0,
|
||||
extra: Record<string, unknown> = {}
|
||||
): Promise<SubsonicAlbum[]> {
|
||||
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', { type, size, offset, ...extra });
|
||||
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', { type, size, offset, _t: Date.now(), ...extra });
|
||||
return data.albumList2?.album ?? [];
|
||||
}
|
||||
|
||||
|
||||
@@ -59,8 +59,8 @@ export default function AlbumCard({ album }: AlbumCardProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="album-card-info">
|
||||
<p className="album-card-title truncate" data-tooltip={album.name}>{album.name}</p>
|
||||
<p className="album-card-artist truncate" data-tooltip={album.artist}>{album.artist}</p>
|
||||
<p className="album-card-title truncate">{album.name}</p>
|
||||
<p className="album-card-artist truncate">{album.artist}</p>
|
||||
{album.year && <p className="album-card-year">{album.year}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -144,7 +144,7 @@ export default function AlbumTrackList({
|
||||
: (song.track ?? i + 1)}
|
||||
</div>
|
||||
<div className="track-info">
|
||||
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
{hasVariousArtists && (
|
||||
<div className="track-artist-cell">
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function ArtistCardLocal({ artist }: Props) {
|
||||
)}
|
||||
</div>
|
||||
<div className="artist-card-info">
|
||||
<span className="artist-card-name" data-tooltip={artist.name}>{artist.name}</span>
|
||||
<span className="artist-card-name">{artist.name}</span>
|
||||
{typeof artist.albumCount === 'number' && (
|
||||
<span className="artist-card-meta">
|
||||
{artist.albumCount} {artist.albumCount === 1 ? 'Album' : 'Alben'}
|
||||
|
||||
@@ -75,7 +75,6 @@ export default function PlayerBar() {
|
||||
<div className="player-track-meta">
|
||||
<div
|
||||
className="player-track-name"
|
||||
data-tooltip={currentTrack?.title ?? ''}
|
||||
style={{ cursor: currentTrack?.albumId ? 'pointer' : 'default' }}
|
||||
onClick={() => currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
|
||||
>
|
||||
@@ -83,7 +82,6 @@ export default function PlayerBar() {
|
||||
</div>
|
||||
<div
|
||||
className="player-track-artist"
|
||||
data-tooltip={currentTrack?.artist ?? ''}
|
||||
style={{ cursor: currentTrack?.artistId ? 'pointer' : 'default' }}
|
||||
onClick={() => currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||
>
|
||||
|
||||
@@ -329,16 +329,14 @@ export default function QueuePanel() {
|
||||
)}
|
||||
</div>
|
||||
<div className="queue-current-info">
|
||||
<h3 className="truncate" data-tooltip={currentTrack.title}>{currentTrack.title}</h3>
|
||||
<h3 className="truncate">{currentTrack.title}</h3>
|
||||
<div
|
||||
className="queue-current-sub truncate"
|
||||
data-tooltip={currentTrack.artist}
|
||||
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
|
||||
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||
>{currentTrack.artist}</div>
|
||||
<div
|
||||
className="queue-current-sub truncate"
|
||||
data-tooltip={currentTrack.album}
|
||||
style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }}
|
||||
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
|
||||
>{currentTrack.album}</div>
|
||||
|
||||
@@ -32,7 +32,7 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Betriebssysteme',
|
||||
group: 'Operating Systems',
|
||||
themes: [
|
||||
{ id: 'cupertino-light', label: 'Cupertino Light', bg: '#ffffff', card: '#f2f2f7', accent: '#0071e3' },
|
||||
{ id: 'cupertino-dark', label: 'Cupertino Dark', bg: '#1e1e1f', card: '#2d2d2f', accent: '#007aff' },
|
||||
@@ -77,6 +77,16 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
|
||||
{ id: 'tokyo-night-light', label: 'Light', bg: '#d5d6db', card: '#e9e9ec', accent: '#34548a' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Movies',
|
||||
themes: [
|
||||
{ id: 'middle-earth', label: 'Middle Earth', bg: '#f4e4bc', card: '#2a1d15', accent: '#d4af37' },
|
||||
{ id: 'morpheus', label: 'Morpheus', bg: '#0a0a0a', card: '#000000', accent: '#00ff41' },
|
||||
{ id: 'pandora', label: 'Pandora', bg: '#0c1b22', card: '#142b35', accent: '#00f2ff' },
|
||||
{ id: 'stark-hud', label: 'Stark HUD', bg: '#0b0f15', card: '#05070a', accent: '#00f2ff' },
|
||||
{ id: 'blade', label: 'Blade', bg: '#121212', card: '#050505', accent: '#b30000' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -13,6 +13,9 @@ export default function TooltipPortal() {
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const [style, setStyle] = useState<React.CSSProperties>({ opacity: 0 });
|
||||
|
||||
const tooltipRef = useRef<TooltipState | null>(null);
|
||||
tooltipRef.current = tooltip;
|
||||
|
||||
useEffect(() => {
|
||||
const onOver = (e: MouseEvent) => {
|
||||
const target = (e.target as HTMLElement).closest('[data-tooltip]') as HTMLElement | null;
|
||||
@@ -26,15 +29,19 @@ export default function TooltipPortal() {
|
||||
wrap: target.hasAttribute('data-tooltip-wrap'),
|
||||
});
|
||||
};
|
||||
const onOut = (e: MouseEvent) => {
|
||||
const onOut = () => setTooltip(null);
|
||||
const onMove = (e: MouseEvent) => {
|
||||
if (!tooltipRef.current) return;
|
||||
const target = (e.target as HTMLElement).closest('[data-tooltip]');
|
||||
if (target) setTooltip(null);
|
||||
if (!target) setTooltip(null);
|
||||
};
|
||||
document.addEventListener('mouseover', onOver);
|
||||
document.addEventListener('mouseout', onOut);
|
||||
document.addEventListener('mousemove', onMove, { passive: true });
|
||||
return () => {
|
||||
document.removeEventListener('mouseover', onOver);
|
||||
document.removeEventListener('mouseout', onOut);
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -64,6 +64,8 @@ const enTranslation = {
|
||||
goToArtist: 'Go to Artist',
|
||||
readMore: 'Read more',
|
||||
showLess: 'Show less',
|
||||
genreInfo: 'Genre',
|
||||
trackInfo: 'Track Info',
|
||||
},
|
||||
contextMenu: {
|
||||
playNow: 'Play Now',
|
||||
@@ -590,6 +592,8 @@ const deTranslation = {
|
||||
goToArtist: 'Zum Künstler',
|
||||
readMore: 'Mehr lesen',
|
||||
showLess: 'Weniger anzeigen',
|
||||
genreInfo: 'Genre',
|
||||
trackInfo: 'Track-Info',
|
||||
},
|
||||
contextMenu: {
|
||||
playNow: 'Direkt abspielen',
|
||||
@@ -1116,6 +1120,8 @@ const frTranslation = {
|
||||
goToArtist: "Aller à l'artiste",
|
||||
readMore: 'Lire la suite',
|
||||
showLess: 'Réduire',
|
||||
genreInfo: 'Genre',
|
||||
trackInfo: 'Infos piste',
|
||||
},
|
||||
contextMenu: {
|
||||
playNow: 'Lire maintenant',
|
||||
@@ -1642,6 +1648,8 @@ const nlTranslation = {
|
||||
goToArtist: 'Naar artiest',
|
||||
readMore: 'Meer lezen',
|
||||
showLess: 'Minder tonen',
|
||||
genreInfo: 'Genre',
|
||||
trackInfo: 'Trackinfo',
|
||||
},
|
||||
contextMenu: {
|
||||
playNow: 'Nu afspelen',
|
||||
|
||||
@@ -106,7 +106,7 @@ export default function Favorites() {
|
||||
{i + 1}
|
||||
</div>
|
||||
<div className="track-info">
|
||||
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
<div className="track-artist-cell">
|
||||
<span
|
||||
|
||||
+150
-62
@@ -107,30 +107,49 @@ const EQBars = memo(function EQBars({ isPlaying }: { isPlaying: boolean }) {
|
||||
// ─── Tag Cloud ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TagCloudProps {
|
||||
genre?: string;
|
||||
year?: number;
|
||||
similarArtists: Array<{ id: string; name: string }>;
|
||||
onArtistClick: (id: string) => void;
|
||||
}
|
||||
|
||||
function TagCloud({ genre, year, similarArtists, onArtistClick }: TagCloudProps) {
|
||||
function strHash(s: string): number {
|
||||
let h = 0;
|
||||
for (const c of s) h = (h * 31 + c.charCodeAt(0)) & 0xffff;
|
||||
return h;
|
||||
}
|
||||
|
||||
function TagCloud({ similarArtists, onArtistClick }: TagCloudProps) {
|
||||
const { t } = useTranslation();
|
||||
const hasTags = genre || year || similarArtists.length > 0;
|
||||
if (!hasTags) return null;
|
||||
if (similarArtists.length === 0) return null;
|
||||
|
||||
const getTagStyle = (name: string, idx: number): React.CSSProperties => {
|
||||
const h = strHash(name);
|
||||
const sizePool = [12, 13, 14, 15, 16, 17, 18, 20, 22];
|
||||
const size = sizePool[(h + idx * 7) % sizePool.length];
|
||||
const weight = size >= 19 ? 700 : size >= 16 ? 500 : 400;
|
||||
const pad = size >= 18 ? '7px 15px' : size >= 15 ? '6px 12px' : '5px 10px';
|
||||
const opacity = 0.6 + ((h % 5) * 0.08);
|
||||
const verticals = [-10, -6, -3, 0, 4, 7, 10, -8, 3, -4, 8, -1, 5, -7, 2];
|
||||
const ty = verticals[(h + idx * 4) % verticals.length];
|
||||
return { fontSize: `${size}px`, fontWeight: weight, padding: pad, opacity, transform: `translateY(${ty}px)` };
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="np-tag-cloud">
|
||||
{genre && <span className="np-tag np-tag-accent">{genre}</span>}
|
||||
{year && <span className="np-tag">{year}</span>}
|
||||
{similarArtists.slice(0, 6).map(a => (
|
||||
<span
|
||||
key={a.id}
|
||||
className="np-tag np-tag-clickable"
|
||||
onClick={() => onArtistClick(a.id)}
|
||||
data-tooltip={t('nowPlaying.goToArtist')}
|
||||
>
|
||||
{a.name}
|
||||
</span>
|
||||
<div className="np-tag-cloud-header">{t('artistDetail.similarArtists')}</div>
|
||||
{([similarArtists.slice(0, 3), similarArtists.slice(3, 6)] as const).map((row, rowIdx) => (
|
||||
<div key={rowIdx} className="np-tag-cloud-tags" style={rowIdx === 0 ? { marginBottom: '26px' } : undefined}>
|
||||
{row.map((a, i) => (
|
||||
<span
|
||||
key={a.id}
|
||||
className="np-tag np-tag-clickable"
|
||||
style={getTagStyle(a.name, rowIdx * 3 + i)}
|
||||
onClick={() => onArtistClick(a.id)}
|
||||
data-tooltip={t('nowPlaying.goToArtist')}
|
||||
>
|
||||
{a.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -165,6 +184,53 @@ const NpBg = memo(function NpBg({ url }: { url: string }) {
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Album Tracklist ──────────────────────────────────────────────────────────
|
||||
|
||||
interface NpTrackListProps {
|
||||
albumTracks: SubsonicSong[];
|
||||
currentTrackId: string;
|
||||
album: string;
|
||||
albumId?: string;
|
||||
onNavigate: (path: string) => void;
|
||||
}
|
||||
|
||||
const NpTrackList = memo(function NpTrackList({ albumTracks, currentTrackId, album, albumId, onNavigate }: NpTrackListProps) {
|
||||
const { t } = useTranslation();
|
||||
if (albumTracks.length === 0) return null;
|
||||
return (
|
||||
<div className="np-info-card">
|
||||
<div className="np-card-header">
|
||||
<h3 className="np-card-title">{t('nowPlaying.fromAlbum')}: <em style={{ fontStyle: 'normal', color: 'rgba(255,255,255,0.6)' }}>{album}</em></h3>
|
||||
{albumId && (
|
||||
<button className="np-card-link" onClick={() => onNavigate(`/album/${albumId}`)}>
|
||||
{t('nowPlaying.viewAlbum')} <ExternalLink size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="np-album-tracklist">
|
||||
{albumTracks.map(track => {
|
||||
const isActive = track.id === currentTrackId;
|
||||
return (
|
||||
<div key={track.id}
|
||||
className={`np-album-track${isActive ? ' active' : ''}`}
|
||||
onClick={() => albumId && onNavigate(`/album/${albumId}`)}
|
||||
>
|
||||
<span className="np-album-track-num">
|
||||
{isActive
|
||||
? <Star size={10} fill="var(--accent)" color="var(--accent)" />
|
||||
: track.track ?? '—'
|
||||
}
|
||||
</span>
|
||||
<span className="np-album-track-title truncate">{track.title}</span>
|
||||
<span className="np-album-track-dur">{formatTime(track.duration)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function NowPlaying() {
|
||||
@@ -174,6 +240,8 @@ export default function NowPlaying() {
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
|
||||
const stableNavigate = useCallback((path: string) => navigate(path), [navigate]);
|
||||
|
||||
// Extra song metadata
|
||||
const [songMeta, setSongMeta] = useState<SubsonicSong | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -213,6 +281,42 @@ export default function NowPlaying() {
|
||||
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
|
||||
const resolvedCover = useCachedUrl(coverFetchUrl, coverKey);
|
||||
|
||||
// Ambilight — sample 8 zones (4 corners + 4 edge midpoints)
|
||||
const [ambilightColors, setAmbilightColors] = useState({
|
||||
tl: '0,0,0', tc: '0,0,0', tr: '0,0,0',
|
||||
ml: '0,0,0', mr: '0,0,0',
|
||||
bl: '0,0,0', bc: '0,0,0', br: '0,0,0',
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!resolvedCover) return;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const S = 30;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = S; canvas.height = S;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.drawImage(img, 0, 0, S, S);
|
||||
const data = ctx.getImageData(0, 0, S, S).data;
|
||||
const t = Math.floor(S * 0.25), m = Math.floor(S * 0.5), b2 = Math.floor(S * 0.75);
|
||||
const avg = (x0: number, y0: number, x1: number, y1: number) => {
|
||||
let r = 0, g = 0, b = 0, n = 0;
|
||||
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) {
|
||||
const i = (y * S + x) * 4;
|
||||
r += data[i]; g += data[i+1]; b += data[i+2]; n++;
|
||||
}
|
||||
return `${Math.round(r/n)},${Math.round(g/n)},${Math.round(b/n)}`;
|
||||
};
|
||||
setAmbilightColors({
|
||||
tl: avg(0, 0, t, t), tc: avg(t, 0, b2, t), tr: avg(b2, 0, S, t),
|
||||
ml: avg(0, t, t, b2), mr: avg(b2, t, S, b2),
|
||||
bl: avg(0, b2, t, S), bc: avg(t, b2, b2, S), br: avg(b2, b2, S, S),
|
||||
});
|
||||
};
|
||||
img.src = resolvedCover;
|
||||
}, [resolvedCover]);
|
||||
|
||||
|
||||
const similarArtists = artistInfo?.similarArtist ?? [];
|
||||
|
||||
return (
|
||||
@@ -225,17 +329,10 @@ export default function NowPlaying() {
|
||||
{/* ── Hero Card ── */}
|
||||
<div className="np-hero-card">
|
||||
|
||||
{/* Left: cover + meta info */}
|
||||
{/* Left: meta info */}
|
||||
<div className="np-hero-left">
|
||||
<div className="np-hero-cover-wrap">
|
||||
{resolvedCover && <img src={resolvedCover} alt="" className="np-cover-glow" aria-hidden />}
|
||||
{resolvedCover
|
||||
? <img src={resolvedCover} alt="" className="np-cover" />
|
||||
: <div className="np-cover np-cover-fallback"><Music size={52} /></div>
|
||||
}
|
||||
</div>
|
||||
<div className="np-hero-info">
|
||||
<div className="np-title">{currentTrack.title}</div>
|
||||
<div className="np-title" style={{ color: 'var(--accent)' }}>{currentTrack.title}</div>
|
||||
<div className="np-artist-album">
|
||||
<span className="np-link"
|
||||
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||
@@ -263,13 +360,30 @@ export default function NowPlaying() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center: EQ bars */}
|
||||
<EQBars isPlaying={isPlaying} />
|
||||
{/* Center: cover */}
|
||||
<div className="np-hero-cover-wrap">
|
||||
<div style={{
|
||||
position: 'absolute', inset: '-20px', zIndex: 0,
|
||||
background: `
|
||||
radial-gradient(circle at 0% 0%, rgba(${ambilightColors.tl},0.85) 0%, transparent 55%),
|
||||
radial-gradient(circle at 50% 0%, rgba(${ambilightColors.tc},0.85) 0%, transparent 55%),
|
||||
radial-gradient(circle at 100% 0%, rgba(${ambilightColors.tr},0.85) 0%, transparent 55%),
|
||||
radial-gradient(circle at 0% 50%, rgba(${ambilightColors.ml},0.85) 0%, transparent 55%),
|
||||
radial-gradient(circle at 100% 50%, rgba(${ambilightColors.mr},0.85) 0%, transparent 55%),
|
||||
radial-gradient(circle at 0% 100%, rgba(${ambilightColors.bl},0.85) 0%, transparent 55%),
|
||||
radial-gradient(circle at 50% 100%, rgba(${ambilightColors.bc},0.85) 0%, transparent 55%),
|
||||
radial-gradient(circle at 100% 100%, rgba(${ambilightColors.br},0.85) 0%, transparent 55%)
|
||||
`,
|
||||
filter: 'blur(28px)',
|
||||
}} />
|
||||
{resolvedCover
|
||||
? <img src={resolvedCover} alt="" className="np-cover" style={{ position: 'relative', zIndex: 1 }} />
|
||||
: <div className="np-cover np-cover-fallback" style={{ position: 'relative', zIndex: 1 }}><Music size={52} /></div>
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Right: tag cloud */}
|
||||
<TagCloud
|
||||
genre={songMeta?.genre}
|
||||
year={currentTrack.year}
|
||||
similarArtists={similarArtists}
|
||||
onArtistClick={id => navigate(`/artist/${id}`)}
|
||||
/>
|
||||
@@ -306,39 +420,13 @@ export default function NowPlaying() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── From this Album ── */}
|
||||
{albumTracks.length > 0 && (
|
||||
<div className="np-info-card">
|
||||
<div className="np-card-header">
|
||||
<h3 className="np-card-title">{t('nowPlaying.fromAlbum')}: <em style={{ fontStyle: 'normal', color: 'rgba(255,255,255,0.6)' }}>{currentTrack.album}</em></h3>
|
||||
{currentTrack.albumId && (
|
||||
<button className="np-card-link" onClick={() => navigate(`/album/${currentTrack.albumId}`)}>
|
||||
{t('nowPlaying.viewAlbum')} <ExternalLink size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="np-album-tracklist">
|
||||
{albumTracks.map(track => {
|
||||
const isActive = track.id === currentTrack.id;
|
||||
return (
|
||||
<div key={track.id}
|
||||
className={`np-album-track${isActive ? ' active' : ''}`}
|
||||
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
|
||||
>
|
||||
<span className="np-album-track-num">
|
||||
{isActive
|
||||
? <Star size={10} fill="var(--accent)" color="var(--accent)" />
|
||||
: track.track ?? '—'
|
||||
}
|
||||
</span>
|
||||
<span className="np-album-track-title truncate">{track.title}</span>
|
||||
<span className="np-album-track-dur">{formatTime(track.duration)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<NpTrackList
|
||||
albumTracks={albumTracks}
|
||||
currentTrackId={currentTrack.id}
|
||||
album={currentTrack.album}
|
||||
albumId={currentTrack.albumId}
|
||||
onNavigate={stableNavigate}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="np-empty-state">
|
||||
|
||||
@@ -351,7 +351,7 @@ export default function RandomMix() {
|
||||
<button className="btn btn-ghost" style={{ padding: 4 }} onClick={e => { e.stopPropagation(); playTrack(song, genreMixSongs); }}>
|
||||
<Play size={14} fill="currentColor" />
|
||||
</button>
|
||||
<div className="track-info"><span className="track-title" data-tooltip={song.title}>{song.title}</span></div>
|
||||
<div className="track-info"><span className="track-title">{song.title}</span></div>
|
||||
<div className="track-artist-cell"><span className="track-artist">{song.artist}</span></div>
|
||||
<div className="track-info"><span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--subtext0)' }}>{song.album}</span></div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{song.genre ?? '—'}</div>
|
||||
@@ -415,15 +415,15 @@ export default function RandomMix() {
|
||||
</button>
|
||||
|
||||
<div className="track-info">
|
||||
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
|
||||
<span className="track-title">{song.title}</span>
|
||||
</div>
|
||||
|
||||
<div className="track-artist-cell">
|
||||
<span className="track-artist" data-tooltip={song.artist}>{song.artist}</span>
|
||||
<span className="track-artist">{song.artist}</span>
|
||||
</div>
|
||||
|
||||
<div className="track-info">
|
||||
<span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--subtext0)' }} data-tooltip={song.album}>{song.album}</span>
|
||||
<span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--subtext0)' }}>{song.album}</span>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
|
||||
+13
-16
@@ -212,11 +212,11 @@ export default function Settings() {
|
||||
};
|
||||
|
||||
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: 'server', label: t('settings.tabServer'), icon: <Server size={15} /> },
|
||||
{ id: 'appearance', label: t('settings.tabAppearance'), icon: <Palette size={15} /> },
|
||||
{ id: 'playback', label: t('settings.tabPlayback'), icon: <Play size={15} /> },
|
||||
{ id: 'library', label: t('settings.tabLibrary'), icon: <Shuffle size={15} /> },
|
||||
{ id: 'appearance', label: t('settings.tabAppearance'), icon: <Palette size={15} /> },
|
||||
{ id: 'shortcuts', label: t('settings.tabShortcuts'), icon: <Keyboard size={15} /> },
|
||||
{ id: 'server', label: t('settings.tabServer'), icon: <Server size={15} /> },
|
||||
{ id: 'about', label: t('settings.tabAbout'), icon: <Info size={15} /> },
|
||||
];
|
||||
|
||||
@@ -331,11 +331,8 @@ export default function Settings() {
|
||||
{/* Gapless */}
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{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>
|
||||
@@ -468,16 +465,6 @@ export default function Settings() {
|
||||
{/* ── Appearance ───────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'appearance' && (
|
||||
<>
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Palette size={18} />
|
||||
<h2>{t('settings.theme')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<ThemePicker value={theme.theme} onChange={v => theme.setTheme(v as any)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Globe size={18} />
|
||||
@@ -499,6 +486,16 @@ export default function Settings() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Palette size={18} />
|
||||
<h2>{t('settings.theme')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<ThemePicker value={theme.theme} onChange={v => theme.setTheme(v as any)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Type size={18} />
|
||||
|
||||
@@ -224,7 +224,7 @@ export default function Statistics() {
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.875rem', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{track.name}</span>
|
||||
{track.nowPlaying && (
|
||||
<span style={{ fontSize: '0.65rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--accent)', flexShrink: 0 }}>{t('statistics.lfmNowPlaying')}</span>
|
||||
<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', flexShrink: 0 }}>{t('statistics.lfmNowPlaying')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
|
||||
@@ -11,7 +11,7 @@ interface FontState {
|
||||
export const useFontStore = create<FontState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
font: 'inter',
|
||||
font: 'lexend',
|
||||
setFont: (font) => set({ font }),
|
||||
}),
|
||||
{ name: 'psysonic_font' }
|
||||
|
||||
@@ -2,7 +2,7 @@ import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, SubsonicSong } from '../api/subsonic';
|
||||
import { buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong } from '../api/subsonic';
|
||||
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
|
||||
import { useAuthStore } from './authStore';
|
||||
|
||||
@@ -155,9 +155,10 @@ function handleAudioProgress(current_time: number, duration: number) {
|
||||
const progress = current_time / dur;
|
||||
usePlayerStore.setState({ currentTime: current_time, progress, buffered: 0 });
|
||||
|
||||
// Scrobble at 50% directly via Last.fm
|
||||
// Scrobble at 50%: Last.fm + Navidrome (updates play_date / recently played)
|
||||
if (progress >= 0.5 && !store.scrobbled) {
|
||||
usePlayerStore.setState({ scrobbled: true });
|
||||
scrobbleSong(track.id, Date.now());
|
||||
const { scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
|
||||
if (scrobblingEnabled && lastfmSessionKey) {
|
||||
lastfmScrobble(track, Date.now(), lastfmSessionKey);
|
||||
|
||||
@@ -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' | 'wnamp' | 'poison' | 'nucleo' | 'navy-jukebox' | 'cobalt-media' | 'onyx-cinema' | 'vintage-tube-radio' | 'neon-drift' | 'aero-glass' | 'luna-teal' | 'cupertino-light' | 'cupertino-dark' | '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' | 'spotless' | 'dzr0' | 'cupertino-beats';
|
||||
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | 'wnamp' | 'poison' | 'nucleo' | 'navy-jukebox' | 'cobalt-media' | 'onyx-cinema' | 'vintage-tube-radio' | 'neon-drift' | 'aero-glass' | 'luna-teal' | 'cupertino-light' | 'cupertino-dark' | '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' | 'spotless' | 'dzr0' | 'cupertino-beats' | 'middle-earth' | 'morpheus' | 'pandora' | 'stark-hud' | 'blade';
|
||||
|
||||
interface ThemeState {
|
||||
theme: Theme;
|
||||
|
||||
+55
-79
@@ -1796,6 +1796,11 @@
|
||||
}
|
||||
|
||||
/* ── Cover breathing ── */
|
||||
@keyframes ambilight-pulse {
|
||||
0%, 100% { opacity: 0.7; }
|
||||
50% { opacity: 1.0; }
|
||||
}
|
||||
|
||||
@keyframes cover-breathe {
|
||||
|
||||
0%,
|
||||
@@ -2645,26 +2650,24 @@
|
||||
|
||||
/* ── Hero card: 3-column (cover+info | EQ | tag cloud) ── */
|
||||
.np-hero-card {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 20px;
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
padding: 32px;
|
||||
background: rgba(0, 0, 0, 0.30);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
flex-shrink: 0;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* Left column: cover + meta, left-aligned — takes equal share with right */
|
||||
/* Left column: meta info, big text */
|
||||
.np-hero-left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.np-hero-cover-wrap {
|
||||
@@ -2680,58 +2683,44 @@
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding-top: 4px;
|
||||
gap: 14px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Center column: fixed width so it stays truly centered between equal outer columns */
|
||||
.np-eq-wrap {
|
||||
flex: 0 0 clamp(100px, 20vw, 220px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 4px;
|
||||
mask-image: linear-gradient(to right, transparent 0%, black 20%, black 80%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(to right, transparent 0%, black 20%, black 80%, transparent 100%);
|
||||
}
|
||||
|
||||
.np-eq-bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 3px;
|
||||
height: 80px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.np-eq-bar {
|
||||
flex: 1;
|
||||
min-width: 3px;
|
||||
max-width: 8px;
|
||||
background: var(--accent);
|
||||
border-radius: 2px 2px 0 0;
|
||||
opacity: 0.85;
|
||||
height: 8%;
|
||||
transition: background 0.3s;
|
||||
will-change: height;
|
||||
}
|
||||
|
||||
/* Right column: tag cloud — takes equal share with left so EQ stays centered */
|
||||
/* Right column: tag cloud */
|
||||
.np-tag-cloud {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.np-tag-cloud-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
align-content: flex-start;
|
||||
padding-top: 4px;
|
||||
gap: 8px 10px;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.np-tag-cloud-header {
|
||||
width: 100%;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
margin-bottom: 10px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.np-tag {
|
||||
font-size: 11px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.10);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
@@ -3030,9 +3019,18 @@
|
||||
inset: -15%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
filter: blur(40px) brightness(0.55) saturate(1.6);
|
||||
filter: blur(70px) brightness(0.55) saturate(1.6);
|
||||
transition: opacity 0.6s ease;
|
||||
will-change: opacity;
|
||||
animation: np-ken-burns 40s ease-in-out infinite alternate;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@keyframes np-ken-burns {
|
||||
0% { transform: scale(1.00) translate(0%, 0%); }
|
||||
25% { transform: scale(1.04) translate(-2%, -1.5%); }
|
||||
50% { transform: scale(1.06) translate(1.5%, 2%); }
|
||||
75% { transform: scale(1.03) translate(-1%, 1.5%); }
|
||||
100% { transform: scale(1.05) translate(2%, -1%); }
|
||||
}
|
||||
|
||||
.np-bg-overlay {
|
||||
@@ -3063,35 +3061,13 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.np-cover-glow {
|
||||
position: absolute;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
filter: blur(36px) saturate(1.5) brightness(0.85);
|
||||
opacity: 0.7;
|
||||
transform: translateY(12px) scale(1.05);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
will-change: transform, opacity;
|
||||
animation: np-glow-pulse 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes np-glow-pulse {
|
||||
0%, 100% { opacity: 0.6; transform: translateY(14px) scale(1.04); }
|
||||
50% { opacity: 0.85; transform: translateY(9px) scale(1.10); }
|
||||
}
|
||||
|
||||
.np-cover {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 196px;
|
||||
height: 196px;
|
||||
border-radius: 14px;
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
border-radius: 16px;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.6);
|
||||
animation: cover-breathe 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.np-cover-fallback {
|
||||
@@ -3114,14 +3090,14 @@
|
||||
|
||||
.np-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 26px;
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
line-height: 1.15;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.np-artist-album {
|
||||
font-size: 15px;
|
||||
font-size: 19px;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -3366,3 +3366,461 @@ input[type="range"]:hover::-webkit-slider-thumb {
|
||||
.eq-bar:nth-child(2) { animation: wave 0.8s ease-in-out 0.15s infinite; }
|
||||
.eq-bar:nth-child(3) { animation: wave 0.8s ease-in-out 0.3s infinite; }
|
||||
.eq-bar:nth-child(4) { animation: wave 0.8s ease-in-out 0.45s infinite; }
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
Middle Earth — Epic Fantasy / Cinematic
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
[data-theme='middle-earth'] {
|
||||
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%234a3728%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: #2a1d15;
|
||||
--ctp-mantle: #3b2a1e;
|
||||
--ctp-base: #f4e4bc;
|
||||
--ctp-surface0: #ebd5a3;
|
||||
--ctp-surface1: #dec48d;
|
||||
--ctp-surface2: #cbb07c;
|
||||
--ctp-overlay0: #8c7356;
|
||||
--ctp-overlay1: #6e5a42;
|
||||
--ctp-overlay2: #4a3728;
|
||||
--ctp-text: #2a1d15;
|
||||
--ctp-subtext1: #4a3728;
|
||||
--ctp-subtext0: #6e5a42;
|
||||
--ctp-mauve: #d4af37;
|
||||
--ctp-lavender: #e5c158;
|
||||
--ctp-green: #4a7040;
|
||||
--ctp-teal: #3e5a5a;
|
||||
--ctp-sky: #78a2c0;
|
||||
--ctp-blue: #4a6b8a;
|
||||
--ctp-sapphire: #2a4b6a;
|
||||
--ctp-pink: #b05a7e;
|
||||
--ctp-flamingo: #c07a7a;
|
||||
--ctp-rosewater:#e5caca;
|
||||
--ctp-yellow: #d4af37;
|
||||
--ctp-peach: #c08a4a;
|
||||
--ctp-maroon: #6a2a2a;
|
||||
--ctp-red: #8a2a2a;
|
||||
|
||||
--bg-app: #f4e4bc;
|
||||
--bg-sidebar: #3b2a1e;
|
||||
--bg-card: #ebd5a3;
|
||||
--bg-hover: rgba(74, 55, 40, 0.1);
|
||||
--bg-player: #2a1d15;
|
||||
--bg-glass: rgba(244, 228, 188, 0.85);
|
||||
|
||||
--accent: #d4af37;
|
||||
--accent-dim: rgba(212, 175, 55, 0.2);
|
||||
--accent-glow: rgba(212, 175, 55, 0.4);
|
||||
|
||||
--text-primary: #2a1d15;
|
||||
--text-secondary:#4a3728;
|
||||
--text-muted: #8c7356;
|
||||
|
||||
--border: #cbb07c;
|
||||
--border-subtle: #dec48d;
|
||||
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
}
|
||||
|
||||
[data-theme='middle-earth'] .player-track-name {
|
||||
color: #f4e4bc;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
[data-theme='middle-earth'] .player-track-artist { color: #d4af37; }
|
||||
[data-theme='middle-earth'] .player-time { color: #dec48d; }
|
||||
|
||||
[data-theme='middle-earth'] .sidebar {
|
||||
border-right: 1px solid rgba(212, 175, 55, 0.3);
|
||||
}
|
||||
[data-theme='middle-earth'] .nav-link { color: #dec48d; }
|
||||
[data-theme='middle-earth'] .nav-link:hover { background: rgba(212, 175, 55, 0.1); color: #f4e4bc; }
|
||||
[data-theme='middle-earth'] .nav-link.active { color: #d4af37; background: rgba(212, 175, 55, 0.15); }
|
||||
[data-theme='middle-earth'] .nav-link.active::before { background-color: #d4af37; box-shadow: 0 0 10px rgba(212, 175, 55, 0.6); }
|
||||
|
||||
[data-theme='middle-earth'] .btn-primary {
|
||||
background: #d4af37;
|
||||
color: #2a1d15;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 0 15px rgba(212, 175, 55, 0.3);
|
||||
}
|
||||
[data-theme='middle-earth'] .player-btn { color: #dec48d; }
|
||||
[data-theme='middle-earth'] .player-btn:hover { color: #d4af37; }
|
||||
|
||||
/* Queue läuft auf dunklem Player-Hintergrund — helle Textfarben nötig */
|
||||
[data-theme='middle-earth'] .queue-current-info h3 { color: #f4e4bc; }
|
||||
[data-theme='middle-earth'] .queue-current-sub { color: #d4af37; }
|
||||
[data-theme='middle-earth'] .queue-item-title { color: #f4e4bc; }
|
||||
[data-theme='middle-earth'] .queue-item-artist { color: #cbb07c; }
|
||||
|
||||
/* Dezentes Pergament-Noise über der App */
|
||||
[data-theme='middle-earth'] .app-shell::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
opacity: 0.03;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
background-size: 200px 200px;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
Morpheus — Matrix Style
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
[data-theme='morpheus'] {
|
||||
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%2300ff41%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: #000000;
|
||||
--ctp-mantle: #050505;
|
||||
--ctp-base: #0a0a0a;
|
||||
--ctp-surface0: #0d1a0d;
|
||||
--ctp-surface1: #1a2e1a;
|
||||
--ctp-surface2: #243d24;
|
||||
--ctp-overlay0: #003b00;
|
||||
--ctp-overlay1: #008f11;
|
||||
--ctp-overlay2: #00ff41;
|
||||
--ctp-text: #00ff41;
|
||||
--ctp-subtext1: #00cc33;
|
||||
--ctp-subtext0: #008f11;
|
||||
--ctp-mauve: #00ff41;
|
||||
--ctp-lavender: #adff2f;
|
||||
--ctp-green: #00ff41;
|
||||
--ctp-teal: #20c20e;
|
||||
--ctp-sky: #00ff41;
|
||||
--ctp-blue: #00ff41;
|
||||
--ctp-sapphire: #008f11;
|
||||
--ctp-pink: #003b00;
|
||||
--ctp-flamingo: #003b00;
|
||||
--ctp-rosewater:#d2ff00;
|
||||
--ctp-yellow: #adff2f;
|
||||
--ctp-peach: #00ff41;
|
||||
--ctp-maroon: #003b00;
|
||||
--ctp-red: #ff0000;
|
||||
|
||||
--bg-app: #0a0a0a;
|
||||
--bg-sidebar: #000000;
|
||||
--bg-card: #0d1a0d;
|
||||
--bg-hover: rgba(0, 255, 65, 0.1);
|
||||
--bg-player: #000000;
|
||||
--bg-glass: rgba(0, 0, 0, 0.85);
|
||||
|
||||
--accent: #00ff41;
|
||||
--accent-dim: rgba(0, 255, 65, 0.15);
|
||||
--accent-glow: rgba(0, 255, 65, 0.4);
|
||||
|
||||
--text-primary: #00ff41;
|
||||
--text-secondary:#00cc33;
|
||||
--text-muted: #1a7a2a;
|
||||
|
||||
--border: #003b00;
|
||||
--border-subtle: #001a00;
|
||||
|
||||
--positive: #00ff41;
|
||||
--warning: #adff2f;
|
||||
--danger: #ff0033;
|
||||
|
||||
--radius-sm: 0px;
|
||||
--radius-md: 2px;
|
||||
--radius-lg: 4px;
|
||||
}
|
||||
|
||||
[data-theme='morpheus'] .player-track-name {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
text-shadow: 0 0 8px rgba(0, 255, 65, 0.6);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
[data-theme='morpheus'] .player-track-artist,
|
||||
[data-theme='morpheus'] .player-time {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
opacity: 0.8;
|
||||
}
|
||||
[data-theme='morpheus'] .nav-link.active {
|
||||
background: linear-gradient(90deg, rgba(0, 255, 65, 0.1) 0%, transparent 100%);
|
||||
text-shadow: 0 0 5px rgba(0, 255, 65, 0.4);
|
||||
}
|
||||
[data-theme='morpheus'] .nav-link.active::before {
|
||||
box-shadow: 0 0 8px rgba(0, 255, 65, 0.6);
|
||||
}
|
||||
[data-theme='morpheus'] .player-bar {
|
||||
box-shadow: 0 -5px 15px rgba(0, 255, 65, 0.05);
|
||||
}
|
||||
[data-theme='morpheus'] .sidebar {
|
||||
border-right: 1px solid #003b00;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
Pandora — Bioluminescent Sci-Fi
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
[data-theme='pandora'] {
|
||||
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%2300f2ff%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: #040a0e;
|
||||
--ctp-mantle: #08141a;
|
||||
--ctp-base: #0c1b22;
|
||||
--ctp-surface0: #142b35;
|
||||
--ctp-surface1: #1a3844;
|
||||
--ctp-surface2: #244b5a;
|
||||
--ctp-overlay0: #4a6d7c;
|
||||
--ctp-overlay1: #6a8fa0;
|
||||
--ctp-overlay2: #8ab1c4;
|
||||
--ctp-text: #e0f7fa;
|
||||
--ctp-subtext1: #b2ebf2;
|
||||
--ctp-subtext0: #80deea;
|
||||
--ctp-mauve: #00f2ff;
|
||||
--ctp-lavender: #80f9ff;
|
||||
--ctp-green: #4ade80;
|
||||
--ctp-teal: #2dd4bf;
|
||||
--ctp-sky: #38bdf8;
|
||||
--ctp-blue: #60a5fa;
|
||||
--ctp-sapphire: #2563eb;
|
||||
--ctp-pink: #f472b6;
|
||||
--ctp-flamingo: #fb7185;
|
||||
--ctp-rosewater:#ffe4e6;
|
||||
--ctp-yellow: #fbbf24;
|
||||
--ctp-peach: #fb923c;
|
||||
--ctp-maroon: #e11d48;
|
||||
--ctp-red: #f87171;
|
||||
|
||||
--bg-app: #0c1b22;
|
||||
--bg-sidebar: #08141a;
|
||||
--bg-card: rgba(20, 43, 53, 0.6);
|
||||
--bg-hover: rgba(26, 56, 68, 0.4);
|
||||
--bg-player: #040a0e;
|
||||
--bg-glass: rgba(12, 27, 34, 0.7);
|
||||
|
||||
--accent: #00f2ff;
|
||||
--accent-dim: rgba(0, 242, 255, 0.15);
|
||||
--accent-glow: rgba(0, 242, 255, 0.4);
|
||||
|
||||
--text-primary: #e0f7fa;
|
||||
--text-secondary:#b2ebf2;
|
||||
--text-muted: #6a8fa0;
|
||||
|
||||
--border: #1a3844;
|
||||
--border-subtle: #0f242d;
|
||||
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 20px;
|
||||
}
|
||||
|
||||
[data-theme='pandora'] .sidebar {
|
||||
backdrop-filter: blur(12px);
|
||||
border-right: 1px solid rgba(0, 242, 255, 0.1);
|
||||
}
|
||||
[data-theme='pandora'] .player-bar {
|
||||
border-top: 1px solid rgba(0, 242, 255, 0.2);
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
[data-theme='pandora'] .player-track-name {
|
||||
color: #00f2ff;
|
||||
text-shadow: 0 0 8px rgba(0, 242, 255, 0.6);
|
||||
font-weight: 600;
|
||||
}
|
||||
[data-theme='pandora'] .btn-primary {
|
||||
background: linear-gradient(135deg, #00f2ff, #0088cc);
|
||||
box-shadow: 0 0 15px rgba(0, 242, 255, 0.3);
|
||||
color: #040a0e;
|
||||
}
|
||||
[data-theme='pandora'] .nav-link.active {
|
||||
background: linear-gradient(90deg, rgba(0, 242, 255, 0.15), transparent);
|
||||
color: #00f2ff;
|
||||
text-shadow: 0 0 5px rgba(0, 242, 255, 0.4);
|
||||
}
|
||||
[data-theme='pandora'] .card:hover {
|
||||
border: 1px solid rgba(0, 242, 255, 0.3);
|
||||
box-shadow: 0 0 20px rgba(0, 242, 255, 0.1);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
Stark HUD — Iron Man / Arc Reactor
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
[data-theme='stark-hud'] {
|
||||
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%2300f2ff%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: #05070a;
|
||||
--ctp-mantle: #080c12;
|
||||
--ctp-base: #0b0f15;
|
||||
--ctp-surface0: #141b24;
|
||||
--ctp-surface1: #1c2531;
|
||||
--ctp-surface2: #253141;
|
||||
--ctp-overlay0: #4a5d75;
|
||||
--ctp-overlay1: #6a8099;
|
||||
--ctp-overlay2: #8ba2bd;
|
||||
--ctp-text: #e0f7fa;
|
||||
--ctp-subtext1: #a3ced4;
|
||||
--ctp-subtext0: #7da5aa;
|
||||
--ctp-mauve: #00f2ff;
|
||||
--ctp-lavender: #7df9ff;
|
||||
--ctp-green: #00ff9d;
|
||||
--ctp-teal: #00d4cc;
|
||||
--ctp-sky: #00f2ff;
|
||||
--ctp-blue: #00f2ff;
|
||||
--ctp-sapphire: #0088cc;
|
||||
--ctp-pink: #ff3399;
|
||||
--ctp-flamingo: #ff6680;
|
||||
--ctp-rosewater:#ffe0e6;
|
||||
--ctp-yellow: #ffcc00;
|
||||
--ctp-peach: #ff8800;
|
||||
--ctp-maroon: #cc0022;
|
||||
--ctp-red: #ff3333;
|
||||
|
||||
--bg-app: #0b0f15;
|
||||
--bg-sidebar: #080c12;
|
||||
--bg-card: #141b24;
|
||||
--bg-hover: rgba(0, 242, 255, 0.08);
|
||||
--bg-player: #05070a;
|
||||
--bg-glass: rgba(11, 15, 21, 0.85);
|
||||
|
||||
--accent: #00f2ff;
|
||||
--accent-dim: rgba(0, 242, 255, 0.12);
|
||||
--accent-glow: rgba(0, 242, 255, 0.4);
|
||||
|
||||
--text-primary: #e0f7fa;
|
||||
--text-secondary:#a3ced4;
|
||||
--text-muted: #5a718a;
|
||||
|
||||
--border: #1e2a3a;
|
||||
--border-subtle: #141b24;
|
||||
|
||||
--radius-sm: 2px;
|
||||
--radius-md: 4px;
|
||||
--radius-lg: 6px;
|
||||
}
|
||||
|
||||
[data-theme='stark-hud'] .app-shell {
|
||||
background: radial-gradient(circle at 50% 50%, #0e1621 0%, #05070a 100%);
|
||||
}
|
||||
[data-theme='stark-hud'] .sidebar {
|
||||
border-right: 1px solid rgba(0, 242, 255, 0.15);
|
||||
}
|
||||
[data-theme='stark-hud'] .player-bar {
|
||||
border-top: 1px solid rgba(0, 242, 255, 0.2);
|
||||
box-shadow: 0 -10px 30px rgba(0, 242, 255, 0.05);
|
||||
}
|
||||
[data-theme='stark-hud'] .player-track-name {
|
||||
color: #00f2ff;
|
||||
text-shadow: 0 0 10px rgba(0, 242, 255, 0.6);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
[data-theme='stark-hud'] .nav-link.active {
|
||||
background: linear-gradient(90deg, rgba(0, 242, 255, 0.1) 0%, transparent 100%);
|
||||
box-shadow: -5px 0 15px rgba(0, 242, 255, 0.2);
|
||||
}
|
||||
[data-theme='stark-hud'] .nav-link.active::before {
|
||||
box-shadow: 0 0 10px rgba(0, 242, 255, 0.5);
|
||||
}
|
||||
[data-theme='stark-hud'] .btn-primary {
|
||||
background: #00f2ff;
|
||||
color: #05070a;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 0 15px rgba(0, 242, 255, 0.3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
Blade — Cinematic Industrial
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
[data-theme='blade'] {
|
||||
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%23a0a0a0%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: #050505;
|
||||
--ctp-mantle: #0a0a0a;
|
||||
--ctp-base: #121212;
|
||||
--ctp-surface0: #1a1a1a;
|
||||
--ctp-surface1: #252525;
|
||||
--ctp-surface2: #333333;
|
||||
--ctp-overlay0: #4a4a4a;
|
||||
--ctp-overlay1: #707070;
|
||||
--ctp-overlay2: #909090;
|
||||
--ctp-text: #e0e0e0;
|
||||
--ctp-subtext1: #b0b0b0;
|
||||
--ctp-subtext0: #888888;
|
||||
--ctp-mauve: #b30000;
|
||||
--ctp-lavender: #ff1a1a;
|
||||
--ctp-green: #00ff66;
|
||||
--ctp-teal: #1abc9c;
|
||||
--ctp-sky: #3498db;
|
||||
--ctp-blue: #2980b9;
|
||||
--ctp-sapphire: #1f618d;
|
||||
--ctp-pink: #e91e63;
|
||||
--ctp-flamingo: #f06292;
|
||||
--ctp-rosewater:#f8bbd0;
|
||||
--ctp-yellow: #f1c40f;
|
||||
--ctp-peach: #e67e22;
|
||||
--ctp-maroon: #800000;
|
||||
--ctp-red: #ff0000;
|
||||
|
||||
--bg-app: #121212;
|
||||
--bg-sidebar: #0a0a0a;
|
||||
--bg-card: #1a1a1a;
|
||||
--bg-hover: rgba(179, 0, 0, 0.1);
|
||||
--bg-player: #050505;
|
||||
--bg-glass: rgba(10, 10, 10, 0.85);
|
||||
|
||||
--accent: #b30000;
|
||||
--accent-dim: rgba(179, 0, 0, 0.15);
|
||||
--accent-glow: rgba(179, 0, 0, 0.4);
|
||||
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary:#b0b0b0;
|
||||
--text-muted: #666666;
|
||||
|
||||
--border: #2a2a2a;
|
||||
--border-subtle: #1a1a1a;
|
||||
|
||||
--positive: #00ff66;
|
||||
--warning: #f1c40f;
|
||||
--danger: #ff0000;
|
||||
|
||||
--radius-sm: 2px;
|
||||
--radius-md: 4px;
|
||||
--radius-lg: 6px;
|
||||
}
|
||||
|
||||
[data-theme='blade'] .player-bar {
|
||||
border-top: 1px solid #222;
|
||||
background: linear-gradient(to bottom, #0a0a0a 0%, #050505 100%);
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
[data-theme='blade'] .sidebar {
|
||||
border-right: 1px solid #1a1a1a;
|
||||
}
|
||||
[data-theme='blade'] .nav-link.active {
|
||||
background: linear-gradient(90deg, rgba(179, 0, 0, 0.2) 0%, transparent 100%);
|
||||
color: #fff;
|
||||
}
|
||||
[data-theme='blade'] .nav-link.active::before {
|
||||
box-shadow: 0 0 8px rgba(179, 0, 0, 0.5);
|
||||
}
|
||||
[data-theme='blade'] .player-track-name {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
text-shadow: 0 0 10px rgba(179, 0, 0, 0.3);
|
||||
}
|
||||
[data-theme='blade'] .btn-primary {
|
||||
background: linear-gradient(135deg, #b30000 0%, #800000 100%);
|
||||
border: 1px solid rgba(255, 26, 26, 0.2);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user