From 867c5fbd3e3773b6d0a6a533f5da44eda3d0420a Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Sun, 22 Mar 2026 02:37:49 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20v1.11.0=20=E2=80=94=20Movies=20themes,?= =?UTF-8?q?=20Settings=20polish,=20Gapless=20stable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 23 ++ CLAUDE.md | 2 +- README.md | 5 +- package.json | 2 +- packages/aur/PKGBUILD | 4 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- src/api/subsonic.ts | 2 +- src/components/AlbumCard.tsx | 4 +- src/components/AlbumTrackList.tsx | 2 +- src/components/ArtistCardLocal.tsx | 2 +- src/components/PlayerBar.tsx | 2 - src/components/QueuePanel.tsx | 4 +- src/components/ThemePicker.tsx | 12 +- src/components/TooltipPortal.tsx | 11 +- src/i18n.ts | 8 + src/pages/Favorites.tsx | 2 +- src/pages/NowPlaying.tsx | 212 +++++++++---- src/pages/RandomMix.tsx | 8 +- src/pages/Settings.tsx | 29 +- src/pages/Statistics.tsx | 2 +- src/store/fontStore.ts | 2 +- src/store/playerStore.ts | 5 +- src/store/themeStore.ts | 2 +- src/styles/components.css | 134 ++++----- src/styles/theme.css | 458 +++++++++++++++++++++++++++++ 27 files changed, 753 insertions(+), 190 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe06dbab..d7acda0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/), 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 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 32953687..1bd6c3da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`). - **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. -- **Version**: 1.10.0 +- **Version**: 1.11.0 diff --git a/README.md b/README.md index db31e806..93c48a79 100644 --- a/README.md +++ b/README.md @@ -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] Crossfade between tracks - [x] Replay Gain (track + album mode) -- [x] Gapless playback *(experimental)* +- [x] Gapless playback - [x] Waveform seekbar - [x] Last.fm scrobbling, Now Playing & love/unlove *(beta)* - [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] IndexedDB image caching - [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] AUR package (Arch / CachyOS) - [x] Configurable keybindings @@ -61,7 +61,6 @@ Designed specifically for users hosting their own music via Navidrome or other S ### 🚧 In Progress - [ ] **Last.fm integration** — stabilising, moving out of beta -- [ ] **Gapless playback** — edge case stabilisation - [ ] **Crossfade** — stability improvements ### 📋 Planned diff --git a/package.json b/package.json index 33371c0c..fecdbcc8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "psysonic", - "version": "1.10.0", + "version": "1.11.0", "private": true, "scripts": { "dev": "vite", diff --git a/packages/aur/PKGBUILD b/packages/aur/PKGBUILD index c05e724d..a08bd476 100644 --- a/packages/aur/PKGBUILD +++ b/packages/aur/PKGBUILD @@ -1,6 +1,6 @@ -# Maintainer: stelle +# Maintainer: Psychotoxic pkgname=psysonic -pkgver=1.10.0 +pkgver=1.11.0 pkgrel=1 pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)" arch=('x86_64') diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index dd9c96ed..6071fb0d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3139,7 +3139,7 @@ dependencies = [ [[package]] name = "psysonic" -version = "1.9.0" +version = "1.11.0" dependencies = [ "biquad", "md5", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e3203fb0..554b4f6c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psysonic" -version = "1.10.0" +version = "1.11.0" description = "Psysonic Desktop Music Player" authors = [] license = "" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8cca8e46..7a6fa514 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Psysonic", - "version": "1.10.0", + "version": "1.11.0", "identifier": "dev.psysonic.player", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index d9c8d348..dcf330b7 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -167,7 +167,7 @@ export async function getAlbumList( offset = 0, extra: Record = {} ): Promise { - 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 ?? []; } diff --git a/src/components/AlbumCard.tsx b/src/components/AlbumCard.tsx index b29dde9a..b132609f 100644 --- a/src/components/AlbumCard.tsx +++ b/src/components/AlbumCard.tsx @@ -59,8 +59,8 @@ export default function AlbumCard({ album }: AlbumCardProps) {
-

{album.name}

-

{album.artist}

+

{album.name}

+

{album.artist}

{album.year &&

{album.year}

}
diff --git a/src/components/AlbumTrackList.tsx b/src/components/AlbumTrackList.tsx index 2048717f..8a3533a5 100644 --- a/src/components/AlbumTrackList.tsx +++ b/src/components/AlbumTrackList.tsx @@ -144,7 +144,7 @@ export default function AlbumTrackList({ : (song.track ?? i + 1)}
- {song.title} + {song.title}
{hasVariousArtists && (
diff --git a/src/components/ArtistCardLocal.tsx b/src/components/ArtistCardLocal.tsx index 25930d71..f0ea90a7 100644 --- a/src/components/ArtistCardLocal.tsx +++ b/src/components/ArtistCardLocal.tsx @@ -31,7 +31,7 @@ export default function ArtistCardLocal({ artist }: Props) { )}
- {artist.name} + {artist.name} {typeof artist.albumCount === 'number' && ( {artist.albumCount} {artist.albumCount === 1 ? 'Album' : 'Alben'} diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index a6abaa5a..d7f49b6c 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -75,7 +75,6 @@ export default function PlayerBar() {
currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)} > @@ -83,7 +82,6 @@ export default function PlayerBar() {
currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)} > diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index dc3d19d6..d996f5fd 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -329,16 +329,14 @@ export default function QueuePanel() { )}
-

{currentTrack.title}

+

{currentTrack.title}

currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)} >{currentTrack.artist}
currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)} >{currentTrack.album}
diff --git a/src/components/ThemePicker.tsx b/src/components/ThemePicker.tsx index 1c02f748..cc00e117 100644 --- a/src/components/ThemePicker.tsx +++ b/src/components/ThemePicker.tsx @@ -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 { diff --git a/src/components/TooltipPortal.tsx b/src/components/TooltipPortal.tsx index 4392d3ed..52585a3d 100644 --- a/src/components/TooltipPortal.tsx +++ b/src/components/TooltipPortal.tsx @@ -13,6 +13,9 @@ export default function TooltipPortal() { const boxRef = useRef(null); const [style, setStyle] = useState({ opacity: 0 }); + const tooltipRef = useRef(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); }; }, []); diff --git a/src/i18n.ts b/src/i18n.ts index 2c1da0bd..8d3fbb7c 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -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', diff --git a/src/pages/Favorites.tsx b/src/pages/Favorites.tsx index 96fb286f..c1cada0a 100644 --- a/src/pages/Favorites.tsx +++ b/src/pages/Favorites.tsx @@ -106,7 +106,7 @@ export default function Favorites() { {i + 1}
- {song.title} + {song.title}
; 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 (
- {genre && {genre}} - {year && {year}} - {similarArtists.slice(0, 6).map(a => ( - onArtistClick(a.id)} - data-tooltip={t('nowPlaying.goToArtist')} - > - {a.name} - +
{t('artistDetail.similarArtists')}
+ {([similarArtists.slice(0, 3), similarArtists.slice(3, 6)] as const).map((row, rowIdx) => ( +
+ {row.map((a, i) => ( + onArtistClick(a.id)} + data-tooltip={t('nowPlaying.goToArtist')} + > + {a.name} + + ))} +
))}
); @@ -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 ( +
+
+

{t('nowPlaying.fromAlbum')}: {album}

+ {albumId && ( + + )} +
+
+ {albumTracks.map(track => { + const isActive = track.id === currentTrackId; + return ( +
albumId && onNavigate(`/album/${albumId}`)} + > + + {isActive + ? + : track.track ?? '—' + } + + {track.title} + {formatTime(track.duration)} +
+ ); + })} +
+
+ ); +}); + // ─── 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(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 ── */}
- {/* Left: cover + meta info */} + {/* Left: meta info */}
-
- {resolvedCover && } - {resolvedCover - ? - :
- } -
-
{currentTrack.title}
+
{currentTrack.title}
currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)} @@ -263,13 +360,30 @@ export default function NowPlaying() {
- {/* Center: EQ bars */} - + {/* Center: cover */} +
+
+ {resolvedCover + ? + :
+ } +
{/* Right: tag cloud */} navigate(`/artist/${id}`)} /> @@ -306,39 +420,13 @@ export default function NowPlaying() {
)} - {/* ── From this Album ── */} - {albumTracks.length > 0 && ( -
-
-

{t('nowPlaying.fromAlbum')}: {currentTrack.album}

- {currentTrack.albumId && ( - - )} -
-
- {albumTracks.map(track => { - const isActive = track.id === currentTrack.id; - return ( -
currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)} - > - - {isActive - ? - : track.track ?? '—' - } - - {track.title} - {formatTime(track.duration)} -
- ); - })} -
-
- )} + ) : (
diff --git a/src/pages/RandomMix.tsx b/src/pages/RandomMix.tsx index d4f1941f..b38f6ed1 100644 --- a/src/pages/RandomMix.tsx +++ b/src/pages/RandomMix.tsx @@ -351,7 +351,7 @@ export default function RandomMix() { -
{song.title}
+
{song.title}
{song.artist}
{song.album}
{song.genre ?? '—'}
@@ -415,15 +415,15 @@ export default function RandomMix() {
- {song.title} + {song.title}
- {song.artist} + {song.artist}
- {song.album} + {song.album}
{(() => { diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 46f71a3c..15f61756 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -212,11 +212,11 @@ export default function Settings() { }; const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [ + { id: 'server', label: t('settings.tabServer'), icon: }, + { id: 'appearance', label: t('settings.tabAppearance'), icon: }, { id: 'playback', label: t('settings.tabPlayback'), icon: }, { id: 'library', label: t('settings.tabLibrary'), icon: }, - { id: 'appearance', label: t('settings.tabAppearance'), icon: }, { id: 'shortcuts', label: t('settings.tabShortcuts'), icon: }, - { id: 'server', label: t('settings.tabServer'), icon: }, { id: 'about', label: t('settings.tabAbout'), icon: }, ]; @@ -331,11 +331,8 @@ export default function Settings() { {/* Gapless */}
-
+
{t('settings.gapless')} - - {t('settings.experimental')} -
{t('settings.gaplessDesc')}
@@ -468,16 +465,6 @@ export default function Settings() { {/* ── Appearance ───────────────────────────────────────────────────────── */} {activeTab === 'appearance' && ( <> -
-
- -

{t('settings.theme')}

-
-
- theme.setTheme(v as any)} /> -
-
-
@@ -499,6 +486,16 @@ export default function Settings() {
+
+
+ +

{t('settings.theme')}

+
+
+ theme.setTheme(v as any)} /> +
+
+
diff --git a/src/pages/Statistics.tsx b/src/pages/Statistics.tsx index d7b60060..963eed63 100644 --- a/src/pages/Statistics.tsx +++ b/src/pages/Statistics.tsx @@ -224,7 +224,7 @@ export default function Statistics() {
{track.name} {track.nowPlaying && ( - {t('statistics.lfmNowPlaying')} + {t('statistics.lfmNowPlaying')} )}
diff --git a/src/store/fontStore.ts b/src/store/fontStore.ts index 2c44ce52..c3937ed3 100644 --- a/src/store/fontStore.ts +++ b/src/store/fontStore.ts @@ -11,7 +11,7 @@ interface FontState { export const useFontStore = create()( persist( (set) => ({ - font: 'inter', + font: 'lexend', setFont: (font) => set({ font }), }), { name: 'psysonic_font' } diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 232e4c8c..ccb2df7b 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -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); diff --git a/src/store/themeStore.ts b/src/store/themeStore.ts index 48a06217..d1371773 100644 --- a/src/store/themeStore.ts +++ b/src/store/themeStore.ts @@ -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; diff --git a/src/styles/components.css b/src/styles/components.css index 7acee780..9479be3e 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -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; diff --git a/src/styles/theme.css b/src/styles/theme.css index c73eefd5..99b901b7 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -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; +}