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:
Psychotoxical
2026-03-22 02:37:49 +01:00
parent e550340565
commit 867c5fbd3e
27 changed files with 753 additions and 190 deletions
+23
View File
@@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.11.0] - 2026-03-22
### Added
#### Five New Themes — Movies
- **Middle Earth**: Warm parchment light theme. Cream/beige background, dark ebony player and sidebar, gold accent (`#d4af37`). Georgia serif for track names, subtle noise texture.
- **Morpheus**: Pure black terminal aesthetic inspired by The Matrix. Phosphor green accent (`#00ff41`), monospace font.
- **Pandora**: Deep bioluminescent navy inspired by Avatar. Cyan accent (`#00f2ff`), large radii, glow effects.
- **Stark HUD**: Near-black tactical HUD inspired by Iron Man. Cyan accent, JetBrains Mono, uppercase track name.
- **Blade**: Deep black with blood-red accent (`#b30000`). Sharp radii, uppercase track name.
- All five themes in a new **Movies** group in the theme picker.
### Changed
- **Settings — tab order**: Reordered to Server → Appearance → Playback → Library → Shortcuts → About.
- **Settings — Appearance**: Language selector moved to the top of the tab, above Theme and Font.
- **Settings — Theme picker**: "Betriebssysteme" group renamed to "Operating Systems".
- **Default font**: Changed from Inter to **Lexend** for new installations.
- **Gapless playback**: Experimental badge removed — gapless is now considered stable.
- **Now Playing — background**: Ken Burns animation (40 s, subtle scale + translate). Background blur increased to eliminate JPEG block artefacts at high blur values.
- **Now Playing — Similar Artists**: Tag cloud redesigned into 2 rows with varied font sizes and vertical offsets for a natural look.
- **Statistics**: "Now Playing" indicator rendered as a styled badge matching the app's badge style.
## [1.10.0] - 2026-03-22 ## [1.10.0] - 2026-03-22
### Added ### Added
+1 -1
View File
@@ -227,4 +227,4 @@ The workflow is split into three jobs: `create-release` (creates the GitHub Rele
- **CoverLightbox**: Shared component (`src/components/CoverLightbox.tsx`). Props: `{ src, alt, onClose }`. ESC + overlay click to close. Used in `AlbumHeader` (album cover) and `ArtistDetail` (artist avatar, wrapped in `.artist-detail-avatar-btn`). - **CoverLightbox**: Shared component (`src/components/CoverLightbox.tsx`). Props: `{ src, alt, onClose }`. ESC + overlay click to close. Used in `AlbumHeader` (album cover) and `ArtistDetail` (artist avatar, wrapped in `.artist-detail-avatar-btn`).
- **Home page**: Section order: recent → discover → artist discovery (pill-buttons, no images) → starred → mostPlayed. Artist discovery uses `getArtists()` full list + client-side Fisher-Yates shuffle (16 random), rendered as `artist-ext-link` pill-buttons (same as ArtistDetail "Similar Artists") — no image loading, no performance impact. - **Home page**: Section order: recent → discover → artist discovery (pill-buttons, no images) → starred → mostPlayed. Artist discovery uses `getArtists()` full list + client-side Fisher-Yates shuffle (16 random), rendered as `artist-ext-link` pill-buttons (same as ArtistDetail "Similar Artists") — no image loading, no performance impact.
- **CoverLightbox + EQ popup**: Both use `createPortal(…, document.body)` to escape `backdrop-filter` CSS containing-block issues on the player bar and other ancestors. - **CoverLightbox + EQ popup**: Both use `createPortal(…, document.body)` to escape `backdrop-filter` CSS containing-block issues on the player bar and other ancestors.
- **Version**: 1.10.0 - **Version**: 1.11.0
+2 -3
View File
@@ -45,7 +45,7 @@ Designed specifically for users hosting their own music via Navidrome or other S
- [x] 10-band graphic EQ with built-in and custom presets - [x] 10-band graphic EQ with built-in and custom presets
- [x] Crossfade between tracks - [x] Crossfade between tracks
- [x] Replay Gain (track + album mode) - [x] Replay Gain (track + album mode)
- [x] Gapless playback *(experimental)* - [x] Gapless playback
- [x] Waveform seekbar - [x] Waveform seekbar
- [x] Last.fm scrobbling, Now Playing & love/unlove *(beta)* - [x] Last.fm scrobbling, Now Playing & love/unlove *(beta)*
- [x] Similar Artists via Last.fm, filtered to library - [x] Similar Artists via Last.fm, filtered to library
@@ -53,7 +53,7 @@ Designed specifically for users hosting their own music via Navidrome or other S
- [x] Multi-server support - [x] Multi-server support
- [x] IndexedDB image caching - [x] IndexedDB image caching
- [x] Random Mix with keyword filter & Super Genre mix - [x] Random Mix with keyword filter & Super Genre mix
- [x] 30 themes across 7 groups: Catppuccin, Nord, Retro (Gruvbox), Tokyo Night, Betriebssysteme, Psysonic originals, Psysonic Mediaplayer - [x] 35 themes across 8 groups: Catppuccin, Nord, Retro (Gruvbox), Tokyo Night, Operating Systems, Movies, Psysonic originals, Psysonic Mediaplayer
- [x] Internationalization (English, German, French, Dutch) - [x] Internationalization (English, German, French, Dutch)
- [x] AUR package (Arch / CachyOS) - [x] AUR package (Arch / CachyOS)
- [x] Configurable keybindings - [x] Configurable keybindings
@@ -61,7 +61,6 @@ Designed specifically for users hosting their own music via Navidrome or other S
### 🚧 In Progress ### 🚧 In Progress
- [ ] **Last.fm integration** — stabilising, moving out of beta - [ ] **Last.fm integration** — stabilising, moving out of beta
- [ ] **Gapless playback** — edge case stabilisation
- [ ] **Crossfade** — stability improvements - [ ] **Crossfade** — stability improvements
### 📋 Planned ### 📋 Planned
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "psysonic", "name": "psysonic",
"version": "1.10.0", "version": "1.11.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+2 -2
View File
@@ -1,6 +1,6 @@
# Maintainer: stelle <stelle@psychotoxical.dev> # Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic pkgname=psysonic
pkgver=1.10.0 pkgver=1.11.0
pkgrel=1 pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)" pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64') arch=('x86_64')
+1 -1
View File
@@ -3139,7 +3139,7 @@ dependencies = [
[[package]] [[package]]
name = "psysonic" name = "psysonic"
version = "1.9.0" version = "1.11.0"
dependencies = [ dependencies = [
"biquad", "biquad",
"md5", "md5",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "psysonic" name = "psysonic"
version = "1.10.0" version = "1.11.0"
description = "Psysonic Desktop Music Player" description = "Psysonic Desktop Music Player"
authors = [] authors = []
license = "" license = ""
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic", "productName": "Psysonic",
"version": "1.10.0", "version": "1.11.0",
"identifier": "dev.psysonic.player", "identifier": "dev.psysonic.player",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+1 -1
View File
@@ -167,7 +167,7 @@ export async function getAlbumList(
offset = 0, offset = 0,
extra: Record<string, unknown> = {} extra: Record<string, unknown> = {}
): Promise<SubsonicAlbum[]> { ): 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 ?? []; return data.albumList2?.album ?? [];
} }
+2 -2
View File
@@ -59,8 +59,8 @@ export default function AlbumCard({ album }: AlbumCardProps) {
</div> </div>
</div> </div>
<div className="album-card-info"> <div className="album-card-info">
<p className="album-card-title truncate" data-tooltip={album.name}>{album.name}</p> <p className="album-card-title truncate">{album.name}</p>
<p className="album-card-artist truncate" data-tooltip={album.artist}>{album.artist}</p> <p className="album-card-artist truncate">{album.artist}</p>
{album.year && <p className="album-card-year">{album.year}</p>} {album.year && <p className="album-card-year">{album.year}</p>}
</div> </div>
</div> </div>
+1 -1
View File
@@ -144,7 +144,7 @@ export default function AlbumTrackList({
: (song.track ?? i + 1)} : (song.track ?? i + 1)}
</div> </div>
<div className="track-info"> <div className="track-info">
<span className="track-title" data-tooltip={song.title}>{song.title}</span> <span className="track-title">{song.title}</span>
</div> </div>
{hasVariousArtists && ( {hasVariousArtists && (
<div className="track-artist-cell"> <div className="track-artist-cell">
+1 -1
View File
@@ -31,7 +31,7 @@ export default function ArtistCardLocal({ artist }: Props) {
)} )}
</div> </div>
<div className="artist-card-info"> <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' && ( {typeof artist.albumCount === 'number' && (
<span className="artist-card-meta"> <span className="artist-card-meta">
{artist.albumCount} {artist.albumCount === 1 ? 'Album' : 'Alben'} {artist.albumCount} {artist.albumCount === 1 ? 'Album' : 'Alben'}
-2
View File
@@ -75,7 +75,6 @@ export default function PlayerBar() {
<div className="player-track-meta"> <div className="player-track-meta">
<div <div
className="player-track-name" className="player-track-name"
data-tooltip={currentTrack?.title ?? ''}
style={{ cursor: currentTrack?.albumId ? 'pointer' : 'default' }} style={{ cursor: currentTrack?.albumId ? 'pointer' : 'default' }}
onClick={() => currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)} onClick={() => currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
> >
@@ -83,7 +82,6 @@ export default function PlayerBar() {
</div> </div>
<div <div
className="player-track-artist" className="player-track-artist"
data-tooltip={currentTrack?.artist ?? ''}
style={{ cursor: currentTrack?.artistId ? 'pointer' : 'default' }} style={{ cursor: currentTrack?.artistId ? 'pointer' : 'default' }}
onClick={() => currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)} onClick={() => currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
> >
+1 -3
View File
@@ -329,16 +329,14 @@ export default function QueuePanel() {
)} )}
</div> </div>
<div className="queue-current-info"> <div className="queue-current-info">
<h3 className="truncate" data-tooltip={currentTrack.title}>{currentTrack.title}</h3> <h3 className="truncate">{currentTrack.title}</h3>
<div <div
className="queue-current-sub truncate" className="queue-current-sub truncate"
data-tooltip={currentTrack.artist}
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }} style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)} onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
>{currentTrack.artist}</div> >{currentTrack.artist}</div>
<div <div
className="queue-current-sub truncate" className="queue-current-sub truncate"
data-tooltip={currentTrack.album}
style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }} style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }}
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)} onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
>{currentTrack.album}</div> >{currentTrack.album}</div>
+11 -1
View File
@@ -32,7 +32,7 @@ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
], ],
}, },
{ {
group: 'Betriebssysteme', group: 'Operating Systems',
themes: [ themes: [
{ id: 'cupertino-light', label: 'Cupertino Light', bg: '#ffffff', card: '#f2f2f7', accent: '#0071e3' }, { id: 'cupertino-light', label: 'Cupertino Light', bg: '#ffffff', card: '#f2f2f7', accent: '#0071e3' },
{ id: 'cupertino-dark', label: 'Cupertino Dark', bg: '#1e1e1f', card: '#2d2d2f', accent: '#007aff' }, { 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' }, { 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 { interface Props {
+9 -2
View File
@@ -13,6 +13,9 @@ export default function TooltipPortal() {
const boxRef = useRef<HTMLDivElement>(null); const boxRef = useRef<HTMLDivElement>(null);
const [style, setStyle] = useState<React.CSSProperties>({ opacity: 0 }); const [style, setStyle] = useState<React.CSSProperties>({ opacity: 0 });
const tooltipRef = useRef<TooltipState | null>(null);
tooltipRef.current = tooltip;
useEffect(() => { useEffect(() => {
const onOver = (e: MouseEvent) => { const onOver = (e: MouseEvent) => {
const target = (e.target as HTMLElement).closest('[data-tooltip]') as HTMLElement | null; 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'), 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]'); const target = (e.target as HTMLElement).closest('[data-tooltip]');
if (target) setTooltip(null); if (!target) setTooltip(null);
}; };
document.addEventListener('mouseover', onOver); document.addEventListener('mouseover', onOver);
document.addEventListener('mouseout', onOut); document.addEventListener('mouseout', onOut);
document.addEventListener('mousemove', onMove, { passive: true });
return () => { return () => {
document.removeEventListener('mouseover', onOver); document.removeEventListener('mouseover', onOver);
document.removeEventListener('mouseout', onOut); document.removeEventListener('mouseout', onOut);
document.removeEventListener('mousemove', onMove);
}; };
}, []); }, []);
+8
View File
@@ -64,6 +64,8 @@ const enTranslation = {
goToArtist: 'Go to Artist', goToArtist: 'Go to Artist',
readMore: 'Read more', readMore: 'Read more',
showLess: 'Show less', showLess: 'Show less',
genreInfo: 'Genre',
trackInfo: 'Track Info',
}, },
contextMenu: { contextMenu: {
playNow: 'Play Now', playNow: 'Play Now',
@@ -590,6 +592,8 @@ const deTranslation = {
goToArtist: 'Zum Künstler', goToArtist: 'Zum Künstler',
readMore: 'Mehr lesen', readMore: 'Mehr lesen',
showLess: 'Weniger anzeigen', showLess: 'Weniger anzeigen',
genreInfo: 'Genre',
trackInfo: 'Track-Info',
}, },
contextMenu: { contextMenu: {
playNow: 'Direkt abspielen', playNow: 'Direkt abspielen',
@@ -1116,6 +1120,8 @@ const frTranslation = {
goToArtist: "Aller à l'artiste", goToArtist: "Aller à l'artiste",
readMore: 'Lire la suite', readMore: 'Lire la suite',
showLess: 'Réduire', showLess: 'Réduire',
genreInfo: 'Genre',
trackInfo: 'Infos piste',
}, },
contextMenu: { contextMenu: {
playNow: 'Lire maintenant', playNow: 'Lire maintenant',
@@ -1642,6 +1648,8 @@ const nlTranslation = {
goToArtist: 'Naar artiest', goToArtist: 'Naar artiest',
readMore: 'Meer lezen', readMore: 'Meer lezen',
showLess: 'Minder tonen', showLess: 'Minder tonen',
genreInfo: 'Genre',
trackInfo: 'Trackinfo',
}, },
contextMenu: { contextMenu: {
playNow: 'Nu afspelen', playNow: 'Nu afspelen',
+1 -1
View File
@@ -106,7 +106,7 @@ export default function Favorites() {
{i + 1} {i + 1}
</div> </div>
<div className="track-info"> <div className="track-info">
<span className="track-title" data-tooltip={song.title}>{song.title}</span> <span className="track-title">{song.title}</span>
</div> </div>
<div className="track-artist-cell"> <div className="track-artist-cell">
<span <span
+150 -62
View File
@@ -107,30 +107,49 @@ const EQBars = memo(function EQBars({ isPlaying }: { isPlaying: boolean }) {
// ─── Tag Cloud ──────────────────────────────────────────────────────────────── // ─── Tag Cloud ────────────────────────────────────────────────────────────────
interface TagCloudProps { interface TagCloudProps {
genre?: string;
year?: number;
similarArtists: Array<{ id: string; name: string }>; similarArtists: Array<{ id: string; name: string }>;
onArtistClick: (id: string) => void; 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 { t } = useTranslation();
const hasTags = genre || year || similarArtists.length > 0; if (similarArtists.length === 0) return null;
if (!hasTags) 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 ( return (
<div className="np-tag-cloud"> <div className="np-tag-cloud">
{genre && <span className="np-tag np-tag-accent">{genre}</span>} <div className="np-tag-cloud-header">{t('artistDetail.similarArtists')}</div>
{year && <span className="np-tag">{year}</span>} {([similarArtists.slice(0, 3), similarArtists.slice(3, 6)] as const).map((row, rowIdx) => (
{similarArtists.slice(0, 6).map(a => ( <div key={rowIdx} className="np-tag-cloud-tags" style={rowIdx === 0 ? { marginBottom: '26px' } : undefined}>
<span {row.map((a, i) => (
key={a.id} <span
className="np-tag np-tag-clickable" key={a.id}
onClick={() => onArtistClick(a.id)} className="np-tag np-tag-clickable"
data-tooltip={t('nowPlaying.goToArtist')} style={getTagStyle(a.name, rowIdx * 3 + i)}
> onClick={() => onArtistClick(a.id)}
{a.name} data-tooltip={t('nowPlaying.goToArtist')}
</span> >
{a.name}
</span>
))}
</div>
))} ))}
</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 ──────────────────────────────────────────────────────────────── // ─── Main Page ────────────────────────────────────────────────────────────────
export default function NowPlaying() { export default function NowPlaying() {
@@ -174,6 +240,8 @@ export default function NowPlaying() {
const currentTrack = usePlayerStore(s => s.currentTrack); const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying); const isPlaying = usePlayerStore(s => s.isPlaying);
const stableNavigate = useCallback((path: string) => navigate(path), [navigate]);
// Extra song metadata // Extra song metadata
const [songMeta, setSongMeta] = useState<SubsonicSong | null>(null); const [songMeta, setSongMeta] = useState<SubsonicSong | null>(null);
useEffect(() => { useEffect(() => {
@@ -213,6 +281,42 @@ export default function NowPlaying() {
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : ''; const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
const resolvedCover = useCachedUrl(coverFetchUrl, coverKey); 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 ?? []; const similarArtists = artistInfo?.similarArtist ?? [];
return ( return (
@@ -225,17 +329,10 @@ export default function NowPlaying() {
{/* ── Hero Card ── */} {/* ── Hero Card ── */}
<div className="np-hero-card"> <div className="np-hero-card">
{/* Left: cover + meta info */} {/* Left: meta info */}
<div className="np-hero-left"> <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-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"> <div className="np-artist-album">
<span className="np-link" <span className="np-link"
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)} onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
@@ -263,13 +360,30 @@ export default function NowPlaying() {
</div> </div>
</div> </div>
{/* Center: EQ bars */} {/* Center: cover */}
<EQBars isPlaying={isPlaying} /> <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 */} {/* Right: tag cloud */}
<TagCloud <TagCloud
genre={songMeta?.genre}
year={currentTrack.year}
similarArtists={similarArtists} similarArtists={similarArtists}
onArtistClick={id => navigate(`/artist/${id}`)} onArtistClick={id => navigate(`/artist/${id}`)}
/> />
@@ -306,39 +420,13 @@ export default function NowPlaying() {
</div> </div>
)} )}
{/* ── From this Album ── */} <NpTrackList
{albumTracks.length > 0 && ( albumTracks={albumTracks}
<div className="np-info-card"> currentTrackId={currentTrack.id}
<div className="np-card-header"> album={currentTrack.album}
<h3 className="np-card-title">{t('nowPlaying.fromAlbum')}: <em style={{ fontStyle: 'normal', color: 'rgba(255,255,255,0.6)' }}>{currentTrack.album}</em></h3> albumId={currentTrack.albumId}
{currentTrack.albumId && ( onNavigate={stableNavigate}
<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>
)}
</> </>
) : ( ) : (
<div className="np-empty-state"> <div className="np-empty-state">
+4 -4
View File
@@ -351,7 +351,7 @@ export default function RandomMix() {
<button className="btn btn-ghost" style={{ padding: 4 }} onClick={e => { e.stopPropagation(); playTrack(song, genreMixSongs); }}> <button className="btn btn-ghost" style={{ padding: 4 }} onClick={e => { e.stopPropagation(); playTrack(song, genreMixSongs); }}>
<Play size={14} fill="currentColor" /> <Play size={14} fill="currentColor" />
</button> </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-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 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> <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> </button>
<div className="track-info"> <div className="track-info">
<span className="track-title" data-tooltip={song.title}>{song.title}</span> <span className="track-title">{song.title}</span>
</div> </div>
<div className="track-artist-cell"> <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>
<div className="track-info"> <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> </div>
{(() => { {(() => {
+13 -16
View File
@@ -212,11 +212,11 @@ export default function Settings() {
}; };
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [ 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: 'playback', label: t('settings.tabPlayback'), icon: <Play size={15} /> },
{ id: 'library', label: t('settings.tabLibrary'), icon: <Shuffle 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: '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} /> }, { id: 'about', label: t('settings.tabAbout'), icon: <Info size={15} /> },
]; ];
@@ -331,11 +331,8 @@ export default function Settings() {
{/* Gapless */} {/* Gapless */}
<div className="settings-toggle-row"> <div className="settings-toggle-row">
<div> <div>
<div style={{ fontWeight: 500, display: 'flex', alignItems: 'center', gap: '0.5rem' }}> <div style={{ fontWeight: 500 }}>
{t('settings.gapless')} {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>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.gaplessDesc')}</div> <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.gaplessDesc')}</div>
</div> </div>
@@ -468,16 +465,6 @@ export default function Settings() {
{/* ── Appearance ───────────────────────────────────────────────────────── */} {/* ── Appearance ───────────────────────────────────────────────────────── */}
{activeTab === '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"> <section className="settings-section">
<div className="settings-section-header"> <div className="settings-section-header">
<Globe size={18} /> <Globe size={18} />
@@ -499,6 +486,16 @@ export default function Settings() {
</div> </div>
</section> </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"> <section className="settings-section">
<div className="settings-section-header"> <div className="settings-section-header">
<Type size={18} /> <Type size={18} />
+1 -1
View File
@@ -224,7 +224,7 @@ export default function Statistics() {
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}> <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> <span style={{ fontSize: '0.875rem', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{track.name}</span>
{track.nowPlaying && ( {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>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
+1 -1
View File
@@ -11,7 +11,7 @@ interface FontState {
export const useFontStore = create<FontState>()( export const useFontStore = create<FontState>()(
persist( persist(
(set) => ({ (set) => ({
font: 'inter', font: 'lexend',
setFont: (font) => set({ font }), setFont: (font) => set({ font }),
}), }),
{ name: 'psysonic_font' } { name: 'psysonic_font' }
+3 -2
View File
@@ -2,7 +2,7 @@ import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware'; import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event'; 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 { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
import { useAuthStore } from './authStore'; import { useAuthStore } from './authStore';
@@ -155,9 +155,10 @@ function handleAudioProgress(current_time: number, duration: number) {
const progress = current_time / dur; const progress = current_time / dur;
usePlayerStore.setState({ currentTime: current_time, progress, buffered: 0 }); 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) { if (progress >= 0.5 && !store.scrobbled) {
usePlayerStore.setState({ scrobbled: true }); usePlayerStore.setState({ scrobbled: true });
scrobbleSong(track.id, Date.now());
const { scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState(); const { scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
if (scrobblingEnabled && lastfmSessionKey) { if (scrobblingEnabled && lastfmSessionKey) {
lastfmScrobble(track, Date.now(), lastfmSessionKey); lastfmScrobble(track, Date.now(), lastfmSessionKey);
+1 -1
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware'; 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 { interface ThemeState {
theme: Theme; theme: Theme;
+55 -79
View File
@@ -1796,6 +1796,11 @@
} }
/* ── Cover breathing ── */ /* ── Cover breathing ── */
@keyframes ambilight-pulse {
0%, 100% { opacity: 0.7; }
50% { opacity: 1.0; }
}
@keyframes cover-breathe { @keyframes cover-breathe {
0%, 0%,
@@ -2645,26 +2650,24 @@
/* ── Hero card: 3-column (cover+info | EQ | tag cloud) ── */ /* ── Hero card: 3-column (cover+info | EQ | tag cloud) ── */
.np-hero-card { .np-hero-card {
display: flex; display: grid;
align-items: stretch; grid-template-columns: 1fr auto 1fr;
gap: 20px; align-items: center;
padding: 24px; gap: 28px;
padding: 32px;
background: rgba(0, 0, 0, 0.30); background: rgba(0, 0, 0, 0.30);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.08);
flex-shrink: 0; 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 { .np-hero-left {
flex: 1;
min-width: 0; min-width: 0;
display: flex; display: flex;
align-items: flex-start; align-items: center;
gap: 18px;
} }
.np-hero-cover-wrap { .np-hero-cover-wrap {
@@ -2680,58 +2683,44 @@
min-width: 0; min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 14px;
padding-top: 4px;
justify-content: center; justify-content: center;
} }
/* Center column: fixed width so it stays truly centered between equal outer columns */ /* Right column: tag cloud */
.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 */
.np-tag-cloud { .np-tag-cloud {
flex: 1;
min-width: 0; min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
gap: 0;
}
.np-tag-cloud-tags {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 7px; gap: 8px 10px;
align-content: flex-start;
padding-top: 4px;
justify-content: flex-end; 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 { .np-tag {
font-size: 11px; font-size: 13px;
font-weight: 600; font-weight: 600;
padding: 4px 10px; padding: 6px 14px;
border-radius: 999px; border-radius: 999px;
background: rgba(255, 255, 255, 0.10); background: rgba(255, 255, 255, 0.10);
color: rgba(255, 255, 255, 0.75); color: rgba(255, 255, 255, 0.75);
@@ -3030,9 +3019,18 @@
inset: -15%; inset: -15%;
background-size: cover; background-size: cover;
background-position: center; 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; 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 { .np-bg-overlay {
@@ -3063,35 +3061,13 @@
justify-content: center; 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 { .np-cover {
position: relative; position: relative;
z-index: 1; z-index: 1;
width: 196px; width: 280px;
height: 196px; height: 280px;
border-radius: 14px; border-radius: 16px;
object-fit: cover; 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 { .np-cover-fallback {
@@ -3114,14 +3090,14 @@
.np-title { .np-title {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 26px; font-size: 36px;
font-weight: 700; font-weight: 700;
line-height: 1.2; line-height: 1.15;
color: white; color: white;
} }
.np-artist-album { .np-artist-album {
font-size: 15px; font-size: 19px;
color: rgba(255, 255, 255, 0.75); color: rgba(255, 255, 255, 0.75);
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
+458
View File
@@ -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(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(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; } .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;
}