mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
feat: v1.11.0 — Movies themes, Settings polish, Gapless stable
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
Generated
+1
-1
@@ -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,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,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
@@ -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 ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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}`)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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} />
|
||||||
|
|||||||
@@ -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' }}>
|
||||||
|
|||||||
@@ -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' }
|
||||||
|
|||||||
@@ -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,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
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user