Compare commits

..

11 Commits

Author SHA1 Message Date
Psychotoxical 9b1f3b019f chore: Bump version to 1.0.12, update changelog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 14:42:41 +01:00
Psychotoxical baa701dd74 fix: Playback stability, seek stop bug, and UI polish
- playerStore: Guard onend against spurious 'ended' events fired by
  WebKit/GStreamer immediately after a direct audioNode.currentTime seek.
  Root cause of the deterministic "second click on progress bar stops song"
  bug: a lastSeekAt timestamp + position check now silently drops any
  'ended' event that fires within 1 s of a seek if the playhead isn't
  actually near the track end.

- playerStore: Debounce togglePlay with a 300 ms lock to prevent rapid
  double-clicks from issuing pause→play before GStreamer has finished its
  state transition, which caused a multi-second pipeline hang.

- NowPlayingDropdown: Clicking a Live entry now navigates to the album page.

- QueuePanel: DnD drop target index now calculated from clientY position
  at drop time instead of refs, eliminating the dragend-before-drop race
  on macOS WKWebView and Windows WebView2.

- styles: Increased Hero background blur for a more immersive look.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 14:41:02 +01:00
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
31 changed files with 1089 additions and 677 deletions
+6 -6
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/*
+80
View File
@@ -5,6 +5,86 @@ 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.12] - 2026-03-14
### Fixed
- **Seek Stop Bug**: Clicking the progress bar a second time no longer stops playback. Root cause: WebKit and GStreamer fire spurious `ended` events immediately after a direct `audioNode.currentTime` seek. A guard now checks `lastSeekAt` + playhead position to silently discard these false alarms.
- **Play/Pause Hang**: Rapidly double-clicking the play/pause button no longer freezes the audio pipeline. A 300 ms lock prevents a second toggle from issuing `pause→play` before GStreamer has finished the previous state transition.
- **Queue DnD (macOS / Windows)**: Drop target index is now calculated from the mouse `clientY` position at drop time instead of refs, eliminating the `dragend`-before-`drop` timing race on macOS WKWebView and Windows WebView2.
### Added
- **Live Now Playing navigation**: Clicking an entry in the Live dropdown now navigates to the corresponding album page.
### Changed
- **Hero blur**: Increased background blur in the Hero section for a more immersive look.
## [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.~~ Fixed in 1.0.12.
- ~~**Queue drag & drop (macOS / Windows)**: Queue reordering via drag & drop may not always work correctly on macOS and Windows.~~ Fixed in 1.0.12.
## [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
+12 -10
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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.0.7",
"version": "1.0.12",
"private": true,
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -2732,7 +2732,7 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.0.6"
version = "1.0.11"
dependencies = [
"serde",
"serde_json",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.0.7"
version = "1.0.12"
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.7",
"version": "1.0.12",
"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;
+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);
+137 -98
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,9 +121,7 @@ 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);
@@ -94,30 +133,41 @@ const FsPlayBtn = memo(function FsPlayBtn() {
);
});
// ─── 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={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}
data-tooltip={`${t('player.repeat')}: ${repeatMode === 'off' ? t('player.repeatOff') : repeatMode === 'all' ? t('player.repeatAll') : t('player.repeatOne')}`}
>
{repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />}
</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('');
+7 -1
View File
@@ -4,9 +4,11 @@ import { getNowPlaying, SubsonicNowPlaying, buildCoverArtUrl } from '../api/subs
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
export default function NowPlayingDropdown() {
const { t } = useTranslation();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const [nowPlaying, setNowPlaying] = useState<SubsonicNowPlaying[]>([]);
const isPlaying = usePlayerStore(s => s.isPlaying);
@@ -121,7 +123,11 @@ export default function NowPlayingDropdown() {
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{visible.map((stream, idx) => (
<div key={`${stream.id}-${idx}`} style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', background: 'var(--bg-hover)', padding: '0.5rem', borderRadius: '8px' }}>
<div
key={`${stream.id}-${idx}`}
onClick={() => { if (stream.albumId) { setIsOpen(false); navigate(`/album/${stream.albumId}`); } }}
style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', background: 'var(--bg-hover)', padding: '0.5rem', borderRadius: '8px', cursor: stream.albumId ? 'pointer' : 'default' }}
>
<div style={{ width: '48px', height: '48px', flexShrink: 0, borderRadius: '6px', overflow: 'hidden', background: 'var(--bg-surface)' }}>
{stream.coverArt ? (
<img src={buildCoverArtUrl(stream.coverArt, 100)} alt="Cover" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
+18 -4
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>
+79 -56
View File
@@ -4,6 +4,7 @@ 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);
@@ -125,6 +127,12 @@ 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 queueListRef = useRef<HTMLDivElement>(null);
const [saveModalOpen, setSaveModalOpen] = useState(false);
const [loadModalOpen, setLoadModalOpen] = useState(false);
@@ -144,12 +152,12 @@ export default function QueuePanel() {
const onDragStart = (e: React.DragEvent, index: number) => {
isDraggingInternalRef.current = true;
draggedIdxRef.current = index;
setDraggedIdx(index);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', JSON.stringify({
type: 'queue_reorder',
index
}));
// 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) => {
@@ -160,60 +168,68 @@ export default function QueuePanel() {
const onDragOverItem = (e: React.DragEvent, index: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy';
dragOverIdxRef.current = index;
setDragOverIdx(index);
};
const onDragEnd = () => {
isDraggingInternalRef.current = false;
setDraggedIdx(null);
setDragOverIdx(null);
isDraggingInternalRef.current = false;
draggedIdxRef.current = null;
dragOverIdxRef.current = null;
};
const onDropQueue = async (e: React.DragEvent, dropIndex?: number) => {
const onDropQueue = async (e: React.DragEvent) => {
e.preventDefault();
// Handle internal queue reorder using state — more reliable than dataTransfer on Windows/WebView2
if (draggedIdx !== null) {
const targetIdx = dropIndex !== undefined ? dropIndex : queue.length;
if (draggedIdx !== targetIdx) {
reorderQueue(draggedIdx, targetIdx);
// Clear visual state immediately
isDraggingInternalRef.current = false;
draggedIdxRef.current = null;
dragOverIdxRef.current = null;
setDraggedIdx(null);
setDragOverIdx(null);
let parsedData: any = null;
try {
const raw = e.dataTransfer.getData('text/plain');
if (raw) parsedData = JSON.parse(raw);
} catch { /* ignore */ }
if (parsedData?.type === 'queue_reorder') {
// fromIdx: always reliable from dataTransfer (set during dragstart)
const fromIdx: number = parsedData.index;
// toIdx: calculate from drop coordinates — avoids all ref timing issues.
// Works even when dragend fires before drop (macOS WKWebView / Windows WebView2).
let toIdx = queue.length;
if (queueListRef.current) {
const items = queueListRef.current.querySelectorAll<HTMLElement>('[data-queue-idx]');
for (let i = 0; i < items.length; i++) {
const rect = items[i].getBoundingClientRect();
if (e.clientY < rect.top + rect.height / 2) {
toIdx = parseInt(items[i].dataset.queueIdx!);
break;
}
}
}
isDraggingInternalRef.current = false;
setDraggedIdx(null);
setDragOverIdx(null);
if (fromIdx !== toIdx) reorderQueue(fromIdx, toIdx);
return;
}
isDraggingInternalRef.current = false;
setDraggedIdx(null);
setDragOverIdx(null);
try {
const dataStr = e.dataTransfer.getData('text/plain');
if (!dataStr) return;
const data = JSON.parse(dataStr);
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);
// 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);
}
};
@@ -222,7 +238,7 @@ export default function QueuePanel() {
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={e => onDropQueue(e)}
onDrop={onDropQueue}
style={{
borderLeftWidth: isQueueVisible ? 1 : 0
}}
@@ -251,15 +267,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">
@@ -277,7 +303,7 @@ export default function QueuePanel() {
{currentTrack && queue.length > 0 && <div className="queue-divider"><span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.nextTracks')}</span></div>}
<div className="queue-list">
<div className="queue-list" ref={queueListRef}>
{queue.length === 0 ? (
<div className="queue-empty">
{t('queue.emptyQueue')}
@@ -301,8 +327,9 @@ export default function QueuePanel() {
}
return (
<div
key={`${track.id}-${idx}`}
<div
key={`${track.id}-${idx}`}
data-queue-idx={idx}
className={`queue-item ${isPlaying ? 'active' : ''}`}
onClick={() => playTrack(track, queue)}
onContextMenu={(e) => {
@@ -314,10 +341,6 @@ export default function QueuePanel() {
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">
@@ -358,7 +381,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) {
+4 -5
View File
@@ -1,4 +1,5 @@
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 {
@@ -22,7 +23,7 @@ const navItems = [
];
function isNewer(latest: string, current: string): boolean {
const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number);
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;
@@ -74,17 +75,15 @@ export default function Sidebar({
let cancelled = false;
const timer = setTimeout(async () => {
try {
const { getVersion } = await import('@tauri-apps/api/app');
const current = await getVersion();
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, current)) {
if (!cancelled && tag && isNewer(tag, appVersion)) {
setLatestVersion(tag.startsWith('v') ? tag : `v${tag}`);
}
} catch {
// network unavailable or not running in Tauri — silently skip
// network unavailable — silently skip
}
}, 1500);
return () => { cancelled = true; clearTimeout(timer); };
+11 -3
View File
@@ -44,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?',
@@ -258,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',
@@ -401,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?',
@@ -612,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',
+30 -18
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('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');
+1 -1
View File
@@ -72,7 +72,7 @@ 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('text/plain', JSON.stringify({ type: 'song', track }));
+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) {
+3 -3
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 {
@@ -99,7 +99,7 @@ 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('text/plain', JSON.stringify({ type: 'song', track }));
@@ -133,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>
);
}
+5 -1
View File
@@ -374,7 +374,7 @@ export default function Settings() {
Psysonic
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
{t('settings.aboutVersion')} 1.0.7
{t('settings.aboutVersion')} 1.0.12
</div>
</div>
</div>
@@ -397,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
+336 -248
View File
@@ -10,6 +10,7 @@ export interface Track {
artist: string;
album: string;
albumId: string;
artistId?: string;
duration: number;
coverArt?: string;
track?: number;
@@ -25,13 +26,12 @@ 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
playTrack: (track: Track, queue?: Track[]) => void;
pause: () => void;
resume: () => void;
@@ -44,37 +44,50 @@ interface PlayerState {
setProgress: (t: number, duration: number) => void;
enqueue: (tracks: Track[]) => void;
clearQueue: () => void;
prefetchUpcoming: (fromIndex: number, queue: Track[]) => void;
isQueueVisible: boolean;
toggleQueue: () => void;
isFullscreenOpen: boolean;
toggleFullscreen: () => void;
repeatMode: 'off' | 'all' | 'one';
toggleRepeat: () => void;
reorderQueue: (startIndex: number, endIndex: number) => void;
removeTrack: (index: number) => void;
initializeFromServerQueue: () => Promise<void>;
// Context Menu Global State
contextMenu: {
isOpen: boolean;
x: number;
y: number;
item: any;
type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song' | null;
queueIndex?: number; // Only for 'queue-item'
queueIndex?: number;
};
openContextMenu: (x: number, y: number, item: any, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song', queueIndex?: number) => void;
closeContextMenu: () => void;
}
// ─── Module-level playback primitives ─────────────────────────────────────────
//
// Kept outside Zustand to avoid stale-closure / React re-render races.
//
// activeHowl the one and only live Howl; all event handlers reference this.
// playGeneration monotonically incremented on every playTrack() call.
// Every Howl event callback captures its own `gen` value at creation time
// and bails out immediately if playGeneration has moved on. This prevents
// stale onend / onplay callbacks from a superseded Howl from affecting state.
let activeHowl: Howl | null = null;
let playGeneration = 0;
let progressInterval: ReturnType<typeof setInterval> | null = null;
let seekDebounce: ReturnType<typeof setTimeout> | null = null;
let resumeFromTime: number | null = null; // cold-start resume position (app relaunch)
let lastSeekAt = 0; // timestamp (ms) of the most recent seek — used to ignore spurious 'ended' events
let togglePlayLock = false; // prevents rapid double-click from sending pause→play before GStreamer settles
function clearProgress() {
if (progressInterval) {
@@ -83,19 +96,27 @@ function clearProgress() {
}
}
// Helper to debounce or fire queue syncs
// Remove all Howler-level listeners BEFORE stopping/unloading.
// This is the critical step that prevents stale `onend` callbacks from firing
// on a superseded Howl and triggering an unwanted next() / skip.
function destroyHowl(howl: Howl | null) {
if (!howl) return;
howl.off(); // remove all Howler event listeners
howl.stop(); // stop any playing sound
howl.unload(); // release the <audio> element and all resources
}
// ─── Server queue sync ─────────────────────────────────────────────────────────
let syncTimeout: ReturnType<typeof setTimeout> | null = null;
function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTime: number) {
if (syncTimeout) clearTimeout(syncTimeout);
syncTimeout = setTimeout(() => {
// Collect up to 1000 track IDs just in case it's huge
const ids = queue.slice(0, 1000).map(t => t.id);
// Convert currentTime (seconds) to expected format (milliseconds)
const pos = Math.floor(currentTime * 1000);
savePlayQueue(ids, currentTrack?.id, pos).catch(err => {
console.error('Failed to sync play queue to server', err);
});
}, 1500); // 1.5s debounce
}, 1500);
}
export const usePlayerStore = create<PlayerState>()(
@@ -106,10 +127,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,
@@ -117,253 +138,320 @@ export const usePlayerStore = create<PlayerState>()(
contextMenu: { isOpen: false, x: 0, y: 0, item: null, type: null },
openContextMenu: (x, y, item, type, queueIndex) => set({
contextMenu: { isOpen: true, x, y, item, type, queueIndex }
contextMenu: { isOpen: true, x, y, item, type, queueIndex },
}),
closeContextMenu: () => set(state => ({
contextMenu: { ...state.contextMenu, isOpen: false }
contextMenu: { ...state.contextMenu, isOpen: false },
})),
toggleQueue: () => set((state) => ({ isQueueVisible: !state.isQueueVisible })),
toggleFullscreen: () => set((state) => ({ isFullscreenOpen: !state.isFullscreenOpen })),
toggleQueue: () => set(state => ({ isQueueVisible: !state.isQueueVisible })),
toggleFullscreen: () => set(state => ({ isFullscreenOpen: !state.isFullscreenOpen })),
toggleRepeat: () => set((state) => {
const modes = ['off', 'all', 'one'] as const;
const nextIdx = (modes.indexOf(state.repeatMode) + 1) % modes.length;
return { repeatMode: modes[nextIdx] };
}),
toggleRepeat: () => set(state => {
const modes = ['off', 'all', 'one'] as const;
return { repeatMode: modes[(modes.indexOf(state.repeatMode) + 1) % modes.length] };
}),
stop: () => {
get().howl?.stop();
get().howl?.seek(0);
clearProgress();
set({ isPlaying: false, progress: 0, currentTime: 0 });
},
playTrack: (track, queue) => {
const state = get();
// Stop current
state.howl?.unload();
clearProgress();
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);
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 });
// Scrobble at 50%
if (prog >= 0.5 && !get().scrobbled) {
set({ scrobbled: true });
const { scrobblingEnabled } = useAuthStore.getState();
if (scrobblingEnabled) {
scrobbleSong(track.id, Date.now());
}
}
}, 500);
// Prefetch next 3
get().prefetchUpcoming(idx + 1, newQueue);
},
onend: () => {
// ── stop ────────────────────────────────────────────────────────────────
stop: () => {
destroyHowl(activeHowl);
activeHowl = null;
clearProgress();
set({ isPlaying: false, progress: 0, currentTime: 0 });
const { repeatMode, currentTrack, queue } = get();
if (repeatMode === 'one' && currentTrack) {
get().playTrack(currentTrack, queue);
} else {
get().next();
}
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0, howl: null });
},
onstop: () => {
// ── playTrack ────────────────────────────────────────────────────────────
playTrack: (track, queue) => {
// Claim a new generation. Every callback created below captures `gen`.
// If playTrack() is called again before these callbacks fire, gen will
// no longer match playGeneration and the callbacks silently return.
const gen = ++playGeneration;
// Fully destroy the previous Howl — listeners first, then audio resources.
destroyHowl(activeHowl);
activeHowl = null;
clearProgress();
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
const state = get();
const newQueue = queue ?? state.queue;
const idx = newQueue.findIndex(t => t.id === track.id);
const howl = new Howl({
src: [buildStreamUrl(track.id)],
html5: true,
volume: state.volume,
});
activeHowl = howl;
// Commit state BEFORE howl.play() so queueIndex / currentTrack are
// already correct when the onplay / onend callbacks fire.
set({
currentTrack: track,
queue: newQueue,
queueIndex: idx >= 0 ? idx : 0,
howl,
progress: 0,
buffered: 0,
currentTime: 0,
scrobbled: false,
});
howl.on('play', () => {
if (playGeneration !== gen) return;
set({ isPlaying: true });
reportNowPlaying(track.id);
// Cold-start resume: seek to the position that was saved before the
// app was closed. A short delay lets the audio pipeline stabilise.
if (resumeFromTime !== null) {
const t = resumeFromTime;
resumeFromTime = null;
setTimeout(() => {
if (playGeneration === gen) activeHowl?.seek(t);
}, 80);
}
clearProgress(); // guard against duplicate onplay
progressInterval = setInterval(() => {
// Bail out if this interval belongs to a superseded generation
if (playGeneration !== gen) { clearProgress(); return; }
const h = activeHowl;
if (!h) return;
const raw = h.seek();
const cur = typeof raw === 'number' ? raw : 0;
const dur = h.duration() || 1;
// Buffered indicator via 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);
}
set({ currentTime: cur, progress: cur / dur, buffered: Math.min(1, totalBuf / audioNode.duration) });
} else {
set({ currentTime: cur, progress: cur / dur });
}
// Scrobble at 50%
if (cur / dur >= 0.5 && !get().scrobbled) {
set({ scrobbled: true });
const { scrobblingEnabled } = useAuthStore.getState();
if (scrobblingEnabled) scrobbleSong(track.id, Date.now());
}
}, 500);
});
howl.on('end', () => {
if (playGeneration !== gen) return;
// WebKit (and GStreamer on Linux) can fire spurious 'ended' events
// immediately after a direct audioNode.currentTime seek. Guard: if we
// are within 1 s of the last seek AND the playhead is not actually near
// the track end, treat this as a false alarm and ignore it.
if (Date.now() - lastSeekAt < 1000) {
const audioNode = (activeHowl as any)?._sounds?.[0]?._node as HTMLAudioElement | undefined;
const pos = audioNode ? audioNode.currentTime : (typeof activeHowl?.seek() === 'number' ? activeHowl.seek() as number : 0);
const dur = activeHowl?.duration() ?? 0;
if (dur > 0 && pos < dur - 1) return;
}
clearProgress();
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
const { repeatMode, currentTrack, queue: q } = get();
if (repeatMode === 'one' && currentTrack) {
get().playTrack(currentTrack, q);
} else {
get().next();
}
});
howl.on('playerror', (_, err) => {
if (playGeneration !== gen) return;
console.error('Howl play error:', err);
clearProgress();
set({ isPlaying: false });
});
howl.play();
syncQueueToServer(newQueue, track, 0);
},
// ── pause / resume / togglePlay ──────────────────────────────────────────
pause: () => {
activeHowl?.pause();
clearProgress();
set({ isPlaying: false });
},
});
howl.play();
set({ currentTrack: track, queue: newQueue, queueIndex: idx >= 0 ? idx : 0, howl, progress: 0, currentTime: 0 });
syncQueueToServer(newQueue, track, 0);
},
pause: () => {
get().howl?.pause();
clearProgress();
set({ isPlaying: false });
},
resume: () => {
const { howl, currentTrack } = get();
if (!howl || !currentTrack) return;
howl.play();
set({ isPlaying: true });
},
togglePlay: () => {
const { isPlaying } = get();
isPlaying ? get().pause() : get().resume();
},
next: () => {
const { queue, queueIndex, repeatMode } = get();
const nextIdx = queueIndex + 1;
if (nextIdx < queue.length) {
get().playTrack(queue[nextIdx], queue);
} else if (repeatMode === 'all' && queue.length > 0) {
get().playTrack(queue[0], queue);
}
},
previous: () => {
const { howl, queue, queueIndex, currentTime } = get();
if (currentTime > 3) {
howl?.seek(0);
set({ progress: 0, currentTime: 0 });
return;
}
const prevIdx = queueIndex - 1;
if (prevIdx >= 0) get().playTrack(queue[prevIdx], queue);
},
seek: (progress) => {
const { howl, currentTrack } = get();
if (!howl || !currentTrack) return;
const time = progress * (howl.duration() || currentTrack.duration);
// Update UI immediately for responsiveness
set({ progress, currentTime: time });
// Debounce the actual seek — GStreamer on Linux stalls if two seeks arrive
// before the first one completes (reproducible after the 2nd seek)
if (seekDebounce) clearTimeout(seekDebounce);
seekDebounce = setTimeout(() => {
get().howl?.seek(time);
seekDebounce = null;
}, 100);
},
setVolume: (v) => {
const clamped = Math.max(0, Math.min(1, v));
get().howl?.volume(clamped);
set({ volume: clamped });
},
setProgress: (t, duration) => {
set({ currentTime: t, progress: duration > 0 ? t / duration : 0 });
},
enqueue: (tracks) => {
set(state => {
const newQueue = [...state.queue, ...tracks];
syncQueueToServer(newQueue, state.currentTrack, state.currentTime);
return { queue: newQueue };
});
},
clearQueue: () => {
get().howl?.unload();
clearProgress();
set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 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();
const result = Array.from(queue);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
// Update queueIndex if the currently playing track moved
let newIndex = queueIndex;
if (currentTrack) {
newIndex = result.findIndex(t => t.id === currentTrack.id);
}
set({ queue: result, queueIndex: Math.max(0, newIndex) });
syncQueueToServer(result, currentTrack, get().currentTime);
},
removeTrack: (index: number) => {
const { queue, queueIndex } = get();
const newQueue = [...queue];
newQueue.splice(index, 1);
// If we removed the currently playing track, stop playback?
// Usually wait until it finishes or user skips. We'll just update state.
set({ queue: newQueue, queueIndex: Math.min(queueIndex, newQueue.length - 1) });
syncQueueToServer(newQueue, get().currentTrack, get().currentTime);
},
initializeFromServerQueue: async () => {
try {
const q = await getPlayQueue();
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,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
let currentTrack = mappedTracks[0];
let queueIndex = 0;
if (q.current) {
const idx = mappedTracks.findIndex(t => t.id === q.current);
if (idx >= 0) {
currentTrack = mappedTracks[idx];
queueIndex = idx;
}
resume: () => {
const { currentTrack, queue, currentTime } = get();
if (!currentTrack) return;
if (activeHowl) {
activeHowl.play();
set({ isPlaying: true });
return;
}
set({
queue: mappedTracks,
queueIndex,
currentTrack,
// Convert position from ms to s
currentTime: q.position ? q.position / 1000 : 0
// Cold start after app relaunch — Howl was not persisted.
resumeFromTime = currentTime > 0 ? currentTime : null;
get().playTrack(currentTrack, queue);
},
togglePlay: () => {
// Guard: rapid double-clicks send pause→play (or play→pause) before
// GStreamer/WebKit has finished the previous state transition, causing
// the audio pipeline to hang for several seconds. Ignore the second
// click if it arrives within 300 ms of the first.
if (togglePlayLock) return;
togglePlayLock = true;
setTimeout(() => { togglePlayLock = false; }, 300);
const { isPlaying } = get();
isPlaying ? get().pause() : get().resume();
},
// ── next / previous ──────────────────────────────────────────────────────
next: () => {
const { queue, queueIndex, repeatMode } = get();
const nextIdx = queueIndex + 1;
if (nextIdx < queue.length) {
get().playTrack(queue[nextIdx], queue);
} else if (repeatMode === 'all' && queue.length > 0) {
get().playTrack(queue[0], queue);
} else {
// End of queue — clean stop without destroying currentTrack metadata
destroyHowl(activeHowl);
activeHowl = null;
clearProgress();
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0, howl: null });
}
},
previous: () => {
const { queue, queueIndex, currentTime } = get();
if (currentTime > 3) {
activeHowl?.seek(0);
set({ progress: 0, currentTime: 0 });
return;
}
const prevIdx = queueIndex - 1;
if (prevIdx >= 0) get().playTrack(queue[prevIdx], queue);
},
// ── seek ─────────────────────────────────────────────────────────────────
// Debounced 100 ms to collapse rapid slider drags into one actual seek.
// We bypass Howler's seek() entirely and set currentTime directly on the
// underlying <audio> element. Howler's seek() internally calls pause() +
// play() which can fire spurious ended/stop events on some WebKit versions,
// especially on the second consecutive seek.
seek: (progress) => {
const { currentTrack } = get();
if (!activeHowl || !currentTrack) return;
const dur = activeHowl.duration() || currentTrack.duration;
if (!dur || !isFinite(dur)) return;
// Clamp slightly before end to prevent accidentally triggering 'ended'
const time = Math.max(0, Math.min(progress * dur, dur - 0.25));
set({ progress: time / dur, currentTime: time });
lastSeekAt = Date.now();
if (seekDebounce) clearTimeout(seekDebounce);
seekDebounce = setTimeout(() => {
seekDebounce = null;
const audioNode = (activeHowl as any)?._sounds?.[0]?._node as HTMLAudioElement | undefined;
if (audioNode && isFinite(time)) {
audioNode.currentTime = time;
} else {
activeHowl?.seek(time);
}
}, 100);
},
// ── volume ───────────────────────────────────────────────────────────────
setVolume: (v) => {
const clamped = Math.max(0, Math.min(1, v));
activeHowl?.volume(clamped);
set({ volume: clamped });
},
setProgress: (t, duration) => {
set({ currentTime: t, progress: duration > 0 ? t / duration : 0 });
},
// ── queue management ─────────────────────────────────────────────────────
enqueue: (tracks) => {
set(state => {
const newQueue = [...state.queue, ...tracks];
syncQueueToServer(newQueue, state.currentTrack, state.currentTime);
return { queue: newQueue };
});
}
} catch (e) {
console.error('Failed to initialize queue from server', e);
},
clearQueue: () => {
destroyHowl(activeHowl);
activeHowl = null;
clearProgress();
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0, howl: null });
syncQueueToServer([], null, 0);
},
reorderQueue: (startIndex, endIndex) => {
const { queue, queueIndex, currentTrack } = get();
const result = Array.from(queue);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
let newIndex = queueIndex;
if (currentTrack) newIndex = result.findIndex(t => t.id === currentTrack.id);
set({ queue: result, queueIndex: Math.max(0, newIndex) });
syncQueueToServer(result, currentTrack, get().currentTime);
},
removeTrack: (index) => {
const { queue, queueIndex } = get();
const newQueue = [...queue];
newQueue.splice(index, 1);
set({ queue: newQueue, queueIndex: Math.min(queueIndex, newQueue.length - 1) });
syncQueueToServer(newQueue, get().currentTrack, get().currentTime);
},
// ── server queue restore ─────────────────────────────────────────────────
initializeFromServerQueue: async () => {
try {
const q = await getPlayQueue();
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, artistId: s.artistId, duration: s.duration,
coverArt: s.coverArt, track: s.track, year: s.year,
bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
let currentTrack = mappedTracks[0];
let queueIndex = 0;
if (q.current) {
const idx = mappedTracks.findIndex(t => t.id === q.current);
if (idx >= 0) { currentTrack = mappedTracks[idx]; queueIndex = idx; }
}
set({
queue: mappedTracks,
queueIndex,
currentTrack,
currentTime: q.position ? q.position / 1000 : 0,
});
}
} catch (e) {
console.error('Failed to initialize queue from server', e);
}
},
}),
{
name: 'psysonic-player',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
volume: state.volume,
repeatMode: state.repeatMode,
} as Partial<PlayerState>),
}
},
}), {
name: 'psysonic-player',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
volume: state.volume,
repeatMode: state.repeatMode,
} as Partial<PlayerState>),
}));
)
);
+166 -177
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;
}
@@ -23,7 +23,9 @@
background-position: center;
transition: transform 8s ease, opacity 0.8s ease, filter 0.8s ease;
transform: scale(1.05);
filter: blur(12px);
}
.hero:hover .hero-bg { filter: blur(8px); }
.hero:hover .hero-bg { transform: scale(1); }
.hero-dots {
@@ -74,8 +76,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;
@@ -567,8 +569,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 +582,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 +596,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 +896,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,13 +917,47 @@
to { transform: translateY(0); opacity: 1; }
}
/* Blurred background */
/* ── 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);
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: opacity;
@@ -922,17 +968,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 +1014,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 +1023,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: flex-start;
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 +1068,8 @@
display: block;
}
.fs-cover-placeholder {
width: 100%;
height: 100%;
background: var(--ctp-surface0);
display: flex;
align-items: center;
@@ -1010,32 +1077,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: #ffffff;
margin: 0 0 4px;
line-height: 1.2;
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(--ctp-lavender);
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: rgba(255, 255, 255, 0.6);
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 +1117,8 @@
border: 1px solid rgba(255,255,255,0.1);
padding: 2px 8px;
border-radius: var(--radius-full);
color: rgba(255, 255, 255, 0.45);
margin-top: 6px;
color: rgba(255, 255, 255, 0.35);
margin-top: 8px;
}
/* Progress bar */
@@ -1061,8 +1129,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 +1141,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 +1157,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: 11px;
gap: 14px;
}
.fs-btn {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
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: 25px; height: 25px; }
.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: 48px;
height: 48px;
width: 54px;
height: 54px;
background: linear-gradient(135deg, var(--ctp-mauve), var(--ctp-lavender));
color: var(--ctp-crust);
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.5);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
}
.fs-btn-play:hover {
background: linear-gradient(135deg, var(--ctp-lavender), var(--ctp-mauve));
color: var(--ctp-crust);
transform: scale(1.06);
box-shadow: 0 8px 28px rgba(0,0,0,0.6);
}
/* Right column: upcoming tracks — top-aligned with cover */
.fs-right {
display: flex;
flex-direction: column;
gap: 12px;
flex-shrink: 0;
width: clamp(240px, 26vw, 380px);
align-self: flex-start;
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: rgba(255, 255, 255, 0.4);
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: rgba(255, 255, 255, 0.9);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fs-upcoming-artist {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fs-upcoming-dur {
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
transform: scale(1.07);
box-shadow: 0 10px 34px rgba(0,0,0,0.6);
}
/* Chat */
+14 -9
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 ─── */
@@ -370,7 +369,8 @@
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
gap: 2px;
padding: 12px 0;
}
.player-buttons {
@@ -507,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 {
@@ -540,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);
@@ -567,7 +572,7 @@
}
.queue-divider {
padding: var(--space-4) var(--space-5) 0;
padding: var(--space-3) var(--space-4) 0;
flex-shrink: 0;
}
+2 -2
View File
@@ -580,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);