Compare commits

...

12 Commits

Author SHA1 Message Date
Psychotoxical 1e599d9636 feat: Search results page, gapless removal, UI polish (v1.0.11)
- Add full search results page (/search?q=) with artist, album, song sections
- Fix search results column alignment (fixed-width Format column eliminates per-grid auto-sizing variance)
- Remove experimental gapless playback (caused song skipping and beginning cutoffs)
- Add known limitations to README and CHANGELOG (seeking, DnD reliability)
- Update GitHub Actions to Node.js 24 (checkout@v5, setup-node@v5)
- Add align-items: center to tracklist-header CSS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 12:33:58 +01:00
Psychotoxical 623a6a4a54 feat: UI polish, DnD fix, navigation, and installer improvements (v1.0.10)
- Fix queue DnD on macOS/Windows: delay ref cleanup in onDragEnd so onDropQueue
  reads correct source/destination before dragend clears them
- Active track highlighting in album tracklist (pulsing accent bg + play icon)
- Marquee scrolling for long titles in Fullscreen Player
- Clickable artist/album in Player Bar and Queue now-playing strip
- Tracklist: format column moved after duration, auto width, kHz/filesize removed
- Settings dropdowns: visible border (base bg + overlay0 border)
- Sidebar: fixed responsive width via clamp(), removed drag-to-resize
- Favorites icon: HandMetal → Star in Random Mix page
- Linux app menu category set to Multimedia (AudioVideo)
- Windows MSI: stable upgradeCode for in-place upgrades
- Bundle: category/description fields at correct config level
- About: Claude Code credit, fixed German MIT licence wording

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 11:36:03 +01:00
Psychotoxical 32571a2986 feat: Gapless playback, seek recovery, buffered indicator, and UI polish (v1.0.9)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 23:54:32 +01:00
Psychotoxical c9b4bc091e feat: Ambient Stage, Queue DnD overhaul, and GStreamer fixes (v1.0.8) 2026-03-13 21:57:07 +01:00
Psychotoxical 409c20a79e Update README.md 2026-03-13 20:13:57 +01:00
Psychotoxical 3384db76cd docs: remove Security section from README 2026-03-13 20:11:53 +01:00
Psychotoxical e889a76f5f docs: update README with new features and known limitations 2026-03-13 20:11:00 +01:00
Psychotoxical eb011bdfdf Update README.md 2026-03-13 20:09:07 +01:00
Psychotoxical 1d23b21f6f Update README.md 2026-03-13 20:08:29 +01:00
Psychotoxical 5528123193 feat: Update Notification system and UI refinements (v1.0.7) 2026-03-13 19:31:44 +01:00
Psychotoxical 85823ff4c4 feat: Nord themes, Wayland compatibility, and stability fixes (v1.0.6) 2026-03-13 18:59:32 +01:00
Psychotoxical e36a81f847 Update README.md 2026-03-12 22:30:41 +01:00
32 changed files with 1419 additions and 544 deletions
+7 -7
View File
@@ -14,9 +14,9 @@ jobs:
release_id: ${{ steps.create-release.outputs.result }}
package_version: ${{ steps.get-version.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: setup node
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: lts/*
- name: get version
@@ -56,9 +56,9 @@ jobs:
args: ''
runs-on: ${{ matrix.settings.platform }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: setup node
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: lts/*
- name: install Rust stable
@@ -80,7 +80,7 @@ jobs:
contents: write
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: install dependencies
run: |
@@ -97,7 +97,7 @@ jobs:
chmod +x /usr/local/bin/linuxdeploy-plugin-gstreamer.sh
- name: setup node
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: lts/*
@@ -117,7 +117,7 @@ jobs:
APPIMAGE=$(find src-tauri/target/release/bundle/appimage -name "*.AppImage" -not -name "*.tar.gz" | head -1)
echo "Patching: $APPIMAGE"
APPIMAGE_EXTRACT_AND_RUN=1 ./"$APPIMAGE" --appimage-extract
sed -i '/^exec /i export WEBKIT_DISABLE_COMPOSITING_MODE=1\nexport WEBKIT_DISABLE_DMABUF_RENDERER=1' squashfs-root/AppRun
sed -i '/^exec /i export WEBKIT_DISABLE_COMPOSITING_MODE=1\nexport WEBKIT_DISABLE_DMABUF_RENDERER=1\nexport GDK_BACKEND=x11' squashfs-root/AppRun
wget -q "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" \
-O appimagetool
chmod +x appimagetool
+88
View File
@@ -5,6 +5,94 @@ 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.0.11] - 2026-03-14
### Added
- **Search Results Page**: Pressing Enter in the search bar now navigates to a dedicated full search results page showing artists, albums, and songs with proper column layout and headers.
### Fixed
- **Search Results Column Alignment**: Artist and album columns in the search results song list are now correctly aligned with their column headers.
- **Search Results Header Alignment**: Fixed column header labels not aligning with song row content (root cause: `auto`-width Format column was sized independently per grid row).
### Changed
- **Gapless Playback removed**: Removed the experimental gapless playback feature. It caused intermittent song skipping and beginning cutoffs and was not reliable enough to ship. Standard sequential playback is used instead.
### Known Issues
- **Seeking**: Seeking may occasionally be unreliable, particularly on Linux/GStreamer. Being actively investigated.
- **Queue drag & drop (macOS / Windows)**: Queue reordering via drag & drop may not always work correctly on macOS and Windows. Being actively worked on.
## [1.0.10] - 2026-03-14
### Added
- **Active Track Highlighting**: The currently playing song is highlighted in album tracklists with a subtle pulsing accent background and a play icon — persists when navigating away and returning.
- **Marquee Title in Fullscreen Player**: Long song titles now scroll smoothly as a marquee instead of being cut off.
- **Clickable Artist / Album in Player Bar**: Clicking the artist name navigates to the artist page; clicking the song title navigates to the album page. Same behaviour in the Queue panel's now-playing strip.
- **Linux App Menu Category**: Application now appears under **Multimedia** in desktop application menus (GNOME, KDE, etc.) instead of "Other".
- **Windows MSI Upgrade Support**: Added stable `upgradeCode` GUID so the MSI installer recognises previous versions and upgrades in-place without requiring manual uninstallation first.
### Fixed
- **Drag & Drop (macOS / Windows)**: Queue reordering now works correctly on macOS WKWebView and Windows WebView2. The previous fix cleared index refs synchronously in `onDragEnd`, which fires before `drop` on both platforms — refs are now cleared with a short delay so `onDropQueue` can read the correct source and destination indices.
- **Settings Dropdowns**: Language and theme selects now have a clearly visible border (was invisible against the card background).
- **Tracklist Format Column**: Removed file size and kHz from the format column — codec and bitrate only. Column moved to the far right, after duration. Width is now dynamic (`auto`).
- **`tauri.conf.json`**: Fixed invalid placement of `shortDescription`/`longDescription` (were incorrectly nested under `bundle.linux`, now at `bundle` level). Removed invalid `nsis.allowDowngrades` field.
### Changed
- **Favorites Icon**: Replaced the incorrect fork icon with a star icon in the Random Mix page, consistent with all other pages.
- **Sidebar**: Removed drag-to-resize handle. Width now adapts dynamically to the viewport via `clamp(180px, 15vw, 220px)`.
- **About Section**: Added "Developed with the support of Claude Code by Anthropic" credit. Fixed "weiterzugeben" wording in German MIT licence text.
- **Minimize to Tray**: Now disabled by default.
## [1.0.9] - 2026-03-13
### Added
- **Gapless Playback**: The next track's audio pipeline is silently pre-warmed before the current track ends, eliminating the gap between songs — especially noticeable on live albums and concept records.
- **Pre-caching**: Prefetched Howl instances are now actually reused for playback, giving near-instant track transitions instead of a new HTTP connection each time.
- **Buffered Progress Indicator**: The seek bar now shows a secondary fill indicating how much of the current track has been buffered by the browser — visible in both the Player Bar and Fullscreen Player.
- **Resume on Startup**: Pressing Play after launching the app now resumes the last track at the saved playback position instead of doing nothing.
- **Album Track Hover Play Button**: Hovering over a track number in Album Detail reveals a play button for quick single-click playback.
- **Ken Burns Background**: The Fullscreen Player background now slowly drifts and zooms (Ken Burns effect) for a more cinematic feel.
- **F11 Fullscreen**: Toggle native borderless fullscreen with F11.
- **Compact Queue Now-Playing**: The current track block in the Queue Panel is now a slim horizontal strip (72 px thumbnail) instead of a full-width cover, freeing up significantly more space for the queue list on smaller screens.
### Fixed
- **GStreamer Seek Stability**: Implemented a three-layer recovery system for Linux/GStreamer seek hangs: (1) seek queuing to prevent overlapping GStreamer seeks, (2) a 2-second watchdog that triggers automatic recovery if a seek never completes, (3) an 8-second hang detector that silently recreates the audio pipeline and resumes from the last known position if playback freezes entirely.
- **Fullscreen Player**: Removed drop shadow from cover art — looks cleaner on lighter artist backgrounds.
### Changed
- **Hero Section**: Increased height (300 → 360 px) and cover art size (180 → 220 px) to prevent long album titles from clipping.
- **Player Bar**: Controls and progress bar moved closer together for a more balanced layout.
## [1.0.8] - 2026-03-13
### Added
- **Ambient Stage**: Completely redesigned Fullscreen Player. Experience an immersive atmosphere with drifting color orbs, a "breathing" cover animation, and high-resolution artist backgrounds.
- **Improved Drag & Drop**: Rewritten Play Queue reordering for rock-solid reliability on macOS (WKWebView) and Windows (WebView2).
### Fixed
- **Linux Audio Stability**: Resolved playback stuttering when seeking under GStreamer by implementing a robust pause-seek-play sequence.
- **Data Integration**: Standardized `artistId` propagation across all track sources for better metadata consistency.
## [1.0.7] - 2026-03-13
### Added
- **Update Notifications**: Integrated a native update check system in the sidebar that notifies you when a new version is available on GitHub.
- **Improved Settings**: Refined layout and styling for a cleaner settings experience.
### Fixed
- **UI/UX Refinements**: Polished sidebar animations and layout for better visual consistency.
- **i18n**: Added missing translations for update notifications and system status.
## [1.0.6] - 2026-03-13
### Added
- **Extended Themes**: Selection expanded to 8 themes, including the complete Nord series (Nord, Snowstorm, Frost, Aurora).
- **Light Theme Support**: Enhanced readability for Hero and Fullscreen Player components when using light themes (Latte, Snowstorm).
### Fixed
- **Linux/Wayland Compatibility**: Fixed immediate crash on Wayland environments by forcing X11 backend for the AppImage.
- **Playback Stability**: Introduced seek debouncing to prevent audio stalls on Linux/GStreamer.
- **Windows Integration**: Improved drag-and-drop compatibility for systems using WebView2.
## [1.0.5] - 2026-03-12
### Added
+12 -17
View File
@@ -16,26 +16,28 @@ Psysonic is a beautiful desktop music player built completely from the ground up
Designed specifically for users hosting their own music via Navidrome or other Subsonic API servers, Psysonic aims to be the best way to interact with your personal library.
![Psysonic Screenshot](public/screenshot.png)
## ✨ Features
- 🎨 **Gorgeous UI**: Deeply integrated Catppuccin themes (Mocha & Latte) with smooth glassmorphism effects and micro-animations.
- 🎨 **Gorgeous UI**: 8 deeply integrated themes (Catppuccin series + Nord series) with smooth glassmorphism effects and micro-animations.
-**Blazing Fast**: Built with Rust & Tauri, resulting in minimal RAM usage compared to typical Electron apps.
- 🌍 **Internationalization (i18n)**: Fully translated into English and German (v1.0.0).
- 🌍 **Internationalization (i18n)**: Fully translated into English and German.
- 📻 **Live "Now Playing"**: See what other users on your server are currently listening to in real-time.
- 🎵 **Last.fm Scrobbling**: Full integration for scrobbling your tracks via the Navidrome server.
- 💾 **Local Caching**: Fast loading times with customizable image caching thresholds.
- 💿 **Album & Artist Views**: Beautiful grid displays and detailed artist pages with related albums.
- 💾 **IndexedDB Caching**: Ultra-fast loading times with persistent IndexedDB image caching for cover art and artist images.
- 📀 **Album Downloads**: Support for downloading entire albums directly to your local machine.
- 💿 **Album & Artist Views**: Beautiful grid displays and detailed artist pages with related albums and color-coded initial avatars for fast browsing.
- 🎛️ **Queue Management**: Drag & drop support, playlist saving, and loading directly built into the queue. Server-side queue synchronization is fully supported.
- 🖥️ **Cross-Platform**: Available natively for Windows, macOS, and Linux.
- 🔄 **Update Notifications**: Built-in update checker that notifies you when a new version is available on GitHub.
- 🖥️ **Cross-Platform**: Available natively for Windows, macOS, and Linux (including Wayland support).
## 🛡️ Security
## ● Known Limitations
With the **v1.0.0 Release**, Psysonic has been hardened for public use:
- **Secured Credentials**: Industry-standard security using Tauri's native encrypted store.
- **Hardened Sandbox**: Restricted filesystem permissions to only necessary directories.
- **Native Security**: Disabled global Tauri objects to significantly reduce XSS attack vectors.
- **Seeking (all platforms)**: Seeking may occasionally be unreliable — especially on Linux/GStreamer. This is a known issue currently being worked on.
- **Queue drag & drop (macOS / Windows)**: Queue reordering via drag & drop is being actively worked on and may not always behave correctly on macOS and Windows.
- **Linux (drag & drop cursor feedback)**: Due to a WebKitGTK limitation, the drag cursor does not reflect the drop operation type — it may appear as a "forbidden" symbol or show no indicator at all, depending on the desktop environment. Drag and drop itself works correctly.
## 📥 Installation
@@ -91,10 +93,3 @@ Contributions are completely welcome! Whether it is translating the app into a n
## 📄 License
Distributed under the MIT License. See `LICENSE` for more information.
---
<p align="center">
<i>Core Components developed with <b>Claude Code</b></i><br/>
<i>UI/UX developed with <b>Gemini 2.0 Flash</b> using <b>Antigravity</b></i>
</p>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.0.5",
"version": "1.0.11",
"private": true,
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -2732,7 +2732,7 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.0.5"
version = "1.0.11"
dependencies = [
"serde",
"serde_json",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.0.5"
version = "1.0.11"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
+3 -1
View File
@@ -25,6 +25,8 @@
"core:window:allow-set-title",
"core:window:allow-close",
"core:window:allow-hide",
"core:window:allow-show"
"core:window:allow-show",
"core:window:allow-set-fullscreen",
"core:window:allow-is-fullscreen"
]
}
+1 -1
View File
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default","shell:allow-open","notification:default","global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show"],"platforms":["linux","macOS","windows"]}}
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default","shell:allow-open","notification:default","global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen"],"platforms":["linux","macOS","windows"]}}
+9 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.0.5",
"version": "1.0.11",
"identifier": "dev.psysonic.app",
"build": {
"beforeDevCommand": "npm run dev",
@@ -45,10 +45,18 @@
"icons/icon.icns",
"icons/icon.ico"
],
"category": "Music",
"shortDescription": "A sleek music player for Subsonic-compatible servers",
"longDescription": "Psysonic is a desktop music player for Subsonic-compatible servers such as Navidrome and Gonic. It features a modern Catppuccin-themed UI with glassmorphism effects, gapless playback, a fullscreen ambient player, play queue management with drag-and-drop, scrobbling support, and multi-server profiles.",
"linux": {
"appimage": {
"bundleMediaFramework": true
}
},
"windows": {
"wix": {
"upgradeCode": "e3b4c2a1-7f6d-4e8b-9c5a-2d1f0e3b8a7c"
}
}
}
}
+15 -23
View File
@@ -23,6 +23,7 @@ import Statistics from './pages/Statistics';
import Playlists from './pages/Playlists';
import Help from './pages/Help';
import RandomAlbums from './pages/RandomAlbums';
import SearchResults from './pages/SearchResults';
import FullscreenPlayer from './components/FullscreenPlayer';
import ContextMenu from './components/ContextMenu';
import { useAuthStore } from './store/authStore';
@@ -65,12 +66,10 @@ function AppShell() {
fn();
}, [currentTrack, isPlaying]);
const [sidebarWidth, setSidebarWidth] = useState(220);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
return localStorage.getItem('psysonic_sidebar_collapsed') === 'true';
});
const [queueWidth, setQueueWidth] = useState(300);
const [isDraggingSidebar, setIsDraggingSidebar] = useState(false);
const [isDraggingQueue, setIsDraggingQueue] = useState(false);
useEffect(() => {
@@ -78,24 +77,18 @@ function AppShell() {
}, [isSidebarCollapsed]);
const handleMouseMove = useCallback((e: MouseEvent) => {
if (isDraggingSidebar) {
// Limit sidebar width between 180px and 400px
const newWidth = Math.max(180, Math.min(e.clientX, 400));
setSidebarWidth(newWidth);
} else if (isDraggingQueue) {
// Limit queue width between 250px and 500px. Queue is on the right.
if (isDraggingQueue) {
const newWidth = Math.max(250, Math.min(window.innerWidth - e.clientX, 500));
setQueueWidth(newWidth);
}
}, [isDraggingSidebar, isDraggingQueue]);
}, [isDraggingQueue]);
const handleMouseUp = useCallback(() => {
setIsDraggingSidebar(false);
setIsDraggingQueue(false);
}, []);
useEffect(() => {
if (isDraggingSidebar || isDraggingQueue) {
if (isDraggingQueue) {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
document.body.style.cursor = 'col-resize';
@@ -111,13 +104,13 @@ function AppShell() {
window.removeEventListener('mouseup', handleMouseUp);
document.body.classList.remove('is-dragging');
};
}, [isDraggingSidebar, isDraggingQueue, handleMouseMove, handleMouseUp]);
}, [isDraggingQueue, handleMouseMove, handleMouseUp]);
return (
<div
className="app-shell"
style={{
'--sidebar-width': isSidebarCollapsed ? '72px' : `${sidebarWidth}px`,
style={{
'--sidebar-width': isSidebarCollapsed ? '72px' : 'clamp(180px, 15vw, 220px)',
'--queue-width': isQueueVisible ? `${queueWidth}px` : '0px'
} as React.CSSProperties}
onContextMenu={e => e.preventDefault()}
@@ -126,14 +119,6 @@ function AppShell() {
isCollapsed={isSidebarCollapsed}
toggleCollapse={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
/>
<div
className="resizer resizer-sidebar"
onMouseDown={(e) => {
e.preventDefault();
setIsDraggingSidebar(true);
}}
style={{ display: isSidebarCollapsed ? 'none' : 'block' }}
/>
<main className="main-content">
<header className="content-header">
<LiveSearch />
@@ -153,6 +138,7 @@ function AppShell() {
<Route path="/random-mix" element={<RandomMix />} />
<Route path="/playlists" element={<Playlists />} />
<Route path="/label/:name" element={<LabelAlbums />} />
<Route path="/search" element={<SearchResults />} />
<Route path="/statistics" element={<Statistics />} />
<Route path="/settings" element={<Settings />} />
<Route path="/help" element={<Help />} />
@@ -184,9 +170,15 @@ function TauriEventBridge() {
const previous = usePlayerStore(s => s.previous);
const { minimizeToTray } = useAuthStore();
// Spacebar → play/pause (ignore when focus is in an input)
// Spacebar → play/pause, F11 → window fullscreen
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.code === 'F11') {
e.preventDefault();
const win = getCurrentWindow();
win.isFullscreen().then(fs => win.setFullscreen(!fs));
return;
}
if (e.code !== 'Space') return;
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
+1
View File
@@ -59,6 +59,7 @@ export interface SubsonicSong {
artist: string;
album: string;
albumId: string;
artistId?: string;
duration: number;
track?: number;
discNumber?: number;
+1 -1
View File
@@ -29,7 +29,7 @@ export default function AlbumCard({ album }: AlbumCardProps) {
draggable
onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('application/json', JSON.stringify({
e.dataTransfer.setData('text/plain', JSON.stringify({
type: 'album',
id: album.id,
name: album.name,
+2 -2
View File
@@ -62,7 +62,7 @@ export default function ContextMenu() {
const top = await getTopSongs(artistName);
const radioTracks = [...top, ...similar].map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
playTrack(radioTracks[0], radioTracks);
@@ -137,7 +137,7 @@ export default function ContextMenu() {
const albumData = await getAlbum(song.albumId);
const tracks = albumData.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
enqueue(tracks);
+138 -99
View File
@@ -1,10 +1,10 @@
import React, { useCallback, useEffect, useState, useRef, memo } from 'react';
import {
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX,
Play, Pause, SkipBack, SkipForward,
ChevronDown, Repeat, Repeat1, Square, Music
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo } from '../api/subsonic';
import CachedImage, { useCachedUrl } from './CachedImage';
import { useTranslation } from 'react-i18next';
@@ -15,9 +15,45 @@ function formatTime(seconds: number): string {
return `${m}:${s.toString().padStart(2, '0')}`;
}
// ─── Crossfading blurred background — two stacked divs for true crossfade ───
function MarqueeTitle({ title }: { title: string }) {
const containerRef = useRef<HTMLDivElement>(null);
const textRef = useRef<HTMLSpanElement>(null);
const [scrollAmount, setScrollAmount] = useState(0);
const measure = useCallback(() => {
const container = containerRef.current;
const text = textRef.current;
if (!container || !text) return;
// Temporarily make span inline-block to get its natural width
text.style.display = 'inline-block';
const textWidth = text.getBoundingClientRect().width;
text.style.display = '';
const overflow = textWidth - container.clientWidth;
setScrollAmount(overflow > 4 ? Math.ceil(overflow) : 0);
}, []);
useEffect(() => {
measure();
const ro = new ResizeObserver(measure);
if (containerRef.current) ro.observe(containerRef.current);
return () => ro.disconnect();
}, [title, measure]);
return (
<div ref={containerRef} className="fs-title-wrap">
<span
ref={textRef}
className={scrollAmount > 0 ? 'fs-title-marquee' : ''}
style={scrollAmount > 0 ? { '--scroll-amount': `-${scrollAmount}px` } as React.CSSProperties : {}}
>
{title}
</span>
</div>
);
}
// ─── Crossfading blurred background ───────────────────────────────────────────
const FsBg = memo(function FsBg({ url }: { url: string }) {
// Each layer: {url, id, visible}
const [layers, setLayers] = useState<Array<{ url: string; id: number; visible: boolean }>>(() =>
url ? [{ url, id: 0, visible: true }] : []
);
@@ -26,15 +62,10 @@ const FsBg = memo(function FsBg({ url }: { url: string }) {
useEffect(() => {
if (!url) return;
const id = counterRef.current++;
// Add the new layer (opacity 0)
setLayers(prev => [...prev, { url, id, visible: false }]);
// One frame later: make new layer visible (0→1) and old ones invisible (1→0)
const t1 = setTimeout(() => {
setLayers(prev =>
prev.map(l => ({ ...l, visible: l.id === id }))
);
setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id })));
}, 20);
// After transition: clean up old layers
const t2 = setTimeout(() => {
setLayers(prev => prev.filter(l => l.id === id));
}, 800);
@@ -55,13 +86,20 @@ const FsBg = memo(function FsBg({ url }: { url: string }) {
);
});
// ─── Isolated progress sub-component (re-renders every tick, nothing else does) ───
// ─── Progress bar (isolated — re-renders every tick) ──────────────────────────
const FsProgress = memo(function FsProgress({ duration }: { duration: number }) {
const progress = usePlayerStore(s => s.progress);
const buffered = usePlayerStore(s => s.buffered);
const currentTime = usePlayerStore(s => s.currentTime);
const seek = usePlayerStore(s => s.seek);
const handleSeek = useCallback((e: React.ChangeEvent<HTMLInputElement>) => seek(parseFloat(e.target.value)), [seek]);
const handleSeek = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => seek(parseFloat(e.target.value)),
[seek]
);
const pct = progress * 100;
const buf = Math.max(pct, buffered * 100);
return (
<div className="fs-progress-wrap">
@@ -71,7 +109,10 @@ const FsProgress = memo(function FsProgress({ duration }: { duration: number })
type="range" min={0} max={1} step={0.001}
value={progress}
onChange={handleSeek}
style={{ '--pct': `${progress * 100}%` } as React.CSSProperties}
style={{
'--pct': `${pct}%`,
'--buf': `${buf}%`,
} as React.CSSProperties}
aria-label="progress"
/>
</div>
@@ -80,44 +121,53 @@ const FsProgress = memo(function FsProgress({ duration }: { duration: number })
);
});
// ─── Isolated play/pause button (subscribes to isPlaying only) ───
// ─── Play/Pause button (isolated — subscribes to isPlaying only) ──────────────
const FsPlayBtn = memo(function FsPlayBtn() {
const { t } = useTranslation();
const isPlaying = usePlayerStore(s => s.isPlaying);
const togglePlay = usePlayerStore(s => s.togglePlay);
return (
<button className="fs-btn fs-btn-play" onClick={togglePlay} aria-label={isPlaying ? t('player.pause') : t('player.play')}>
{isPlaying ? <Pause size={36} /> : <Play size={36} fill="currentColor" />}
{isPlaying ? <Pause size={25} /> : <Play size={25} fill="currentColor" />}
</button>
);
});
// ─── Main component ────────────────────────────────────────────────────────────
interface FullscreenPlayerProps {
onClose: () => void;
}
export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
const { t } = useTranslation();
// Static/slow-changing state only — does NOT subscribe to progress or currentTime
const currentTrack = usePlayerStore(s => s.currentTrack);
const repeatMode = usePlayerStore(s => s.repeatMode);
const queue = usePlayerStore(s => s.queue);
const queueIndex = usePlayerStore(s => s.queueIndex);
const next = usePlayerStore(s => s.next);
const previous = usePlayerStore(s => s.previous);
const stop = usePlayerStore(s => s.stop);
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
const playTrack = usePlayerStore(s => s.playTrack);
const duration = currentTrack?.duration ?? 0;
const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '';
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
const duration = currentTrack?.duration ?? 0;
const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '';
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
// useCachedUrl must be called unconditionally (hook rules)
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey);
const upcoming = queue.slice(queueIndex + 1, queueIndex + 15);
// Close on Escape
// Fetch artist image for background — fall back to cover art if unavailable
const [artistBgUrl, setArtistBgUrl] = useState<string>('');
useEffect(() => {
setArtistBgUrl('');
const artistId = currentTrack?.artistId;
if (!artistId) return;
let cancelled = false;
getArtistInfo(artistId).then(info => {
if (!cancelled && info.largeImageUrl) setArtistBgUrl(info.largeImageUrl);
}).catch(() => {});
return () => { cancelled = true; };
}, [currentTrack?.artistId]);
const bgUrl = artistBgUrl || resolvedCoverUrl;
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
@@ -126,88 +176,77 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
return (
<div className="fs-player" role="dialog" aria-modal="true" aria-label={t('player.fullscreen')}>
{/* Crossfading blurred background */}
<FsBg url={resolvedCoverUrl} />
{/* Layer 1 — blurred artist image (falls back to cover art) */}
<FsBg url={bgUrl} />
<div className="fs-bg-overlay" aria-hidden="true" />
{/* Close button */}
<button className="fs-close" onClick={onClose} aria-label={t('player.closeFullscreen')} data-tooltip={t('player.closeTooltip')} data-tooltip-pos="bottom">
{/* Layer 2 — drifting color orbs */}
<div className="fs-orb fs-orb-1" aria-hidden="true" />
<div className="fs-orb fs-orb-2" aria-hidden="true" />
<div className="fs-orb fs-orb-3" aria-hidden="true" />
{/* Close */}
<button className="fs-close" onClick={onClose} aria-label={t('player.closeFullscreen')}>
<ChevronDown size={28} />
</button>
{/* Two-column layout: left = cover + info + controls, right = playlist */}
<div className="fs-layout">
{/* Center stage — everything vertically + horizontally centered */}
<div className="fs-stage">
{/* Left column */}
<div className="fs-left">
<div className="fs-cover-wrap">
{coverUrl ? (
<CachedImage src={coverUrl} cacheKey={coverKey} alt={`${currentTrack?.album} Cover`} className="fs-cover" />
) : (
<div className="fs-cover fs-cover-placeholder"><Music size={72} /></div>
)}
</div>
<p className="fs-artist">{currentTrack?.artist ?? '—'}</p>
<div className="fs-track-info">
<h1 className="fs-title">{currentTrack?.title ?? '—'}</h1>
<p className="fs-artist">{currentTrack?.artist ?? '—'}</p>
<p className="fs-album">{currentTrack?.album ?? '—'}{currentTrack?.year ? ` · ${currentTrack.year}` : ''}</p>
{(currentTrack?.bitRate || currentTrack?.suffix) && (
<span className="fs-codec">
{[currentTrack.suffix?.toUpperCase(), currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : ''].filter(Boolean).join(' · ')}
</span>
)}
</div>
<FsProgress duration={duration} />
<div className="fs-controls">
<button className="fs-btn fs-btn-sm" onClick={stop} data-tooltip="Stop">
<Square size={20} fill="currentColor" />
</button>
<button className="fs-btn" onClick={previous} aria-label={t('player.prev')}>
<SkipBack size={28} />
</button>
<FsPlayBtn />
<button className="fs-btn" onClick={next} aria-label={t('player.next')}>
<SkipForward size={28} />
</button>
<button
className={`fs-btn fs-btn-sm ${repeatMode !== 'off' ? 'active' : ''}`}
onClick={toggleRepeat}
data-tooltip={`${t('player.repeat')}: ${repeatMode === 'off' ? t('player.repeatOff') : repeatMode === 'all' ? t('player.repeatAll') : t('player.repeatOne')}`}
>
{repeatMode === 'one' ? <Repeat1 size={20} /> : <Repeat size={20} />}
</button>
</div>
<div className="fs-cover-wrap">
{coverUrl ? (
<CachedImage
src={coverUrl}
cacheKey={coverKey}
alt={`${currentTrack?.album} Cover`}
className="fs-cover"
/>
) : (
<div className="fs-cover fs-cover-placeholder"><Music size={72} /></div>
)}
</div>
<div className="fs-track-info">
<MarqueeTitle title={currentTrack?.title ?? '—'} />
<p className="fs-album">
{currentTrack?.album ?? ''}
{currentTrack?.year ? ` · ${currentTrack.year}` : ''}
</p>
{(currentTrack?.bitRate || currentTrack?.suffix) && (
<span className="fs-codec">
{[
currentTrack.suffix?.toUpperCase(),
currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : ''
].filter(Boolean).join(' · ')}
</span>
)}
</div>
<FsProgress duration={duration} />
<div className="fs-controls">
<button className="fs-btn fs-btn-sm" onClick={stop} aria-label="Stop">
<Square size={14} fill="currentColor" />
</button>
<button className="fs-btn" onClick={previous} aria-label={t('player.prev')}>
<SkipBack size={20} />
</button>
<FsPlayBtn />
<button className="fs-btn" onClick={next} aria-label={t('player.next')}>
<SkipForward size={20} />
</button>
<button
className={`fs-btn fs-btn-sm ${repeatMode !== 'off' ? 'active' : ''}`}
onClick={toggleRepeat}
aria-label={t('player.repeat')}
>
{repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />}
</button>
</div>
{/* Right column: upcoming tracks */}
{upcoming.length > 0 && (
<div className="fs-right">
<h2 className="fs-upcoming-title">{t('queue.nextTracks')}</h2>
<div className="fs-upcoming-list">
{upcoming.map((track, i) => (
<button
key={`${track.id}-${queueIndex + 1 + i}`}
className="fs-upcoming-item"
onClick={() => playTrack(track, queue)}
>
{track.coverArt ? (
<CachedImage src={buildCoverArtUrl(track.coverArt, 80)} cacheKey={coverArtCacheKey(track.coverArt, 80)} alt="" className="fs-upcoming-art" />
) : (
<div className="fs-upcoming-art fs-upcoming-placeholder"><Music size={14} /></div>
)}
<div className="fs-upcoming-info">
<span className="fs-upcoming-name">{track.title}</span>
<span className="fs-upcoming-artist">{track.artist}</span>
</div>
<span className="fs-upcoming-dur">{formatTime(track.duration)}</span>
</button>
))}
</div>
</div>
)}
</div>
</div>
);
+1 -1
View File
@@ -133,7 +133,7 @@ export default function Hero() {
const albumData = await getAlbum(album.id);
const tracks = albumData.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
usePlayerStore.getState().enqueue(tracks);
+7 -1
View File
@@ -69,6 +69,12 @@ export default function LiveSearch() {
value={query}
onChange={e => setQuery(e.target.value)}
onFocus={() => results && setOpen(true)}
onKeyDown={e => {
if (e.key === 'Enter' && query.trim()) {
setOpen(false);
navigate(`/search?q=${encodeURIComponent(query.trim())}`);
}
}}
aria-autocomplete="list"
aria-controls="search-results"
aria-expanded={open}
@@ -138,7 +144,7 @@ export default function LiveSearch() {
onClick={() => {
playTrack({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating
});
setOpen(false); setQuery('');
+21 -7
View File
@@ -6,6 +6,7 @@ import { usePlayerStore } from '../store/playerStore';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import CachedImage from './CachedImage';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -16,7 +17,8 @@ function formatTime(seconds: number): string {
export default function PlayerBar() {
const { t } = useTranslation();
const { currentTrack, isPlaying, progress, currentTime, volume, togglePlay, next, previous, seek, setVolume, isQueueVisible, toggleQueue, stop, toggleRepeat, repeatMode, toggleFullscreen } = usePlayerStore();
const navigate = useNavigate();
const { currentTrack, isPlaying, progress, buffered, currentTime, volume, togglePlay, next, previous, seek, setVolume, isQueueVisible, toggleQueue, stop, toggleRepeat, repeatMode, toggleFullscreen } = usePlayerStore();
const duration = currentTrack?.duration ?? 0;
const coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]);
@@ -30,8 +32,10 @@ export default function PlayerBar() {
setVolume(parseFloat(e.target.value));
}, [setVolume]);
const pct = progress * 100;
const buf = Math.max(pct, buffered * 100);
const progressStyle = {
background: `linear-gradient(to right, var(--ctp-mauve) ${progress * 100}%, var(--ctp-surface2) ${progress * 100}%)`,
background: `linear-gradient(to right, var(--ctp-mauve) ${pct}%, var(--ctp-overlay0) ${pct}%, var(--ctp-overlay0) ${buf}%, var(--ctp-surface2) ${buf}%)`,
};
const volumeStyle = {
@@ -66,10 +70,20 @@ export default function PlayerBar() {
)}
</div>
<div className="player-track-meta">
<div className="player-track-name" data-tooltip={currentTrack?.title ?? ''}>
<div
className="player-track-name"
data-tooltip={currentTrack?.title ?? ''}
style={{ cursor: currentTrack?.albumId ? 'pointer' : 'default' }}
onClick={() => currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
>
{currentTrack?.title ?? t('player.noTitle')}
</div>
<div className="player-track-artist" data-tooltip={currentTrack?.artist ?? ''}>
<div
className="player-track-artist"
data-tooltip={currentTrack?.artist ?? ''}
style={{ cursor: currentTrack?.artistId ? 'pointer' : 'default' }}
onClick={() => currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
>
{currentTrack?.artist ?? '—'}
</div>
</div>
@@ -83,7 +97,7 @@ export default function PlayerBar() {
</button>
<button className="player-btn" onClick={previous} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
<SkipBack size={18} />
<SkipBack size={20} />
</button>
<button
@@ -92,11 +106,11 @@ export default function PlayerBar() {
aria-label={isPlaying ? t('player.pause') : t('player.play')}
data-tooltip={isPlaying ? t('player.pause') : t('player.play')}
>
{isPlaying ? <Pause size={20} /> : <Play size={20} fill="currentColor" />}
{isPlaying ? <Pause size={22} /> : <Play size={22} fill="currentColor" />}
</button>
<button className="player-btn" onClick={next} aria-label={t('player.next')} data-tooltip={t('player.next')}>
<SkipForward size={18} />
<SkipForward size={20} />
</button>
<button
+89 -51
View File
@@ -1,9 +1,10 @@
import React, { useState } from 'react';
import React, { useState, useRef } from 'react';
import { Track, usePlayerStore } from '../store/playerStore';
import { Play, Music, Star, X, Trash2, Save, FolderOpen } from 'lucide-react';
import { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -111,6 +112,7 @@ function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void, onLoad: (
export default function QueuePanel() {
const { t } = useTranslation();
const navigate = useNavigate();
const queue = usePlayerStore(s => s.queue);
const queueIndex = usePlayerStore(s => s.queueIndex);
const currentTrack = usePlayerStore(s => s.currentTrack);
@@ -124,6 +126,11 @@ export default function QueuePanel() {
const [draggedIdx, setDraggedIdx] = useState<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
const isDraggingInternalRef = useRef(false);
// Refs mirror state so drop handler always reads fresh values even when
// macOS WKWebView fires dragend before drop (spec violation).
const draggedIdxRef = useRef<number | null>(null);
const dragOverIdxRef = useRef<number | null>(null);
const [saveModalOpen, setSaveModalOpen] = useState(false);
const [loadModalOpen, setLoadModalOpen] = useState(false);
@@ -142,69 +149,93 @@ export default function QueuePanel() {
};
const onDragStart = (e: React.DragEvent, index: number) => {
isDraggingInternalRef.current = true;
draggedIdxRef.current = index;
setDraggedIdx(index);
e.dataTransfer.effectAllowed = 'copyMove';
e.dataTransfer.setData('application/json', JSON.stringify({
type: 'queue_reorder',
index
}));
e.dataTransfer.effectAllowed = 'move';
// Store index in dataTransfer too — on macOS WKWebView dragend fires before
// drop, so the ref will already be null; dataTransfer survives that race.
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'queue_reorder', index }));
};
const onDragEnterItem = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy';
};
const onDragOverItem = (e: React.DragEvent, index: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy';
dragOverIdxRef.current = index;
setDragOverIdx(index);
};
const onDragEnd = () => {
// Reset visual state immediately.
setDraggedIdx(null);
setDragOverIdx(null);
// Delay clearing refs so onDropQueue can read them first.
// On macOS WKWebView and Windows WebView2, dragend fires before drop
// (spec violation), so synchronously clearing refs here loses the
// drag source/destination indices before onDropQueue runs.
setTimeout(() => {
isDraggingInternalRef.current = false;
draggedIdxRef.current = null;
dragOverIdxRef.current = null;
}, 200);
};
const onDropQueue = async (e: React.DragEvent, dropIndex?: number) => {
const onDropQueue = async (e: React.DragEvent) => {
e.preventDefault();
// Refs are still valid here — onDragEnd delays clearing them so they survive
// the macOS/WebView2 dragend-before-drop race condition.
const fromIdx = draggedIdxRef.current;
const toIdx = dragOverIdxRef.current ?? queue.length;
// Cancel the pending timeout cleanup and clear refs immediately.
isDraggingInternalRef.current = false;
draggedIdxRef.current = null;
dragOverIdxRef.current = null;
setDraggedIdx(null);
setDragOverIdx(null);
// Read dataTransfer — set during dragstart, survives dragend on all platforms.
// Used as fallback for fromIdx in case refs somehow weren't set.
let parsedData: any = null;
try {
const dataStr = e.dataTransfer.getData('application/json');
if (!dataStr) return;
const data = JSON.parse(dataStr);
if (data.type === 'queue_reorder') {
const fromIdx = data.index;
const targetIdx = dropIndex !== undefined ? dropIndex : queue.length;
if (fromIdx !== undefined && fromIdx !== targetIdx) {
reorderQueue(fromIdx, targetIdx);
}
} else if (data.type === 'song') {
const track = data.track;
if (dropIndex !== undefined) {
// If dropped on a specific item, we might want to insert it there.
// For now we just enqueue it at the end to keep it simple, or insert it.
// Since we don't have an insert method, we use enqueue (appends to end).
enqueue([track]);
} else {
enqueue([track]);
}
} else if (data.type === 'album') {
const albumData = await getAlbum(data.id);
const tracks: Track[] = albumData.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
enqueue(tracks);
}
} catch (err) {
console.error('Drop error', err);
const raw = e.dataTransfer.getData('text/plain');
if (raw) parsedData = JSON.parse(raw);
} catch { /* ignore */ }
// Internal reorder: refs are the primary source; dataTransfer is the fallback.
const reorderFrom = fromIdx ?? (parsedData?.type === 'queue_reorder' ? parsedData.index : null);
if (reorderFrom !== null) {
if (reorderFrom !== toIdx) reorderQueue(reorderFrom, toIdx);
return;
}
// External drop (song / album dragged from elsewhere in the app)
if (!parsedData) return;
if (parsedData.type === 'song') {
enqueue([parsedData.track]);
} else if (parsedData.type === 'album') {
const albumData = await getAlbum(parsedData.id);
const tracks: Track[] = albumData.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
enqueue(tracks);
}
};
return (
<aside
className="queue-panel"
onDragOver={e => e.preventDefault()}
onDrop={e => onDropQueue(e)}
<aside
className="queue-panel"
onDragEnter={e => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; }}
onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; }}
onDrop={onDropQueue}
style={{
borderLeftWidth: isQueueVisible ? 1 : 0
}}
@@ -233,15 +264,25 @@ export default function QueuePanel() {
<div className="queue-current-track">
<div className="queue-current-cover">
{currentTrack.coverArt ? (
<img src={buildCoverArtUrl(currentTrack.coverArt, 400)} alt="" loading="eager" />
<img src={buildCoverArtUrl(currentTrack.coverArt, 128)} alt="" loading="eager" />
) : (
<div className="fallback"><Music size={32} /></div>
)}
</div>
<div className="queue-current-info">
<h3 className="truncate" data-tooltip={currentTrack.title}>{currentTrack.title}</h3>
<h3
className="truncate"
data-tooltip={currentTrack.title}
style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }}
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
>{currentTrack.title}</h3>
{currentTrack.year && <div className="queue-current-sub">{currentTrack.year}</div>}
<div className="queue-current-sub truncate" data-tooltip={currentTrack.album}>{currentTrack.album}</div>
<div
className="queue-current-sub truncate"
data-tooltip={currentTrack.album}
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
>{currentTrack.album}</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '6px' }}>
<div className="queue-current-tech">
@@ -293,12 +334,9 @@ export default function QueuePanel() {
}}
draggable
onDragStart={(e) => onDragStart(e, idx)}
onDragEnter={(e) => onDragEnterItem(e)}
onDragOver={(e) => onDragOverItem(e, idx)}
onDragEnd={onDragEnd}
onDrop={(e) => {
e.stopPropagation();
onDropQueue(e, idx);
}}
style={dragStyle}
>
<div className="queue-item-info">
@@ -339,7 +377,7 @@ export default function QueuePanel() {
const data = await getPlaylist(id);
const tracks: Track[] = data.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
if (tracks.length > 0) {
+67 -7
View File
@@ -1,9 +1,10 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { version as appVersion } from '../../package.json';
import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle, ListMusic,
PanelLeftClose, PanelLeft, HelpCircle, Dices
PanelLeftClose, PanelLeft, HelpCircle, Dices, ArrowUpCircle
} from 'lucide-react';
const PsysonicLogo = () => (
@@ -21,14 +22,72 @@ const navItems = [
{ icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites' },
];
export default function Sidebar({
isCollapsed = false,
toggleCollapse
}: {
function isNewer(latest: string, current: string): boolean {
const parse = (v: string) => v.replace(/^[^0-9]*/, '').split('.').map(Number);
const [lMaj, lMin, lPat] = parse(latest);
const [cMaj, cMin, cPat] = parse(current);
if (lMaj !== cMaj) return lMaj > cMaj;
if (lMin !== cMin) return lMin > cMin;
return lPat > cPat;
}
function UpdateToast({ isCollapsed, latestVersion }: { isCollapsed: boolean; latestVersion: string }) {
const { t } = useTranslation();
if (isCollapsed) {
return (
<div className="update-toast-icon" style={{ marginTop: 'auto' }} title={`${t('sidebar.updateAvailable')}: ${latestVersion}`}>
<ArrowUpCircle size={20} />
</div>
);
}
return (
<div className="update-toast">
<div className="update-toast-header">
<ArrowUpCircle size={14} />
<span className="update-toast-label">{t('sidebar.updateAvailable')}</span>
</div>
<div className="update-toast-version">{t('sidebar.updateReady', { version: latestVersion })}</div>
<a
className="update-toast-link"
href="https://github.com/Psychotoxical/psysonic/releases"
target="_blank"
rel="noopener noreferrer"
>
{t('sidebar.updateLink')}
</a>
</div>
);
}
export default function Sidebar({
isCollapsed = false,
toggleCollapse
}: {
isCollapsed?: boolean;
toggleCollapse?: () => void;
}) {
const { t } = useTranslation();
const [latestVersion, setLatestVersion] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const timer = setTimeout(async () => {
try {
const res = await fetch('https://api.github.com/repos/Psychotoxical/psysonic/releases/latest');
if (!res.ok) return;
const data = await res.json();
const tag: string = data.tag_name ?? '';
if (!cancelled && tag && isNewer(tag, appVersion)) {
setLatestVersion(tag.startsWith('v') ? tag : `v${tag}`);
}
} catch {
// network unavailable — silently skip
}
}, 1500);
return () => { cancelled = true; clearTimeout(timer); };
}, []);
return (
<aside className={`sidebar animate-slide-in ${isCollapsed ? 'collapsed' : ''}`}>
@@ -61,10 +120,11 @@ export default function Sidebar({
))}
{!isCollapsed && <span className="nav-section-label" style={{ marginTop: 'auto' }}>{t('sidebar.system')}</span>}
{latestVersion && <UpdateToast isCollapsed={isCollapsed} latestVersion={latestVersion} />}
<NavLink
to="/statistics"
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
style={isCollapsed ? { marginTop: 'auto' } : undefined}
style={isCollapsed && !latestVersion ? { marginTop: 'auto' } : undefined}
title={isCollapsed ? t('sidebar.statistics') : undefined}
>
<BarChart3 size={isCollapsed ? 22 : 18} />
+21 -5
View File
@@ -18,7 +18,10 @@ const enTranslation = {
settings: 'Settings',
help: 'Help',
expand: 'Expand Sidebar',
collapse: 'Collapse Sidebar'
collapse: 'Collapse Sidebar',
updateAvailable: 'Update available',
updateReady: '{{version}} is ready',
updateLink: 'Go to release →'
},
home: {
starred: 'Personal Favorites',
@@ -41,6 +44,9 @@ const enTranslation = {
albums: 'Albums',
songs: 'Songs',
clearLabel: 'Clear search',
title: 'Search',
resultsFor: 'Results for "{{query}}"',
album: 'Album',
},
nowPlaying: {
tooltip: 'Who is listening?',
@@ -132,6 +138,7 @@ const enTranslation = {
remixTooltip: 'Load new random songs',
playAll: 'Play All',
trackTitle: 'Title',
trackArtist: 'Artist',
trackAlbum: 'Album',
trackFavorite: 'Favorite',
trackDuration: 'Duration',
@@ -254,7 +261,8 @@ const enTranslation = {
aboutLicenseText: 'MIT — free to use, modify, and distribute.',
aboutRepo: 'Source Code on GitHub',
aboutVersion: 'Version',
aboutBuiltWith: 'Built with Tauri · React · TypeScript · Howler.js'
aboutBuiltWith: 'Built with Tauri · React · TypeScript · Howler.js',
aboutAiCredit: 'Developed with the support of Claude Code by Anthropic'
},
help: {
title: 'Help',
@@ -371,7 +379,10 @@ const deTranslation = {
settings: 'Einstellungen',
help: 'Hilfe',
expand: 'Sidebar einblenden',
collapse: 'Sidebar ausblenden'
collapse: 'Sidebar ausblenden',
updateAvailable: 'Update verfügbar',
updateReady: '{{version}} ist bereit',
updateLink: 'Zum Release →'
},
home: {
starred: 'Persönliche Favoriten',
@@ -394,6 +405,9 @@ const deTranslation = {
albums: 'Alben',
songs: 'Songs',
clearLabel: 'Suche leeren',
title: 'Suche',
resultsFor: 'Ergebnisse für „{{query}}"',
album: 'Album',
},
nowPlaying: {
tooltip: 'Wer hört was?',
@@ -485,6 +499,7 @@ const deTranslation = {
remixTooltip: 'Neue Songs laden',
playAll: 'Alle abspielen',
trackTitle: 'Titel',
trackArtist: 'Künstler',
trackAlbum: 'Album',
trackFavorite: 'Favorit',
trackDuration: 'Dauer',
@@ -604,10 +619,11 @@ const deTranslation = {
aboutDesc: 'Ein Desktop-Musikplayer für Subsonic-kompatible Server (Navidrome, Gonic u. a.). Streame deine selbst gehostete Musikbibliothek mit einer modernen Oberfläche im Catppuccin-Design.',
aboutFeatures: 'Multi-Server · Scrobbling · Vollbild-Player · Album-Downloads · Bild-Cache · Catppuccin-Themes',
aboutLicense: 'Lizenz',
aboutLicenseText: 'MIT — kostenlos nutzbar, veränderbar und weitergabbar.',
aboutLicenseText: 'MIT — kostenlos nutzbar, veränderbar und weiterzugeben.',
aboutRepo: 'Quellcode auf GitHub',
aboutVersion: 'Version',
aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Howler.js'
aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Howler.js',
aboutAiCredit: 'Mit freundlicher Unterstützung von Claude Code by Anthropic'
},
help: {
title: 'Hilfe',
+31 -19
View File
@@ -31,11 +31,10 @@ function formatSize(bytes?: number): string {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function codecLabel(song: { suffix?: string; bitRate?: number; samplingRate?: number }): string {
function codecLabel(song: { suffix?: string; bitRate?: number }): string {
const parts: string[] = [];
if (song.suffix) parts.push(song.suffix.toUpperCase());
if (song.bitRate) parts.push(`${song.bitRate} kbps`);
if (song.samplingRate) parts.push(`${(song.samplingRate / 1000).toFixed(1)} kHz`);
return parts.join(' · ');
}
@@ -101,6 +100,7 @@ export default function AlbumDetail() {
const playTrack = usePlayerStore(s => s.playTrack);
const enqueue = usePlayerStore(s => s.enqueue);
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const currentTrack = usePlayerStore(s => s.currentTrack);
const [album, setAlbum] = useState<Awaited<ReturnType<typeof getAlbum>> | null>(null);
const [relatedAlbums, setRelatedAlbums] = useState<SubsonicAlbum[]>([]);
const [ratings, setRatings] = useState<Record<string, number>>({});
@@ -110,6 +110,7 @@ export default function AlbumDetail() {
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
const [isStarred, setIsStarred] = useState(false);
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
const [hoveredSongId, setHoveredSongId] = useState<string | null>(null);
useEffect(() => {
if (!id) return;
@@ -135,7 +136,7 @@ export default function AlbumDetail() {
if (!album) return;
const tracks = album.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
if (tracks[0]) playTrack(tracks[0], tracks);
@@ -145,7 +146,7 @@ export default function AlbumDetail() {
if (!album) return;
const tracks = album.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
enqueue(tracks);
@@ -154,7 +155,7 @@ export default function AlbumDetail() {
const handlePlaySong = (song: SubsonicSong) => {
const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, duration: song.duration, coverArt: song.coverArt,
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt,
track: song.track, year: song.year, bitRate: song.bitRate,
suffix: song.suffix, userRating: song.userRating
};
@@ -377,10 +378,10 @@ export default function AlbumDetail() {
<div style={{ textAlign: 'center' }}>#</div>
<div>{t('albumDetail.trackTitle')}</div>
{hasVariousArtists && <div>{t('albumDetail.trackArtist')}</div>}
<div>{t('albumDetail.trackFormat')}</div>
<div style={{ textAlign: 'center' }}>{t('albumDetail.trackFavorite')}</div>
<div>{t('albumDetail.trackRating')}</div>
<div style={{ textAlign: 'right' }}>{t('albumDetail.trackDuration')}</div>
<div>{t('albumDetail.trackFormat')}</div>
</div>
{(() => {
@@ -404,13 +405,15 @@ export default function AlbumDetail() {
{discs.get(discNum)!.map((song, i) => (
<div
key={song.id}
className={`track-row${hasVariousArtists ? ' track-row-va' : ''}`}
className={`track-row${hasVariousArtists ? ' track-row-va' : ''}${currentTrack?.id === song.id ? ' active' : ''}`}
onMouseEnter={() => setHoveredSongId(song.id)}
onMouseLeave={() => setHoveredSongId(null)}
onDoubleClick={() => handlePlaySong(song)}
onContextMenu={(e) => {
e.preventDefault();
const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, duration: song.duration, coverArt: song.coverArt, track: song.track,
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track,
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
};
openContextMenu(e.clientX, e.clientY, track, 'album-song');
@@ -421,13 +424,23 @@ export default function AlbumDetail() {
e.dataTransfer.effectAllowed = 'copy';
const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, duration: song.duration, coverArt: song.coverArt, track: song.track,
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track,
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
};
e.dataTransfer.setData('application/json', JSON.stringify({ type: 'song', track }));
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
}}
>
<div className="track-num" style={{ textAlign: 'center' }}>{song.track ?? i + 1}</div>
<div
className="track-num"
style={{ textAlign: 'center', cursor: hoveredSongId === song.id ? 'pointer' : 'default', color: (hoveredSongId === song.id || currentTrack?.id === song.id) ? 'var(--accent)' : undefined }}
onClick={() => handlePlaySong(song)}
>
{hoveredSongId === song.id
? <Play size={13} fill="currentColor" />
: currentTrack?.id === song.id
? <Play size={13} fill="currentColor" />
: (song.track ?? i + 1)}
</div>
<div className="track-info">
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
</div>
@@ -436,14 +449,6 @@ export default function AlbumDetail() {
<span className="track-artist">{song.artist}</span>
</div>
)}
<div className="track-meta" style={{ display: 'flex', alignItems: 'center' }}>
{(song.suffix || song.bitRate) && (
<span className="track-codec" style={{ marginTop: 0 }}>
{codecLabel(song)}
{song.size ? <span className="track-size"> · {formatSize(song.size)}</span> : null}
</span>
)}
</div>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<button
className="btn btn-ghost"
@@ -461,6 +466,13 @@ export default function AlbumDetail() {
<div className="track-duration" style={{ textAlign: 'right' }}>
{formatDuration(song.duration)}
</div>
<div className="track-meta" style={{ display: 'flex', alignItems: 'center' }}>
{(song.suffix || song.bitRate) && (
<span className="track-codec" style={{ marginTop: 0 }}>
{codecLabel(song)}
</span>
)}
</div>
</div>
))}
</div>
+1 -1
View File
@@ -258,7 +258,7 @@ export default function ArtistDetail() {
e.preventDefault();
const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, duration: song.duration, coverArt: song.coverArt, track: song.track,
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track,
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
};
openContextMenu(e.clientX, e.clientY, track, 'song');
+2 -2
View File
@@ -72,10 +72,10 @@ export default function Favorites() {
e.dataTransfer.effectAllowed = 'copy';
const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, duration: song.duration, coverArt: song.coverArt, track: song.track,
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track,
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
};
e.dataTransfer.setData('application/json', JSON.stringify({ type: 'song', track }));
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
}}
>
<button
+1 -1
View File
@@ -34,7 +34,7 @@ export default function Playlists() {
const data = await getPlaylist(id);
const tracks = data.songs.map((s: any) => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
if (tracks.length > 0) {
+11 -7
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import { getRandomSongs, SubsonicSong, star, unstar } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { Play, HandMetal, RefreshCw } from 'lucide-react';
import { Play, Star, RefreshCw } from 'lucide-react';
import { useTranslation } from 'react-i18next';
function formatDuration(seconds: number): string {
@@ -78,9 +78,10 @@ export default function RandomMix() {
</div>
) : (
<div className="tracklist">
<div className="tracklist-header" style={{ gridTemplateColumns: '36px 1fr 1fr 60px 80px' }}>
<div className="tracklist-header" style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 60px 80px' }}>
<span></span>
<span>{t('randomMix.trackTitle')}</span>
<span>{t('randomMix.trackArtist')}</span>
<span>{t('randomMix.trackAlbum')}</span>
<span style={{ textAlign: 'center' }}>{t('randomMix.trackFavorite')}</span>
<span style={{ textAlign: 'right' }}>{t('randomMix.trackDuration')}</span>
@@ -90,7 +91,7 @@ export default function RandomMix() {
<div
key={song.id}
className="track-row"
style={{ gridTemplateColumns: '36px 1fr 1fr 60px 80px' }}
style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 60px 80px' }}
onDoubleClick={() => playTrack(song, songs)}
role="row"
draggable
@@ -98,10 +99,10 @@ export default function RandomMix() {
e.dataTransfer.effectAllowed = 'copy';
const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, duration: song.duration, coverArt: song.coverArt, track: song.track,
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track,
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
};
e.dataTransfer.setData('application/json', JSON.stringify({ type: 'song', track }));
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
}}
>
<button
@@ -115,7 +116,10 @@ export default function RandomMix() {
<div className="track-info">
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
<span className="track-artist">{song.artist}</span>
</div>
<div className="track-artist-cell">
<span className="track-artist" data-tooltip={song.artist}>{song.artist}</span>
</div>
<div className="track-info">
@@ -129,7 +133,7 @@ export default function RandomMix() {
data-tooltip={starredSongs.has(song.id) ? t('randomMix.favoriteRemove') : t('randomMix.favoriteAdd')}
style={{ padding: '4px', height: 'auto', minHeight: 'unset', color: starredSongs.has(song.id) ? 'var(--accent)' : 'var(--text-muted)' }}
>
<HandMetal size={14} fill={starredSongs.has(song.id) ? "currentColor" : "none"} />
<Star size={14} fill={starredSongs.has(song.id) ? "currentColor" : "none"} />
</button>
</div>
+134
View File
@@ -0,0 +1,134 @@
import React, { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Play, Search } from 'lucide-react';
import { search, SearchResults as ISearchResults, SubsonicSong } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import AlbumRow from '../components/AlbumRow';
import ArtistRow from '../components/ArtistRow';
import { useTranslation } from 'react-i18next';
function formatDuration(s: number) {
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`;
}
export default function SearchResults() {
const { t } = useTranslation();
const [params] = useSearchParams();
const query = params.get('q') ?? '';
const [results, setResults] = useState<ISearchResults | null>(null);
const [loading, setLoading] = useState(false);
const playTrack = usePlayerStore(s => s.playTrack);
const currentTrack = usePlayerStore(s => s.currentTrack);
useEffect(() => {
if (!query.trim()) { setResults(null); return; }
setLoading(true);
search(query, { artistCount: 20, albumCount: 20, songCount: 50 })
.then(r => setResults(r))
.finally(() => setLoading(false));
}, [query]);
const hasResults = results && (results.artists.length || results.albums.length || results.songs.length);
const playSong = (song: SubsonicSong, list: SubsonicSong[]) => {
playTrack({
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration,
coverArt: song.coverArt, year: song.year, bitRate: song.bitRate,
suffix: song.suffix, userRating: song.userRating,
}, list.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration,
coverArt: s.coverArt, year: s.year, bitRate: s.bitRate,
suffix: s.suffix, userRating: s.userRating,
})));
};
return (
<div className="content-body animate-fade-in" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
<div style={{ marginBottom: '-1.5rem' }}>
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<Search size={22} />
{query ? t('search.resultsFor', { query }) : t('search.title')}
</h1>
</div>
{loading && (
<div className="loading-center"><div className="spinner" /></div>
)}
{!loading && query && !hasResults && (
<div className="empty-state">{t('search.noResults', { query })}</div>
)}
{!loading && results && (
<>
{results.artists.length > 0 && (
<ArtistRow title={t('search.artists')} artists={results.artists} />
)}
{results.albums.length > 0 && (
<AlbumRow title={t('search.albums')} albums={results.albums} />
)}
{results.songs.length > 0 && (
<section className="album-row-section">
<div className="album-row-header" style={{ marginBottom: '1rem' }}>
<h2 className="section-title" style={{ marginBottom: 0 }}>{t('search.songs')}</h2>
</div>
<div className="tracklist" style={{ padding: 0 }}>
<div className="tracklist-header" style={{ gridTemplateColumns: '36px minmax(100px, 2fr) minmax(80px, 1.2fr) minmax(80px, 1.2fr) 100px 60px' }}>
<div />
<div>{t('albumDetail.trackTitle')}</div>
<div>{t('albumDetail.trackArtist')}</div>
<div>{t('search.album')}</div>
<div>{t('albumDetail.trackFormat')}</div>
<div style={{ textAlign: 'right' }}>{t('albumDetail.trackDuration')}</div>
</div>
{results.songs.map(song => (
<div
key={song.id}
className={`track-row${currentTrack?.id === song.id ? ' active' : ''}`}
style={{ gridTemplateColumns: '36px minmax(100px, 2fr) minmax(80px, 1.2fr) minmax(80px, 1.2fr) 100px 60px' }}
onDoubleClick={() => playSong(song, results.songs)}
role="row"
draggable
onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy';
const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration,
coverArt: song.coverArt, year: song.year, bitRate: song.bitRate,
suffix: song.suffix, userRating: song.userRating,
};
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
}}
>
<button
className="btn btn-ghost"
style={{ padding: 4 }}
onClick={e => { e.stopPropagation(); playSong(song, results.songs); }}
>
<Play size={14} fill="currentColor" />
</button>
<div className="track-info">
<span className="track-title" title={song.title}>{song.title}</span>
</div>
<div className="track-artist-cell"><span className="track-artist" title={song.artist}>{song.artist}</span></div>
<div className="track-artist-cell"><span className="track-artist" title={song.album}>{song.album}</span></div>
<span className="track-codec" style={{ alignSelf: 'center' }}>
{[song.suffix?.toUpperCase(), song.bitRate ? `${song.bitRate} kbps` : ''].filter(Boolean).join(' · ')}
</span>
<span className="track-duration" style={{ textAlign: 'right' }}>
{formatDuration(song.duration)}
</span>
</div>
))}
</div>
</section>
)}
</>
)}
</div>
);
}
+13 -3
View File
@@ -179,8 +179,14 @@ export default function Settings() {
onChange={(e) => theme.setTheme(e.target.value as any)}
aria-label={t('settings.theme')}
>
<option value="mocha">Catppuccin Mocha (Dark)</option>
<option value="latte">Catppuccin Latte (Light)</option>
<option value="mocha">Catppuccin Mocha</option>
<option value="macchiato">Catppuccin Macchiato</option>
<option value="frappe">Catppuccin Frappé</option>
<option value="latte">Catppuccin Latte</option>
<option value="nord">Nord · Polar Night</option>
<option value="nord-snowstorm">Nord · Snowstorm</option>
<option value="nord-frost">Nord · Frost</option>
<option value="nord-aurora">Nord · Aurora</option>
</select>
</div>
</div>
@@ -368,7 +374,7 @@ export default function Settings() {
Psysonic
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
{t('settings.aboutVersion')} 1.0.5
{t('settings.aboutVersion')} 1.0.11
</div>
</div>
</div>
@@ -391,6 +397,10 @@ export default function Settings() {
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>Stack</span>
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutBuiltWith')}</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>AI</span>
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutAiCredit')}</span>
</div>
</div>
<button
+151 -70
View File
@@ -10,6 +10,7 @@ export interface Track {
artist: string;
album: string;
albumId: string;
artistId?: string;
duration: number;
coverArt?: string;
track?: number;
@@ -25,10 +26,10 @@ interface PlayerState {
queueIndex: number;
isPlaying: boolean;
progress: number; // 01
buffered: number; // 01
currentTime: number;
volume: number;
howl: Howl | null;
prefetched: Map<string, Howl>;
scrobbled: boolean;
// Actions
@@ -44,8 +45,7 @@ interface PlayerState {
setProgress: (t: number, duration: number) => void;
enqueue: (tracks: Track[]) => void;
clearQueue: () => void;
prefetchUpcoming: (fromIndex: number, queue: Track[]) => void;
isQueueVisible: boolean;
toggleQueue: () => void;
@@ -74,6 +74,13 @@ interface PlayerState {
}
let progressInterval: ReturnType<typeof setInterval> | null = null;
let seekDebounce: ReturnType<typeof setTimeout> | null = null;
let gstSeeking = false; // true while GStreamer is processing a seek
let pendingSeekTime: number | null = null; // queue at most one seek
let gstSeekWatchdog: ReturnType<typeof setTimeout> | null = null; // safety release if onseek never fires
let hangRecoveryPos: number | null = null; // set before a recovery playTrack call so onplay seeks here
let hangLastTime = -1; // last observed currentTime for hang detection
let hangStallTime = 0; // Date.now() when currentTime last moved
function clearProgress() {
if (progressInterval) {
@@ -82,6 +89,18 @@ function clearProgress() {
}
}
function armGstWatchdog(cb: () => void) {
if (gstSeekWatchdog) clearTimeout(gstSeekWatchdog);
gstSeekWatchdog = setTimeout(() => {
gstSeekWatchdog = null;
cb();
}, 2000);
}
function disarmGstWatchdog() {
if (gstSeekWatchdog) { clearTimeout(gstSeekWatchdog); gstSeekWatchdog = null; }
}
// Helper to debounce or fire queue syncs
let syncTimeout: ReturnType<typeof setTimeout> | null = null;
function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTime: number) {
@@ -105,10 +124,10 @@ export const usePlayerStore = create<PlayerState>()(
queueIndex: 0,
isPlaying: false,
progress: 0,
buffered: 0,
currentTime: 0,
volume: 0.8,
howl: null,
prefetched: new Map(),
scrobbled: false,
isQueueVisible: true,
isFullscreenOpen: false,
@@ -135,7 +154,7 @@ export const usePlayerStore = create<PlayerState>()(
get().howl?.stop();
get().howl?.seek(0);
clearProgress();
set({ isPlaying: false, progress: 0, currentTime: 0 });
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
},
playTrack: (track, queue) => {
@@ -143,60 +162,116 @@ export const usePlayerStore = create<PlayerState>()(
// Stop current
state.howl?.unload();
clearProgress();
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
disarmGstWatchdog();
gstSeeking = false;
pendingSeekTime = null;
hangLastTime = -1;
hangStallTime = 0;
const newQueue = queue ?? state.queue;
const idx = newQueue.findIndex(t => t.id === track.id);
const url = buildStreamUrl(track.id);
const howl = new Howl({
src: [url],
html5: true,
volume: state.volume,
onplay: () => {
set({ isPlaying: true });
// Subsonic / Navidrome Now Playing
reportNowPlaying(track.id);
const howl = new Howl({ src: [buildStreamUrl(track.id)], html5: true, volume: state.volume });
set({ scrobbled: false });
progressInterval = setInterval(() => {
const h = get().howl;
if (!h) return;
const cur = typeof h.seek() === 'number' ? h.seek() as number : 0;
const dur = h.duration() || 1;
const prog = cur / dur;
set({ currentTime: cur, progress: prog });
howl.on('play', () => {
set({ isPlaying: true });
reportNowPlaying(track.id);
// Scrobble at 50%
if (prog >= 0.5 && !get().scrobbled) {
set({ scrobbled: true });
const { scrobblingEnabled } = useAuthStore.getState();
if (scrobblingEnabled) {
scrobbleSong(track.id, Date.now());
}
// If recovering from a pipeline hang, seek to the saved position
if (hangRecoveryPos !== null) {
const pos = hangRecoveryPos;
hangRecoveryPos = null;
gstSeeking = true;
armGstWatchdog(() => { gstSeeking = false; pendingSeekTime = null; });
setTimeout(() => { howl.seek(pos); }, 50);
}
set({ scrobbled: false });
hangStallTime = Date.now();
hangLastTime = -1;
progressInterval = setInterval(() => {
const h = get().howl;
if (!h) return;
const s = h.seek();
const cur = typeof s === 'number' ? s : 0;
const dur = h.duration() || 1;
const prog = cur / dur;
// Read buffered ranges from the underlying <audio> element
const audioNode = (h as any)._sounds?.[0]?._node as HTMLAudioElement | undefined;
if (audioNode?.buffered && audioNode.duration > 0) {
let totalBuf = 0;
for (let i = 0; i < audioNode.buffered.length; i++) {
totalBuf += audioNode.buffered.end(i) - audioNode.buffered.start(i);
}
}, 500);
// Prefetch next 3
get().prefetchUpcoming(idx + 1, newQueue);
},
onend: () => {
clearProgress();
set({ isPlaying: false, progress: 0, currentTime: 0 });
const { repeatMode, currentTrack, queue } = get();
if (repeatMode === 'one' && currentTrack) {
get().playTrack(currentTrack, queue);
set({ currentTime: cur, progress: prog, buffered: Math.min(1, totalBuf / audioNode.duration) });
} else {
get().next();
set({ currentTime: cur, progress: prog });
}
},
onstop: () => {
clearProgress();
set({ isPlaying: false });
},
// Hang detection: if playing but currentTime hasn't moved in 5s, recover
if (Math.abs(cur - hangLastTime) > 0.05) {
hangLastTime = cur;
hangStallTime = Date.now();
} else if (get().isPlaying && Date.now() - hangStallTime > 5000) {
const { currentTrack: ct, queue: q } = get();
if (ct) {
hangRecoveryPos = cur;
hangStallTime = Date.now(); // prevent re-trigger while recovering
get().playTrack(ct, q);
}
return;
}
// Scrobble at 50%
if (prog >= 0.5 && !get().scrobbled) {
set({ scrobbled: true });
const { scrobblingEnabled } = useAuthStore.getState();
if (scrobblingEnabled) scrobbleSong(track.id, Date.now());
}
}, 500);
});
howl.play();
set({ currentTrack: track, queue: newQueue, queueIndex: idx >= 0 ? idx : 0, howl, progress: 0, currentTime: 0 });
howl.on('end', () => {
clearProgress();
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
const { repeatMode, currentTrack, queue } = get();
if (repeatMode === 'one' && currentTrack) {
get().playTrack(currentTrack, queue);
} else {
get().next();
}
});
howl.on('stop', () => {
clearProgress();
set({ isPlaying: false });
});
howl.on('seek', () => {
disarmGstWatchdog();
gstSeeking = false;
hangLastTime = -1;
hangStallTime = Date.now();
if (pendingSeekTime !== null) {
const t = pendingSeekTime;
pendingSeekTime = null;
gstSeeking = true;
armGstWatchdog(() => {
gstSeeking = false;
pendingSeekTime = null;
const { currentTrack: ct, queue: q } = get();
if (ct) { hangRecoveryPos = t; get().playTrack(ct, q); }
});
get().howl?.seek(t);
}
});
howl.play(); // for gapless: resumes from paused state, onplay fires and seeks to 0 via hangRecoveryPos
set({ currentTrack: track, queue: newQueue, queueIndex: idx >= 0 ? idx : 0, howl, progress: 0, buffered: 0, currentTime: 0 });
syncQueueToServer(newQueue, track, 0);
},
@@ -207,8 +282,14 @@ export const usePlayerStore = create<PlayerState>()(
},
resume: () => {
const { howl, currentTrack } = get();
if (!howl || !currentTrack) return;
const { howl, currentTrack, queue, currentTime } = get();
if (!currentTrack) return;
if (!howl) {
// Cold start from restored state (e.g. app relaunch) — resume from saved position
if (currentTime > 0) hangRecoveryPos = currentTime;
get().playTrack(currentTrack, queue);
return;
}
howl.play();
set({ isPlaying: true });
},
@@ -243,8 +324,25 @@ export const usePlayerStore = create<PlayerState>()(
const { howl, currentTrack } = get();
if (!howl || !currentTrack) return;
const time = progress * (howl.duration() || currentTrack.duration);
howl.seek(time);
set({ progress, currentTime: time });
if (seekDebounce) clearTimeout(seekDebounce);
seekDebounce = setTimeout(() => {
seekDebounce = null;
if (gstSeeking) {
// GStreamer busy — queue this position; onseek will send it when ready
pendingSeekTime = time;
return;
}
gstSeeking = true;
const seekTarget = time;
armGstWatchdog(() => {
gstSeeking = false;
pendingSeekTime = null;
const { currentTrack: ct, queue: q } = get();
if (ct) { hangRecoveryPos = seekTarget; get().playTrack(ct, q); }
});
get().howl?.seek(time);
}, 100);
},
setVolume: (v) => {
@@ -268,27 +366,10 @@ export const usePlayerStore = create<PlayerState>()(
clearQueue: () => {
get().howl?.unload();
clearProgress();
set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, currentTime: 0, howl: null });
set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0, howl: null });
syncQueueToServer([], null, 0);
},
// Internal: prefetch next N tracks
prefetchUpcoming: (fromIndex: number, queue: Track[]) => {
const { prefetched } = get();
// Unload and clear old prefetches to prevent memory leaks
prefetched.forEach((h, id) => {
h.unload();
});
prefetched.clear();
const toFetch = queue.slice(fromIndex, fromIndex + 3);
toFetch.forEach(track => {
const url = buildStreamUrl(track.id);
const h = new Howl({ src: [url], html5: true, preload: true, autoplay: false });
prefetched.set(track.id, h);
});
set({ prefetched: new Map(prefetched) });
},
// Playlist management
reorderQueue: (startIndex: number, endIndex: number) => {
const { queue, queueIndex, currentTrack } = get();
@@ -321,7 +402,7 @@ export const usePlayerStore = create<PlayerState>()(
if (q.songs.length > 0) {
const mappedTracks: Track[] = q.songs.map((s: SubsonicSong) => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
+1 -1
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type Theme = 'mocha' | 'latte';
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora';
interface ThemeState {
theme: Theme;
+179 -192
View File
@@ -4,14 +4,14 @@
.hero {
position: relative;
width: 100%;
height: 300px;
height: 360px;
overflow: hidden;
flex-shrink: 0;
}
.hero-placeholder {
width: 100%;
height: 300px;
height: 360px;
background: linear-gradient(135deg, var(--ctp-surface0), var(--ctp-mantle));
flex-shrink: 0;
}
@@ -53,13 +53,13 @@
inset: 0;
background: linear-gradient(
to right,
rgba(30, 30, 46, 0.92) 0%,
rgba(30, 30, 46, 0.6) 50%,
rgba(30, 30, 46, 0.3) 100%
rgba(0, 0, 0, 0.78) 0%,
rgba(0, 0, 0, 0.45) 50%,
rgba(0, 0, 0, 0.18) 100%
),
linear-gradient(
to top,
rgba(30, 30, 46, 0.95) 0%,
rgba(0, 0, 0, 0.88) 0%,
transparent 60%
);
}
@@ -74,8 +74,8 @@
}
.hero-cover {
width: 180px;
height: 180px;
width: 220px;
height: 220px;
border-radius: var(--radius-md);
box-shadow: 0 4px 20px rgba(0,0,0,0.6);
object-fit: cover;
@@ -94,7 +94,7 @@
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
color: var(--ctp-lavender);
margin-bottom: var(--space-2);
}
@@ -102,16 +102,16 @@
font-family: var(--font-display);
font-size: clamp(24px, 3vw, 36px);
font-weight: 700;
color: var(--text-primary);
color: #ffffff;
margin-bottom: var(--space-1);
line-height: 1.2;
text-shadow: 0 2px 12px rgba(0,0,0,0.5);
text-shadow: 0 2px 12px rgba(0,0,0,0.6);
max-width: 500px;
}
.hero-artist {
font-size: 16px;
color: var(--text-secondary);
color: rgba(255, 255, 255, 0.72);
margin-bottom: var(--space-3);
}
@@ -567,8 +567,9 @@
.tracklist { padding: 0 var(--space-6) var(--space-6); }
.tracklist-header {
display: grid;
grid-template-columns: 36px minmax(100px, 3fr) minmax(90px, 1.5fr) 70px 80px 60px;
grid-template-columns: 36px minmax(100px, 3fr) 70px 80px 60px auto;
gap: var(--space-3);
align-items: center;
padding: var(--space-2) var(--space-3);
font-size: 11px;
font-weight: 600;
@@ -579,12 +580,12 @@
margin-bottom: var(--space-2);
}
.tracklist-header.tracklist-va {
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) minmax(90px, 1.5fr) 70px 80px 60px;
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) 70px 80px 60px auto;
}
.track-row {
display: grid;
grid-template-columns: 36px minmax(100px, 3fr) minmax(90px, 1.5fr) 70px 80px 60px;
grid-template-columns: 36px minmax(100px, 3fr) 70px 80px 60px auto;
gap: var(--space-3);
align-items: start;
padding: var(--space-2) var(--space-3);
@@ -593,37 +594,46 @@
transition: background var(--transition-fast);
}
.track-row.track-row-va {
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) minmax(90px, 1.5fr) 70px 80px 60px;
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) 70px 80px 60px auto;
}
.tracklist-total {
display: grid;
grid-template-columns: 36px minmax(100px, 3fr) minmax(90px, 1.5fr) 70px 80px 60px;
grid-template-columns: 36px minmax(100px, 3fr) 70px 80px 60px auto;
gap: var(--space-3);
border-top: 1px solid var(--border-subtle);
padding: var(--space-3) var(--space-4);
padding: var(--space-2) var(--space-3);
margin-top: var(--space-1);
}
.tracklist-total.tracklist-va {
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) minmax(90px, 1.5fr) 70px 80px 60px;
grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) 70px 80px 60px auto;
}
.tracklist-total-label {
grid-column: 1 / -2;
grid-column: 1 / 5;
text-align: right;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
padding-right: var(--space-3);
}
.tracklist-total-value {
grid-column: 5 / 6;
text-align: right;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
.tracklist-total.tracklist-va .tracklist-total-label { grid-column: 1 / 6; }
.tracklist-total.tracklist-va .tracklist-total-value { grid-column: 6 / 7; }
.track-row:hover { background: var(--bg-hover); }
.track-row.active { background: var(--accent-dim); animation: track-pulse 2.5s ease-in-out infinite; }
.track-row.active:hover { background: var(--accent-dim); animation: none; }
@keyframes track-pulse {
0%, 100% { background: var(--accent-dim); }
50% { background: transparent; }
}
.track-row > * { padding-top: 6px; padding-bottom: 6px; }
/* CD / Disc separator */
@@ -884,19 +894,19 @@
.empty-state { text-align: center; padding: var(--space-10); color: var(--text-muted); }
/* ─────────────────────────────────────────
Fullscreen Player
Fullscreen Player — Ambient Stage
──────────────────────────────────────────── */
.fs-player {
position: fixed;
inset: 0;
z-index: 9000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
will-change: transform;
contain: layout style paint;
animation: fsIn 280ms cubic-bezier(0.22, 1, 0.36, 1) both;
/* Solid base — guarantees nothing shines through, even at edges */
background: #0e0e1a;
}
@@ -905,16 +915,50 @@
to { transform: translateY(0); opacity: 1; }
}
/* Blurred background — GPU layer, will not repaint with React re-renders */
/* ── Drifting color orbs ── */
@keyframes orb-a {
0% { transform: translate(0px, 0px) scale(1); }
33% { transform: translate(90px, -50px) scale(1.12); }
66% { transform: translate(-40px, 70px) scale(0.94); }
100% { transform: translate(0px, 0px) scale(1); }
}
@keyframes orb-b {
0% { transform: translate(0px, 0px) scale(1); }
33% { transform: translate(-70px, 40px) scale(1.08); }
66% { transform: translate(50px, -60px) scale(1.14); }
100% { transform: translate(0px, 0px) scale(1); }
}
@keyframes orb-c {
0% { transform: translate(0px, 0px) scale(1); }
50% { transform: translate(60px, 50px) scale(0.9); }
100% { transform: translate(0px, 0px) scale(1); }
}
/* ── Cover breathing ── */
@keyframes cover-breathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.018); }
}
@keyframes ken-burns {
0% { transform: scale(1.08) translate(0%, 0%); }
25% { transform: scale(1.12) translate(-1.5%, 1%); }
50% { transform: scale(1.10) translate(1%, -1.5%); }
75% { transform: scale(1.13) translate(1.5%, 0.5%); }
100% { transform: scale(1.08) translate(0%, 0%); }
}
/* ── Blurred background ── */
.fs-bg {
position: absolute;
inset: -10%;
inset: -15%;
background-size: cover;
background-position: center;
filter: blur(50px) brightness(0.28) saturate(1.6);
transform: scale(1.2) translateZ(0);
filter: blur(6px) brightness(0.25) saturate(1.6);
animation: ken-burns 40s ease-in-out infinite;
transform: scale(1.2);
z-index: 0;
will-change: auto;
will-change: opacity;
pointer-events: none;
transition: opacity 700ms ease;
}
@@ -922,17 +966,42 @@
.fs-bg-overlay {
position: absolute;
inset: 0;
background: linear-gradient(
180deg,
rgba(17, 17, 27, 0.45) 0%,
rgba(17, 17, 27, 0.75) 65%,
rgba(17, 17, 27, 0.96) 100%
);
background: rgba(0, 0, 0, 0.45);
z-index: 0;
pointer-events: none;
}
/* Close button */
/* ── Drifting color orbs ── */
.fs-orb {
position: absolute;
border-radius: 50%;
filter: blur(110px);
opacity: 0.22;
pointer-events: none;
z-index: 0;
}
.fs-orb-1 {
background: var(--ctp-mauve);
width: 700px; height: 700px;
top: -220px; left: -180px;
animation: orb-a 20s ease-in-out infinite;
}
.fs-orb-2 {
background: var(--ctp-blue);
width: 600px; height: 600px;
bottom: -180px; right: -120px;
animation: orb-b 26s ease-in-out infinite;
animation-delay: -9s;
}
.fs-orb-3 {
background: var(--ctp-lavender);
width: 480px; height: 480px;
top: 35%; right: 5%;
animation: orb-c 17s ease-in-out infinite;
animation-delay: -14s;
}
/* ── Close button ── */
.fs-close {
position: absolute;
top: 20px;
@@ -943,7 +1012,7 @@
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(8px);
color: var(--text-secondary);
color: rgba(255,255,255,0.7);
display: flex;
align-items: center;
justify-content: center;
@@ -952,49 +1021,43 @@
}
.fs-close:hover {
background: rgba(255, 255, 255, 0.16);
color: var(--text-primary);
color: #ffffff;
transform: translateY(-1px);
}
/* ── Main layout: two columns, left = cover+controls, right = playlist ── */
.fs-layout {
/* ── Center stage ── */
.fs-stage {
position: relative;
z-index: 1;
display: flex;
flex: 1;
flex-direction: row;
align-items: center;
justify-content: center;
gap: clamp(28px, 4vw, 72px);
padding: 72px clamp(40px, 6vw, 100px) 48px;
min-height: 0;
overflow: hidden;
}
/* Left column: cover + track info + progress + controls */
.fs-left {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
flex-shrink: 0;
/* Width = cover size (cover fills 100% of this column) */
width: min(clamp(260px, 36vw, 560px), calc(100vh - 310px));
gap: 18px;
width: min(440px, 88vw);
padding: 16px 0;
}
/* Cover fills the column width */
/* Artist name — above cover */
.fs-artist {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--ctp-lavender);
margin: 0;
opacity: 0.9;
}
/* Cover — breathes slowly */
.fs-cover-wrap {
width: 100%;
width: clamp(300px, 30vw, 480px);
max-height: calc(100vh - 300px);
aspect-ratio: 1 / 1;
border-radius: var(--radius-xl);
overflow: hidden;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255,255,255,0.06);
box-shadow: none;
flex-shrink: 0;
transition: transform var(--transition-slow), box-shadow var(--transition-slow);
}
.fs-cover-wrap:hover {
transform: scale(1.015);
box-shadow: 0 32px 100px rgba(0,0,0,0.8), 0 0 0 1px rgba(255,255,255,0.1);
animation: cover-breathe 9s ease-in-out infinite;
}
.fs-cover {
width: 100%;
@@ -1003,6 +1066,8 @@
display: block;
}
.fs-cover-placeholder {
width: 100%;
height: 100%;
background: var(--ctp-surface0);
display: flex;
align-items: center;
@@ -1010,32 +1075,33 @@
color: var(--text-muted);
}
/* Track metadata */
/* Track info — below cover */
.fs-track-info {
text-align: center;
width: 100%;
}
.fs-title {
.fs-title-wrap {
font-family: var(--font-display);
font-size: clamp(16px, 1.8vw, 26px);
font-size: clamp(20px, 3vw, 32px);
font-weight: 800;
color: var(--text-primary);
margin: 0 0 4px;
line-height: 1.2;
color: #ffffff;
margin: 0 0 6px;
line-height: 1.15;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fs-artist {
font-size: clamp(13px, 1.1vw, 16px);
font-weight: 600;
color: var(--accent);
margin: 0;
.fs-title-marquee {
display: inline-block;
animation: marquee-scroll 14s linear infinite alternate;
}
@keyframes marquee-scroll {
0%, 15% { transform: translateX(0); }
85%, 100% { transform: translateX(var(--scroll-amount, 0px)); }
}
.fs-album {
font-size: 13px;
color: var(--text-secondary);
margin: 3px 0 0;
font-size: 14px;
color: rgba(255, 255, 255, 0.5);
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -1049,8 +1115,8 @@
border: 1px solid rgba(255,255,255,0.1);
padding: 2px 8px;
border-radius: var(--radius-full);
color: var(--text-muted);
margin-top: 6px;
color: rgba(255, 255, 255, 0.35);
margin-top: 8px;
}
/* Progress bar */
@@ -1061,8 +1127,8 @@
width: 100%;
}
.fs-time {
font-size: 12px;
color: var(--text-muted);
font-size: 11px;
color: rgba(255,255,255,0.4);
font-variant-numeric: tabular-nums;
min-width: 36px;
text-align: center;
@@ -1073,8 +1139,14 @@
}
.fs-progress-bar input[type="range"] {
width: 100%;
height: 4px;
background: linear-gradient(to right, var(--ctp-mauve) var(--pct), rgba(255,255,255,0.12) var(--pct));
height: 3px;
background: linear-gradient(
to right,
rgba(255,255,255,0.85) var(--pct, 0%),
rgba(255,255,255,0.32) var(--pct, 0%),
rgba(255,255,255,0.32) var(--buf, 0%),
rgba(255,255,255,0.15) var(--buf, 0%)
);
border-radius: 2px;
cursor: pointer;
appearance: none;
@@ -1083,135 +1155,50 @@
.fs-progress-bar input[type="range"]::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 14px;
height: 14px;
width: 13px;
height: 13px;
border-radius: 50%;
background: white;
background: #ffffff;
cursor: pointer;
box-shadow: 0 1px 4px rgba(0,0,0,0.5);
box-shadow: 0 1px 6px rgba(0,0,0,0.6);
transition: transform var(--transition-fast);
}
.fs-progress-bar input[type="range"]:hover::-webkit-slider-thumb { transform: scale(1.25); }
.fs-progress-bar input[type="range"]:hover::-webkit-slider-thumb { transform: scale(1.3); }
/* Transport controls */
.fs-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
gap: 14px;
}
.fs-btn {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
width: 36px;
height: 36px;
border-radius: 50%;
color: var(--text-secondary);
color: rgba(255,255,255,0.6);
cursor: pointer;
transition: all var(--transition-fast);
background: transparent;
}
.fs-btn:hover { color: var(--text-primary); background: rgba(255,255,255,0.08); }
.fs-btn.active { color: var(--accent); }
.fs-btn-sm { width: 36px; height: 36px; }
.fs-btn:hover { color: #ffffff; background: rgba(255,255,255,0.1); }
.fs-btn.active { color: var(--ctp-lavender); }
.fs-btn-sm { width: 28px; height: 28px; }
.fs-btn-play {
width: 68px;
height: 68px;
background: white;
color: #1e1e2e;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
width: 54px;
height: 54px;
background: linear-gradient(135deg, var(--ctp-mauve), var(--ctp-lavender));
color: var(--ctp-crust);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
}
.fs-btn-play:hover {
background: var(--ctp-lavender);
color: #1e1e2e;
transform: scale(1.06);
box-shadow: 0 12px 40px rgba(0,0,0,0.6);
}
/* Right column: upcoming tracks — same vertical center as left column */
.fs-right {
display: flex;
flex-direction: column;
gap: 12px;
flex-shrink: 0;
width: clamp(240px, 26vw, 380px);
align-self: center;
height: min(clamp(400px, 60vh, 840px), calc(100vh - 180px));
overflow: hidden;
}
.fs-upcoming-title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
margin: 0;
flex-shrink: 0;
}
.fs-upcoming-list {
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
flex: 1;
min-height: 0;
}
.fs-upcoming-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-radius: var(--radius-md);
cursor: pointer;
width: 100%;
text-align: left;
transition: background var(--transition-fast);
background: transparent;
flex-shrink: 0;
}
.fs-upcoming-item:hover { background: rgba(255,255,255,0.07); }
.fs-upcoming-art {
width: 44px;
height: 44px;
border-radius: var(--radius-sm);
object-fit: cover;
flex-shrink: 0;
}
.fs-upcoming-placeholder {
background: var(--ctp-surface1);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
}
.fs-upcoming-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.fs-upcoming-name {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fs-upcoming-artist {
font-size: 12px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fs-upcoming-dur {
font-size: 12px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
background: linear-gradient(135deg, var(--ctp-lavender), var(--ctp-mauve));
color: var(--ctp-crust);
transform: scale(1.07);
box-shadow: 0 10px 34px rgba(0,0,0,0.6);
}
/* Chat */
+92 -19
View File
@@ -29,7 +29,6 @@
background: var(--accent);
}
.resizer-sidebar { left: calc(var(--sidebar-width) - 3px); }
.resizer-queue { right: calc(var(--queue-width) - 3px); }
/* ─── Sidebar ─── */
@@ -164,6 +163,70 @@
color: var(--text-primary);
}
/* ─── Update Toast ─── */
@keyframes update-toast-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.update-toast {
margin: 0 var(--space-1) var(--space-2);
padding: var(--space-3) var(--space-3);
border-radius: var(--radius-md);
background: var(--accent-dim);
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
display: flex;
flex-direction: column;
gap: 3px;
animation: update-toast-in 0.35s ease both;
}
.update-toast-header {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--accent);
}
.update-toast-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--accent);
}
.update-toast-version {
font-size: 12px;
color: var(--text-secondary);
padding-left: 22px;
}
.update-toast-link {
font-size: 11px;
font-weight: 600;
color: var(--accent);
text-decoration: none;
padding-left: 22px;
opacity: 0.8;
transition: opacity var(--transition-fast);
}
.update-toast-link:hover {
opacity: 1;
text-decoration: underline;
}
.update-toast-icon {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-2) 0;
color: var(--accent);
animation: update-toast-in 0.35s ease both;
cursor: default;
}
/* ─── Main Content ─── */
.main-content {
grid-area: main;
@@ -306,13 +369,14 @@
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
gap: 2px;
padding: 12px 0;
}
.player-buttons {
display: flex;
align-items: center;
gap: var(--space-2);
gap: var(--space-3);
}
.player-btn {
@@ -322,29 +386,31 @@
width: 36px;
height: 36px;
border-radius: 50%;
color: var(--text-secondary);
color: var(--text-muted);
transition: all var(--transition-fast);
}
.player-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
transform: scale(1.12);
}
.player-btn-primary {
width: 42px;
height: 42px;
background: var(--text-primary);
color: var(--bg-app);
width: 48px;
height: 48px;
background: linear-gradient(135deg, var(--ctp-mauve), var(--ctp-lavender));
color: var(--ctp-crust);
border-radius: 50%;
box-shadow: var(--shadow-sm);
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.4);
flex-shrink: 0;
}
.player-btn-primary:hover {
background: var(--accent);
background: linear-gradient(135deg, var(--ctp-lavender), var(--ctp-mauve));
color: var(--ctp-crust);
transform: scale(1.05);
box-shadow: var(--shadow-glow);
transform: scale(1.07);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4), var(--shadow-glow);
}
.player-progress {
@@ -441,23 +507,26 @@
}
.queue-current-track {
padding: var(--space-4);
padding: var(--space-3) var(--space-4);
display: flex;
flex-direction: column;
flex-direction: row;
align-items: center;
gap: var(--space-3);
flex-shrink: 0;
border-bottom: 1px solid var(--border-subtle);
}
.queue-current-cover {
width: 100%;
aspect-ratio: 1 / 1;
width: 72px;
height: 72px;
flex-shrink: 0;
border-radius: var(--radius-md);
overflow: hidden;
background: var(--bg-surface);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
.queue-current-cover img {
@@ -474,10 +543,12 @@
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.queue-current-info h3 {
font-size: 16px;
font-size: 13px;
font-weight: 700;
margin: 0;
color: var(--text-primary);
@@ -501,7 +572,7 @@
}
.queue-divider {
padding: var(--space-4) var(--space-5) 0;
padding: var(--space-3) var(--space-4) 0;
flex-shrink: 0;
}
@@ -528,6 +599,8 @@
transition: background var(--transition-fast);
color: var(--text-secondary);
}
/* Prevent child elements from stealing dragenter/dragleave events */
.queue-item > * { pointer-events: none; }
.queue-item:hover {
background: var(--bg-hover);
+317 -2
View File
@@ -56,6 +56,106 @@
--danger: var(--ctp-red);
}
/* ─── Catppuccin Macchiato Variables ─── */
[data-theme='macchiato'] {
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%23b8c0e0%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-rosewater: #f4dbd6;
--ctp-flamingo: #f0c6c6;
--ctp-pink: #f5bde6;
--ctp-mauve: #c6a0f6;
--ctp-red: #ed8796;
--ctp-maroon: #ee99a0;
--ctp-peach: #f5a97f;
--ctp-yellow: #eed49f;
--ctp-green: #a6da95;
--ctp-teal: #8bd5ca;
--ctp-sky: #91d7e3;
--ctp-sapphire: #7dc4e4;
--ctp-blue: #8aadf4;
--ctp-lavender: #b7bdf8;
--ctp-text: #cad3f5;
--ctp-subtext1: #b8c0e0;
--ctp-subtext0: #a5adcb;
--ctp-overlay2: #939ab7;
--ctp-overlay1: #8087a2;
--ctp-overlay0: #6e738d;
--ctp-surface2: #5b6078;
--ctp-surface1: #494d64;
--ctp-surface0: #363a4f;
--ctp-base: #24273a;
--ctp-mantle: #1e2030;
--ctp-crust: #181926;
--bg-app: var(--ctp-base);
--bg-sidebar: var(--ctp-mantle);
--bg-card: var(--ctp-surface0);
--bg-hover: var(--ctp-surface1);
--bg-player: var(--ctp-crust);
--bg-glass: rgba(36, 39, 58, 0.75);
--accent: var(--ctp-mauve);
--accent-dim: rgba(198, 160, 246, 0.15);
--accent-glow: rgba(198, 160, 246, 0.3);
--text-primary: var(--ctp-text);
--text-secondary:var(--ctp-subtext1);
--text-muted: var(--ctp-overlay1);
--border: var(--ctp-surface1);
--border-subtle: var(--ctp-surface0);
--positive: var(--ctp-green);
--warning: var(--ctp-yellow);
--danger: var(--ctp-red);
}
/* ─── Catppuccin Frappé Variables ─── */
[data-theme='frappe'] {
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%23b5bfe2%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-rosewater: #f2d5cf;
--ctp-flamingo: #eebebe;
--ctp-pink: #f4b8e4;
--ctp-mauve: #ca9ee6;
--ctp-red: #e78284;
--ctp-maroon: #ea999c;
--ctp-peach: #ef9f76;
--ctp-yellow: #e5c890;
--ctp-green: #a6d189;
--ctp-teal: #81c8be;
--ctp-sky: #99d1db;
--ctp-sapphire: #85c1dc;
--ctp-blue: #8caaee;
--ctp-lavender: #babbf1;
--ctp-text: #c6d0f5;
--ctp-subtext1: #b5bfe2;
--ctp-subtext0: #a5adce;
--ctp-overlay2: #949cbb;
--ctp-overlay1: #838ba7;
--ctp-overlay0: #737994;
--ctp-surface2: #626880;
--ctp-surface1: #51576d;
--ctp-surface0: #414559;
--ctp-base: #303446;
--ctp-mantle: #292c3c;
--ctp-crust: #232634;
--bg-app: var(--ctp-base);
--bg-sidebar: var(--ctp-mantle);
--bg-card: var(--ctp-surface0);
--bg-hover: var(--ctp-surface1);
--bg-player: var(--ctp-crust);
--bg-glass: rgba(48, 52, 70, 0.75);
--accent: var(--ctp-mauve);
--accent-dim: rgba(202, 158, 230, 0.15);
--accent-glow: rgba(202, 158, 230, 0.3);
--text-primary: var(--ctp-text);
--text-secondary:var(--ctp-subtext1);
--text-muted: var(--ctp-overlay1);
--border: var(--ctp-surface1);
--border-subtle: var(--ctp-surface0);
--positive: var(--ctp-green);
--warning: var(--ctp-yellow);
--danger: var(--ctp-red);
}
/* ─── Catppuccin Latte Variables (Light Theme) ─── */
[data-theme='latte'] {
color-scheme: light;
@@ -107,6 +207,221 @@
--danger: var(--ctp-red);
}
/* ─── Nord Polar Night (Dark) ─── */
[data-theme='nord'] {
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%23e5e9f0%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");
/* Polar Night */
--ctp-crust: #2e3440;
--ctp-mantle: #2e3440;
--ctp-base: #3b4252;
--ctp-surface0: #434c5e;
--ctp-surface1: #4c566a;
--ctp-surface2: #576070;
--ctp-overlay0: #7a8898;
--ctp-overlay1: #8894a4;
--ctp-overlay2: #96a2b0;
/* Snow Storm as text */
--ctp-text: #eceff4;
--ctp-subtext1: #e5e9f0;
--ctp-subtext0: #d8dee9;
/* Frost */
--ctp-teal: #8fbcbb;
--ctp-sky: #88c0d0;
--ctp-sapphire: #81a1c1;
--ctp-blue: #5e81ac;
--ctp-lavender: #81a1c1;
--ctp-mauve: #88c0d0;
/* Aurora */
--ctp-red: #bf616a;
--ctp-maroon: #d08770;
--ctp-peach: #d08770;
--ctp-yellow: #ebcb8b;
--ctp-green: #a3be8c;
--ctp-pink: #b48ead;
--ctp-flamingo: #bf616a;
--ctp-rosewater: #d08770;
--bg-app: #3b4252;
--bg-sidebar: #2e3440;
--bg-card: #434c5e;
--bg-hover: #4c566a;
--bg-player: #2e3440;
--bg-glass: rgba(59, 66, 82, 0.75);
--accent: #88c0d0;
--accent-dim: rgba(136, 192, 208, 0.15);
--accent-glow: rgba(136, 192, 208, 0.3);
--text-primary: #eceff4;
--text-secondary:#e5e9f0;
--text-muted: #8894a4;
--border: #434c5e;
--border-subtle: #3b4252;
--positive: #a3be8c;
--warning: #ebcb8b;
--danger: #bf616a;
}
/* ─── Nord Snow Storm (Light) ─── */
[data-theme='nord-snowstorm'] {
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%233b4252%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");
/* Snow Storm as backgrounds */
--ctp-crust: #d8dee9;
--ctp-mantle: #e5e9f0;
--ctp-base: #eceff4;
--ctp-surface0: #d8dee9;
--ctp-surface1: #c8d0de;
--ctp-surface2: #b8c2d0;
--ctp-overlay0: #7a8898;
--ctp-overlay1: #636e7e;
--ctp-overlay2: #4c566a;
/* Polar Night as text */
--ctp-text: #2e3440;
--ctp-subtext1: #3b4252;
--ctp-subtext0: #434c5e;
/* Frost */
--ctp-teal: #8fbcbb;
--ctp-sky: #88c0d0;
--ctp-sapphire: #81a1c1;
--ctp-blue: #5e81ac;
--ctp-lavender: #81a1c1;
--ctp-mauve: #5e81ac;
/* Aurora */
--ctp-red: #bf616a;
--ctp-maroon: #d08770;
--ctp-peach: #d08770;
--ctp-yellow: #9a7200;
--ctp-green: #4e7c3f;
--ctp-pink: #7e5d8c;
--ctp-flamingo: #bf616a;
--ctp-rosewater: #d08770;
--bg-app: #eceff4;
--bg-sidebar: #e5e9f0;
--bg-card: #d8dee9;
--bg-hover: #c8d0de;
--bg-player: #e5e9f0;
--bg-glass: rgba(236, 239, 244, 0.80);
--accent: #5e81ac;
--accent-dim: rgba(94, 129, 172, 0.15);
--accent-glow: rgba(94, 129, 172, 0.3);
--text-primary: #2e3440;
--text-secondary:#3b4252;
--text-muted: #636e7e;
--border: #c8d0de;
--border-subtle: #d8dee9;
--positive: #4e7c3f;
--warning: #9a7200;
--danger: #bf616a;
}
/* ─── Nord Frost (Deep Ocean Blue) ─── */
[data-theme='nord-frost'] {
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%23e5e9f0%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");
/* Deep ocean blue Frost-inspired dark backgrounds */
--ctp-crust: #1a2535;
--ctp-mantle: #1d2b3a;
--ctp-base: #253545;
--ctp-surface0: #2d3f52;
--ctp-surface1: #354a60;
--ctp-surface2: #3d546e;
--ctp-overlay0: #6888a4;
--ctp-overlay1: #7898b4;
--ctp-overlay2: #88a8c4;
/* Snow Storm as text */
--ctp-text: #eceff4;
--ctp-subtext1: #e5e9f0;
--ctp-subtext0: #d8dee9;
/* Frost accent */
--ctp-teal: #8fbcbb;
--ctp-sky: #88c0d0;
--ctp-sapphire: #81a1c1;
--ctp-blue: #5e81ac;
--ctp-lavender: #8fbcbb;
--ctp-mauve: #88c0d0;
/* Aurora */
--ctp-red: #bf616a;
--ctp-maroon: #d08770;
--ctp-peach: #d08770;
--ctp-yellow: #ebcb8b;
--ctp-green: #a3be8c;
--ctp-pink: #b48ead;
--ctp-flamingo: #bf616a;
--ctp-rosewater: #d08770;
--bg-app: #253545;
--bg-sidebar: #1d2b3a;
--bg-card: #2d3f52;
--bg-hover: #354a60;
--bg-player: #1a2535;
--bg-glass: rgba(37, 53, 69, 0.75);
--accent: #88c0d0;
--accent-dim: rgba(136, 192, 208, 0.15);
--accent-glow: rgba(136, 192, 208, 0.35);
--text-primary: #eceff4;
--text-secondary:#e5e9f0;
--text-muted: #7898b4;
--border: #2d3f52;
--border-subtle: #253545;
--positive: #a3be8c;
--warning: #ebcb8b;
--danger: #bf616a;
}
/* ─── Nord Aurora (Dark + Aurora Purple) ─── */
[data-theme='nord-aurora'] {
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%23e5e9f0%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");
/* Polar Night base */
--ctp-crust: #2e3440;
--ctp-mantle: #2e3440;
--ctp-base: #3b4252;
--ctp-surface0: #434c5e;
--ctp-surface1: #4c566a;
--ctp-surface2: #576070;
--ctp-overlay0: #7a8898;
--ctp-overlay1: #8894a4;
--ctp-overlay2: #96a2b0;
/* Snow Storm as text */
--ctp-text: #eceff4;
--ctp-subtext1: #e5e9f0;
--ctp-subtext0: #d8dee9;
/* Aurora accents — purple as mauve/lavender for button gradients */
--ctp-mauve: #b48ead;
--ctp-lavender: #a3be8c;
--ctp-pink: #b48ead;
--ctp-teal: #8fbcbb;
--ctp-sky: #88c0d0;
--ctp-sapphire: #81a1c1;
--ctp-blue: #5e81ac;
--ctp-red: #bf616a;
--ctp-maroon: #d08770;
--ctp-peach: #d08770;
--ctp-yellow: #ebcb8b;
--ctp-green: #a3be8c;
--ctp-flamingo: #bf616a;
--ctp-rosewater: #d08770;
--bg-app: #3b4252;
--bg-sidebar: #2e3440;
--bg-card: #434c5e;
--bg-hover: #4c566a;
--bg-player: #2e3440;
--bg-glass: rgba(59, 66, 82, 0.75);
--accent: #b48ead;
--accent-dim: rgba(180, 142, 173, 0.15);
--accent-glow: rgba(180, 142, 173, 0.3);
--text-primary: #eceff4;
--text-secondary:#e5e9f0;
--text-muted: #8894a4;
--border: #434c5e;
--border-subtle: #3b4252;
--positive: #a3be8c;
--warning: #ebcb8b;
--danger: #bf616a;
}
/* ─── Global Base Settings ─── */
:root {
/* Typography */
@@ -265,8 +580,8 @@ body.is-dragging * {
.input {
width: 100%;
padding: var(--space-3) var(--space-4);
background: var(--ctp-surface0);
border: 1px solid var(--border);
background: var(--ctp-base);
border: 1px solid var(--ctp-overlay0);
border-radius: var(--radius-md);
color: var(--text-primary);
font-family: var(--font-sans);