mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-23 15:55:45 +00:00
Compare commits
10 Commits
v1.0.5
...
app-v1.0.9
| Author | SHA1 | Date | |
|---|---|---|---|
| 32571a2986 | |||
| c9b4bc091e | |||
| 409c20a79e | |||
| 3384db76cd | |||
| e889a76f5f | |||
| eb011bdfdf | |||
| 1d23b21f6f | |||
| 5528123193 | |||
| 85823ff4c4 | |||
| e36a81f847 |
@@ -117,7 +117,7 @@ jobs:
|
||||
APPIMAGE=$(find src-tauri/target/release/bundle/appimage -name "*.AppImage" -not -name "*.tar.gz" | head -1)
|
||||
echo "Patching: $APPIMAGE"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 ./"$APPIMAGE" --appimage-extract
|
||||
sed -i '/^exec /i export WEBKIT_DISABLE_COMPOSITING_MODE=1\nexport WEBKIT_DISABLE_DMABUF_RENDERER=1' squashfs-root/AppRun
|
||||
sed -i '/^exec /i export WEBKIT_DISABLE_COMPOSITING_MODE=1\nexport WEBKIT_DISABLE_DMABUF_RENDERER=1\nexport GDK_BACKEND=x11' squashfs-root/AppRun
|
||||
wget -q "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" \
|
||||
-O appimagetool
|
||||
chmod +x appimagetool
|
||||
|
||||
@@ -5,6 +5,57 @@ 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.9] - 2026-03-13
|
||||
|
||||
### Added
|
||||
- **Gapless Playback**: The next track's audio pipeline is silently pre-warmed before the current track ends, eliminating the gap between songs — especially noticeable on live albums and concept records.
|
||||
- **Pre-caching**: Prefetched Howl instances are now actually reused for playback, giving near-instant track transitions instead of a new HTTP connection each time.
|
||||
- **Buffered Progress Indicator**: The seek bar now shows a secondary fill indicating how much of the current track has been buffered by the browser — visible in both the Player Bar and Fullscreen Player.
|
||||
- **Resume on Startup**: Pressing Play after launching the app now resumes the last track at the saved playback position instead of doing nothing.
|
||||
- **Album Track Hover Play Button**: Hovering over a track number in Album Detail reveals a play button for quick single-click playback.
|
||||
- **Ken Burns Background**: The Fullscreen Player background now slowly drifts and zooms (Ken Burns effect) for a more cinematic feel.
|
||||
- **F11 Fullscreen**: Toggle native borderless fullscreen with F11.
|
||||
- **Compact Queue Now-Playing**: The current track block in the Queue Panel is now a slim horizontal strip (72 px thumbnail) instead of a full-width cover, freeing up significantly more space for the queue list on smaller screens.
|
||||
|
||||
### Fixed
|
||||
- **GStreamer Seek Stability**: Implemented a three-layer recovery system for Linux/GStreamer seek hangs: (1) seek queuing to prevent overlapping GStreamer seeks, (2) a 2-second watchdog that triggers automatic recovery if a seek never completes, (3) an 8-second hang detector that silently recreates the audio pipeline and resumes from the last known position if playback freezes entirely.
|
||||
- **Fullscreen Player**: Removed drop shadow from cover art — looks cleaner on lighter artist backgrounds.
|
||||
|
||||
### Changed
|
||||
- **Hero Section**: Increased height (300 → 360 px) and cover art size (180 → 220 px) to prevent long album titles from clipping.
|
||||
- **Player Bar**: Controls and progress bar moved closer together for a more balanced layout.
|
||||
|
||||
## [1.0.8] - 2026-03-13
|
||||
|
||||
### Added
|
||||
- **Ambient Stage**: Completely redesigned Fullscreen Player. Experience an immersive atmosphere with drifting color orbs, a "breathing" cover animation, and high-resolution artist backgrounds.
|
||||
- **Improved Drag & Drop**: Rewritten Play Queue reordering for rock-solid reliability on macOS (WKWebView) and Windows (WebView2).
|
||||
|
||||
### Fixed
|
||||
- **Linux Audio Stability**: Resolved playback stuttering when seeking under GStreamer by implementing a robust pause-seek-play sequence.
|
||||
- **Data Integration**: Standardized `artistId` propagation across all track sources for better metadata consistency.
|
||||
|
||||
## [1.0.7] - 2026-03-13
|
||||
|
||||
### Added
|
||||
- **Update Notifications**: Integrated a native update check system in the sidebar that notifies you when a new version is available on GitHub.
|
||||
- **Improved Settings**: Refined layout and styling for a cleaner settings experience.
|
||||
|
||||
### Fixed
|
||||
- **UI/UX Refinements**: Polished sidebar animations and layout for better visual consistency.
|
||||
- **i18n**: Added missing translations for update notifications and system status.
|
||||
|
||||
## [1.0.6] - 2026-03-13
|
||||
|
||||
### Added
|
||||
- **Extended Themes**: Selection expanded to 8 themes, including the complete Nord series (Nord, Snowstorm, Frost, Aurora).
|
||||
- **Light Theme Support**: Enhanced readability for Hero and Fullscreen Player components when using light themes (Latte, Snowstorm).
|
||||
|
||||
### Fixed
|
||||
- **Linux/Wayland Compatibility**: Fixed immediate crash on Wayland environments by forcing X11 backend for the AppImage.
|
||||
- **Playback Stability**: Introduced seek debouncing to prevent audio stalls on Linux/GStreamer.
|
||||
- **Windows Integration**: Improved drag-and-drop compatibility for systems using WebView2.
|
||||
|
||||
## [1.0.5] - 2026-03-12
|
||||
|
||||
### Added
|
||||
|
||||
@@ -16,26 +16,26 @@ 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.
|
||||
|
||||
|
||||

|
||||
|
||||
## ✨ 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.
|
||||
- **Linux (drag & drop cursor feedback)**: Due to a WebKitGTK limitation, the drag cursor does not reflect the drop operation type — it may appear as a "forbidden" symbol or show no indicator at all, depending on the desktop environment. Drag and drop itself works correctly.
|
||||
|
||||
## 📥 Installation
|
||||
|
||||
@@ -91,10 +91,3 @@ Contributions are completely welcome! Whether it is translating the app into a n
|
||||
## 📄 License
|
||||
|
||||
Distributed under the MIT License. See `LICENSE` for more information.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<i>Core Components developed with <b>Claude Code</b></i><br/>
|
||||
<i>UI/UX developed with <b>Gemini 2.0 Flash</b> using <b>Antigravity</b></i>
|
||||
</p>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.9",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+1
-1
@@ -2732,7 +2732,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.0.5"
|
||||
version = "1.0.9"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.0.5"
|
||||
version = "1.0.9"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
|
||||
@@ -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 @@
|
||||
{"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"]}}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.9",
|
||||
"identifier": "dev.psysonic.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
+7
-1
@@ -184,9 +184,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;
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface SubsonicSong {
|
||||
artist: string;
|
||||
album: string;
|
||||
albumId: string;
|
||||
artistId?: string;
|
||||
duration: number;
|
||||
track?: number;
|
||||
discNumber?: number;
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function AlbumCard({ album }: AlbumCardProps) {
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
e.dataTransfer.setData('application/json', JSON.stringify({
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({
|
||||
type: 'album',
|
||||
id: album.id,
|
||||
name: album.name,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,8 @@ function formatTime(seconds: number): string {
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// ─── Crossfading blurred background — two stacked divs for true crossfade ───
|
||||
// ─── 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 +25,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 +49,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 +72,10 @@ const FsProgress = memo(function FsProgress({ duration }: { duration: number })
|
||||
type="range" min={0} max={1} step={0.001}
|
||||
value={progress}
|
||||
onChange={handleSeek}
|
||||
style={{ '--pct': `${progress * 100}%` } as React.CSSProperties}
|
||||
style={{
|
||||
'--pct': `${pct}%`,
|
||||
'--buf': `${buf}%`,
|
||||
} as React.CSSProperties}
|
||||
aria-label="progress"
|
||||
/>
|
||||
</div>
|
||||
@@ -80,44 +84,53 @@ const FsProgress = memo(function FsProgress({ duration }: { duration: number })
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
|
||||
// ─── Isolated play/pause button (subscribes to isPlaying only) ───
|
||||
// ─── Play/Pause button (isolated — subscribes to isPlaying only) ──────────────
|
||||
const FsPlayBtn = memo(function FsPlayBtn() {
|
||||
const { t } = useTranslation();
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const togglePlay = usePlayerStore(s => s.togglePlay);
|
||||
return (
|
||||
<button className="fs-btn fs-btn-play" onClick={togglePlay} aria-label={isPlaying ? t('player.pause') : t('player.play')}>
|
||||
{isPlaying ? <Pause size={36} /> : <Play size={36} fill="currentColor" />}
|
||||
{isPlaying ? <Pause size={25} /> : <Play size={25} fill="currentColor" />}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Main component ────────────────────────────────────────────────────────────
|
||||
interface FullscreenPlayerProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
const { t } = useTranslation();
|
||||
// Static/slow-changing state only — does NOT subscribe to progress or currentTime
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const repeatMode = usePlayerStore(s => s.repeatMode);
|
||||
const queue = usePlayerStore(s => s.queue);
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const next = usePlayerStore(s => s.next);
|
||||
const previous = usePlayerStore(s => s.previous);
|
||||
const stop = usePlayerStore(s => s.stop);
|
||||
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '';
|
||||
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '';
|
||||
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
|
||||
// useCachedUrl must be called unconditionally (hook rules)
|
||||
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey);
|
||||
const upcoming = queue.slice(queueIndex + 1, queueIndex + 15);
|
||||
|
||||
// Close on Escape
|
||||
// Fetch artist image for background — fall back to cover art if unavailable
|
||||
const [artistBgUrl, setArtistBgUrl] = useState<string>('');
|
||||
useEffect(() => {
|
||||
setArtistBgUrl('');
|
||||
const artistId = currentTrack?.artistId;
|
||||
if (!artistId) return;
|
||||
let cancelled = false;
|
||||
getArtistInfo(artistId).then(info => {
|
||||
if (!cancelled && info.largeImageUrl) setArtistBgUrl(info.largeImageUrl);
|
||||
}).catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [currentTrack?.artistId]);
|
||||
|
||||
const bgUrl = artistBgUrl || resolvedCoverUrl;
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', onKey);
|
||||
@@ -126,88 +139,77 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
|
||||
return (
|
||||
<div className="fs-player" role="dialog" aria-modal="true" aria-label={t('player.fullscreen')}>
|
||||
{/* Crossfading blurred background */}
|
||||
<FsBg url={resolvedCoverUrl} />
|
||||
|
||||
{/* Layer 1 — blurred artist image (falls back to cover art) */}
|
||||
<FsBg url={bgUrl} />
|
||||
<div className="fs-bg-overlay" aria-hidden="true" />
|
||||
|
||||
{/* Close button */}
|
||||
<button className="fs-close" onClick={onClose} aria-label={t('player.closeFullscreen')} data-tooltip={t('player.closeTooltip')} data-tooltip-pos="bottom">
|
||||
{/* Layer 2 — drifting color orbs */}
|
||||
<div className="fs-orb fs-orb-1" aria-hidden="true" />
|
||||
<div className="fs-orb fs-orb-2" aria-hidden="true" />
|
||||
<div className="fs-orb fs-orb-3" aria-hidden="true" />
|
||||
|
||||
{/* Close */}
|
||||
<button className="fs-close" onClick={onClose} aria-label={t('player.closeFullscreen')}>
|
||||
<ChevronDown size={28} />
|
||||
</button>
|
||||
|
||||
{/* Two-column layout: left = cover + info + controls, right = playlist */}
|
||||
<div className="fs-layout">
|
||||
{/* Center stage — everything vertically + horizontally centered */}
|
||||
<div className="fs-stage">
|
||||
|
||||
{/* Left column */}
|
||||
<div className="fs-left">
|
||||
<div className="fs-cover-wrap">
|
||||
{coverUrl ? (
|
||||
<CachedImage src={coverUrl} cacheKey={coverKey} alt={`${currentTrack?.album} Cover`} className="fs-cover" />
|
||||
) : (
|
||||
<div className="fs-cover fs-cover-placeholder"><Music size={72} /></div>
|
||||
)}
|
||||
</div>
|
||||
<p className="fs-artist">{currentTrack?.artist ?? '—'}</p>
|
||||
|
||||
<div className="fs-track-info">
|
||||
<h1 className="fs-title">{currentTrack?.title ?? '—'}</h1>
|
||||
<p className="fs-artist">{currentTrack?.artist ?? '—'}</p>
|
||||
<p className="fs-album">{currentTrack?.album ?? '—'}{currentTrack?.year ? ` · ${currentTrack.year}` : ''}</p>
|
||||
{(currentTrack?.bitRate || currentTrack?.suffix) && (
|
||||
<span className="fs-codec">
|
||||
{[currentTrack.suffix?.toUpperCase(), currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : ''].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FsProgress duration={duration} />
|
||||
|
||||
<div className="fs-controls">
|
||||
<button className="fs-btn fs-btn-sm" onClick={stop} data-tooltip="Stop">
|
||||
<Square size={20} fill="currentColor" />
|
||||
</button>
|
||||
<button className="fs-btn" onClick={previous} aria-label={t('player.prev')}>
|
||||
<SkipBack size={28} />
|
||||
</button>
|
||||
<FsPlayBtn />
|
||||
<button className="fs-btn" onClick={next} aria-label={t('player.next')}>
|
||||
<SkipForward size={28} />
|
||||
</button>
|
||||
<button
|
||||
className={`fs-btn fs-btn-sm ${repeatMode !== 'off' ? 'active' : ''}`}
|
||||
onClick={toggleRepeat}
|
||||
data-tooltip={`${t('player.repeat')}: ${repeatMode === 'off' ? t('player.repeatOff') : repeatMode === 'all' ? t('player.repeatAll') : t('player.repeatOne')}`}
|
||||
>
|
||||
{repeatMode === 'one' ? <Repeat1 size={20} /> : <Repeat size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="fs-cover-wrap">
|
||||
{coverUrl ? (
|
||||
<CachedImage
|
||||
src={coverUrl}
|
||||
cacheKey={coverKey}
|
||||
alt={`${currentTrack?.album} Cover`}
|
||||
className="fs-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="fs-cover fs-cover-placeholder"><Music size={72} /></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="fs-track-info">
|
||||
<h1 className="fs-title">{currentTrack?.title ?? '—'}</h1>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -138,7 +138,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('');
|
||||
|
||||
@@ -16,7 +16,7 @@ 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 { 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 +30,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 = {
|
||||
@@ -83,7 +85,7 @@ export default function PlayerBar() {
|
||||
</button>
|
||||
|
||||
<button className="player-btn" onClick={previous} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
|
||||
<SkipBack size={18} />
|
||||
<SkipBack size={20} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -92,11 +94,11 @@ export default function PlayerBar() {
|
||||
aria-label={isPlaying ? t('player.pause') : t('player.play')}
|
||||
data-tooltip={isPlaying ? t('player.pause') : t('player.play')}
|
||||
>
|
||||
{isPlaying ? <Pause size={20} /> : <Play size={20} fill="currentColor" />}
|
||||
{isPlaying ? <Pause size={22} /> : <Play size={22} fill="currentColor" />}
|
||||
</button>
|
||||
|
||||
<button className="player-btn" onClick={next} aria-label={t('player.next')} data-tooltip={t('player.next')}>
|
||||
<SkipForward size={18} />
|
||||
<SkipForward size={20} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Track, usePlayerStore } from '../store/playerStore';
|
||||
import { Play, Music, Star, X, Trash2, Save, FolderOpen } from 'lucide-react';
|
||||
import { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||
@@ -124,6 +124,11 @@ export default function QueuePanel() {
|
||||
|
||||
const [draggedIdx, setDraggedIdx] = useState<number | null>(null);
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||
const isDraggingInternalRef = useRef(false);
|
||||
// Refs mirror state so drop handler always reads fresh values even when
|
||||
// macOS WKWebView fires dragend before drop (spec violation).
|
||||
const draggedIdxRef = useRef<number | null>(null);
|
||||
const dragOverIdxRef = useRef<number | null>(null);
|
||||
|
||||
const [saveModalOpen, setSaveModalOpen] = useState(false);
|
||||
const [loadModalOpen, setLoadModalOpen] = useState(false);
|
||||
@@ -142,69 +147,84 @@ export default function QueuePanel() {
|
||||
};
|
||||
|
||||
const onDragStart = (e: React.DragEvent, index: number) => {
|
||||
isDraggingInternalRef.current = true;
|
||||
draggedIdxRef.current = index;
|
||||
setDraggedIdx(index);
|
||||
e.dataTransfer.effectAllowed = 'copyMove';
|
||||
e.dataTransfer.setData('application/json', JSON.stringify({
|
||||
type: 'queue_reorder',
|
||||
index
|
||||
}));
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
// Store index in dataTransfer too — on macOS WKWebView dragend fires before
|
||||
// drop, so the ref will already be null; dataTransfer survives that race.
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'queue_reorder', index }));
|
||||
};
|
||||
|
||||
const onDragEnterItem = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy';
|
||||
};
|
||||
|
||||
const onDragOverItem = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy';
|
||||
dragOverIdxRef.current = index;
|
||||
setDragOverIdx(index);
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
isDraggingInternalRef.current = false;
|
||||
draggedIdxRef.current = null;
|
||||
dragOverIdxRef.current = null;
|
||||
setDraggedIdx(null);
|
||||
setDragOverIdx(null);
|
||||
};
|
||||
|
||||
const onDropQueue = async (e: React.DragEvent, dropIndex?: number) => {
|
||||
const onDropQueue = async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Capture refs before resetting — dragend may have already cleared them on Mac.
|
||||
const fromIdx = draggedIdxRef.current;
|
||||
const toIdx = dragOverIdxRef.current ?? queue.length;
|
||||
|
||||
isDraggingInternalRef.current = false;
|
||||
draggedIdxRef.current = null;
|
||||
dragOverIdxRef.current = null;
|
||||
setDraggedIdx(null);
|
||||
setDragOverIdx(null);
|
||||
|
||||
|
||||
// Read dataTransfer — set during dragstart, outlives dragend on all platforms.
|
||||
let parsedData: any = null;
|
||||
try {
|
||||
const dataStr = e.dataTransfer.getData('application/json');
|
||||
if (!dataStr) return;
|
||||
const data = JSON.parse(dataStr);
|
||||
|
||||
if (data.type === 'queue_reorder') {
|
||||
const fromIdx = data.index;
|
||||
const targetIdx = dropIndex !== undefined ? dropIndex : queue.length;
|
||||
if (fromIdx !== undefined && fromIdx !== targetIdx) {
|
||||
reorderQueue(fromIdx, targetIdx);
|
||||
}
|
||||
} else if (data.type === 'song') {
|
||||
const track = data.track;
|
||||
if (dropIndex !== undefined) {
|
||||
// If dropped on a specific item, we might want to insert it there.
|
||||
// For now we just enqueue it at the end to keep it simple, or insert it.
|
||||
// Since we don't have an insert method, we use enqueue (appends to end).
|
||||
enqueue([track]);
|
||||
} else {
|
||||
enqueue([track]);
|
||||
}
|
||||
} else if (data.type === 'album') {
|
||||
const albumData = await getAlbum(data.id);
|
||||
const tracks: Track[] = albumData.songs.map(s => ({
|
||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track,
|
||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||
}));
|
||||
enqueue(tracks);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Drop error', err);
|
||||
const raw = e.dataTransfer.getData('text/plain');
|
||||
if (raw) parsedData = JSON.parse(raw);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Internal reorder: prefer ref value (fast path), fall back to dataTransfer
|
||||
// for the Mac dragend-before-drop race condition.
|
||||
const reorderFrom = fromIdx ?? (parsedData?.type === 'queue_reorder' ? parsedData.index : null);
|
||||
if (reorderFrom !== null) {
|
||||
if (reorderFrom !== toIdx) reorderQueue(reorderFrom, toIdx);
|
||||
return;
|
||||
}
|
||||
|
||||
// External drop (song / album dragged from elsewhere in the app)
|
||||
if (!parsedData) return;
|
||||
if (parsedData.type === 'song') {
|
||||
enqueue([parsedData.track]);
|
||||
} else if (parsedData.type === 'album') {
|
||||
const albumData = await getAlbum(parsedData.id);
|
||||
const tracks: Track[] = albumData.songs.map(s => ({
|
||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
|
||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||
}));
|
||||
enqueue(tracks);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="queue-panel"
|
||||
onDragOver={e => e.preventDefault()}
|
||||
onDrop={e => onDropQueue(e)}
|
||||
<aside
|
||||
className="queue-panel"
|
||||
onDragEnter={e => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; }}
|
||||
onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; }}
|
||||
onDrop={onDropQueue}
|
||||
style={{
|
||||
borderLeftWidth: isQueueVisible ? 1 : 0
|
||||
}}
|
||||
@@ -233,7 +253,7 @@ 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>
|
||||
)}
|
||||
@@ -293,12 +313,9 @@ export default function QueuePanel() {
|
||||
}}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, idx)}
|
||||
onDragEnter={(e) => onDragEnterItem(e)}
|
||||
onDragOver={(e) => onDragOverItem(e, idx)}
|
||||
onDragEnd={onDragEnd}
|
||||
onDrop={(e) => {
|
||||
e.stopPropagation();
|
||||
onDropQueue(e, idx);
|
||||
}}
|
||||
style={dragStyle}
|
||||
>
|
||||
<div className="queue-item-info">
|
||||
@@ -339,7 +356,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) {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { version as appVersion } from '../../package.json';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle, ListMusic,
|
||||
PanelLeftClose, PanelLeft, HelpCircle, Dices
|
||||
PanelLeftClose, PanelLeft, HelpCircle, Dices, ArrowUpCircle
|
||||
} from 'lucide-react';
|
||||
|
||||
const PsysonicLogo = () => (
|
||||
@@ -21,14 +22,72 @@ const navItems = [
|
||||
{ icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites' },
|
||||
];
|
||||
|
||||
export default function Sidebar({
|
||||
isCollapsed = false,
|
||||
toggleCollapse
|
||||
}: {
|
||||
function isNewer(latest: string, current: string): boolean {
|
||||
const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number);
|
||||
const [lMaj, lMin, lPat] = parse(latest);
|
||||
const [cMaj, cMin, cPat] = parse(current);
|
||||
if (lMaj !== cMaj) return lMaj > cMaj;
|
||||
if (lMin !== cMin) return lMin > cMin;
|
||||
return lPat > cPat;
|
||||
}
|
||||
|
||||
function UpdateToast({ isCollapsed, latestVersion }: { isCollapsed: boolean; latestVersion: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<div className="update-toast-icon" style={{ marginTop: 'auto' }} title={`${t('sidebar.updateAvailable')}: ${latestVersion}`}>
|
||||
<ArrowUpCircle size={20} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="update-toast">
|
||||
<div className="update-toast-header">
|
||||
<ArrowUpCircle size={14} />
|
||||
<span className="update-toast-label">{t('sidebar.updateAvailable')}</span>
|
||||
</div>
|
||||
<div className="update-toast-version">{t('sidebar.updateReady', { version: latestVersion })}</div>
|
||||
<a
|
||||
className="update-toast-link"
|
||||
href="https://github.com/Psychotoxical/psysonic/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t('sidebar.updateLink')}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Sidebar({
|
||||
isCollapsed = false,
|
||||
toggleCollapse
|
||||
}: {
|
||||
isCollapsed?: boolean;
|
||||
toggleCollapse?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [latestVersion, setLatestVersion] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const res = await fetch('https://api.github.com/repos/Psychotoxical/psysonic/releases/latest');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const tag: string = data.tag_name ?? '';
|
||||
if (!cancelled && tag && isNewer(tag, appVersion)) {
|
||||
setLatestVersion(tag.startsWith('v') ? tag : `v${tag}`);
|
||||
}
|
||||
} catch {
|
||||
// network unavailable — silently skip
|
||||
}
|
||||
}, 1500);
|
||||
return () => { cancelled = true; clearTimeout(timer); };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<aside className={`sidebar animate-slide-in ${isCollapsed ? 'collapsed' : ''}`}>
|
||||
@@ -61,10 +120,11 @@ export default function Sidebar({
|
||||
))}
|
||||
|
||||
{!isCollapsed && <span className="nav-section-label" style={{ marginTop: 'auto' }}>{t('sidebar.system')}</span>}
|
||||
{latestVersion && <UpdateToast isCollapsed={isCollapsed} latestVersion={latestVersion} />}
|
||||
<NavLink
|
||||
to="/statistics"
|
||||
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
|
||||
style={isCollapsed ? { marginTop: 'auto' } : undefined}
|
||||
style={isCollapsed && !latestVersion ? { marginTop: 'auto' } : undefined}
|
||||
title={isCollapsed ? t('sidebar.statistics') : undefined}
|
||||
>
|
||||
<BarChart3 size={isCollapsed ? 22 : 18} />
|
||||
|
||||
+10
-2
@@ -18,7 +18,10 @@ const enTranslation = {
|
||||
settings: 'Settings',
|
||||
help: 'Help',
|
||||
expand: 'Expand Sidebar',
|
||||
collapse: 'Collapse Sidebar'
|
||||
collapse: 'Collapse Sidebar',
|
||||
updateAvailable: 'Update available',
|
||||
updateReady: '{{version}} is ready',
|
||||
updateLink: 'Go to release →'
|
||||
},
|
||||
home: {
|
||||
starred: 'Personal Favorites',
|
||||
@@ -132,6 +135,7 @@ const enTranslation = {
|
||||
remixTooltip: 'Load new random songs',
|
||||
playAll: 'Play All',
|
||||
trackTitle: 'Title',
|
||||
trackArtist: 'Artist',
|
||||
trackAlbum: 'Album',
|
||||
trackFavorite: 'Favorite',
|
||||
trackDuration: 'Duration',
|
||||
@@ -371,7 +375,10 @@ const deTranslation = {
|
||||
settings: 'Einstellungen',
|
||||
help: 'Hilfe',
|
||||
expand: 'Sidebar einblenden',
|
||||
collapse: 'Sidebar ausblenden'
|
||||
collapse: 'Sidebar ausblenden',
|
||||
updateAvailable: 'Update verfügbar',
|
||||
updateReady: '{{version}} ist bereit',
|
||||
updateLink: 'Zum Release →'
|
||||
},
|
||||
home: {
|
||||
starred: 'Persönliche Favoriten',
|
||||
@@ -485,6 +492,7 @@ const deTranslation = {
|
||||
remixTooltip: 'Neue Songs laden',
|
||||
playAll: 'Alle abspielen',
|
||||
trackTitle: 'Titel',
|
||||
trackArtist: 'Künstler',
|
||||
trackAlbum: 'Album',
|
||||
trackFavorite: 'Favorit',
|
||||
trackDuration: 'Dauer',
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -405,12 +406,14 @@ export default function AlbumDetail() {
|
||||
<div
|
||||
key={song.id}
|
||||
className={`track-row${hasVariousArtists ? ' track-row-va' : ''}`}
|
||||
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,21 @@ export default function AlbumDetail() {
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
const track = {
|
||||
id: song.id, title: song.title, artist: song.artist, album: song.album,
|
||||
albumId: song.albumId, duration: song.duration, coverArt: song.coverArt, track: song.track,
|
||||
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track,
|
||||
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
|
||||
};
|
||||
e.dataTransfer.setData('application/json', JSON.stringify({ type: 'song', track }));
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
|
||||
}}
|
||||
>
|
||||
<div className="track-num" style={{ textAlign: 'center' }}>{song.track ?? i + 1}</div>
|
||||
<div
|
||||
className="track-num"
|
||||
style={{ textAlign: 'center', cursor: hoveredSongId === song.id ? 'pointer' : 'default', color: hoveredSongId === song.id ? 'var(--accent)' : undefined }}
|
||||
onClick={() => handlePlaySong(song)}
|
||||
>
|
||||
{hoveredSongId === 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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -72,10 +72,10 @@ export default function Favorites() {
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
const track = {
|
||||
id: song.id, title: song.title, artist: song.artist, album: song.album,
|
||||
albumId: song.albumId, duration: song.duration, coverArt: song.coverArt, track: song.track,
|
||||
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track,
|
||||
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
|
||||
};
|
||||
e.dataTransfer.setData('application/json', JSON.stringify({ type: 'song', track }));
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
|
||||
}}
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -78,9 +78,10 @@ export default function RandomMix() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="tracklist">
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '36px 1fr 1fr 60px 80px' }}>
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 60px 80px' }}>
|
||||
<span></span>
|
||||
<span>{t('randomMix.trackTitle')}</span>
|
||||
<span>{t('randomMix.trackArtist')}</span>
|
||||
<span>{t('randomMix.trackAlbum')}</span>
|
||||
<span style={{ textAlign: 'center' }}>{t('randomMix.trackFavorite')}</span>
|
||||
<span style={{ textAlign: 'right' }}>{t('randomMix.trackDuration')}</span>
|
||||
@@ -90,7 +91,7 @@ export default function RandomMix() {
|
||||
<div
|
||||
key={song.id}
|
||||
className="track-row"
|
||||
style={{ gridTemplateColumns: '36px 1fr 1fr 60px 80px' }}
|
||||
style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 60px 80px' }}
|
||||
onDoubleClick={() => playTrack(song, songs)}
|
||||
role="row"
|
||||
draggable
|
||||
@@ -98,10 +99,10 @@ export default function RandomMix() {
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
const track = {
|
||||
id: song.id, title: song.title, artist: song.artist, album: song.album,
|
||||
albumId: song.albumId, duration: song.duration, coverArt: song.coverArt, track: song.track,
|
||||
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track,
|
||||
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
|
||||
};
|
||||
e.dataTransfer.setData('application/json', JSON.stringify({ type: 'song', track }));
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
|
||||
}}
|
||||
>
|
||||
<button
|
||||
@@ -115,7 +116,10 @@ export default function RandomMix() {
|
||||
|
||||
<div className="track-info">
|
||||
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
|
||||
<span className="track-artist">{song.artist}</span>
|
||||
</div>
|
||||
|
||||
<div className="track-artist-cell">
|
||||
<span className="track-artist" data-tooltip={song.artist}>{song.artist}</span>
|
||||
</div>
|
||||
|
||||
<div className="track-info">
|
||||
|
||||
@@ -179,8 +179,14 @@ export default function Settings() {
|
||||
onChange={(e) => theme.setTheme(e.target.value as any)}
|
||||
aria-label={t('settings.theme')}
|
||||
>
|
||||
<option value="mocha">Catppuccin Mocha (Dark)</option>
|
||||
<option value="latte">Catppuccin Latte (Light)</option>
|
||||
<option value="mocha">Catppuccin Mocha</option>
|
||||
<option value="macchiato">Catppuccin Macchiato</option>
|
||||
<option value="frappe">Catppuccin Frappé</option>
|
||||
<option value="latte">Catppuccin Latte</option>
|
||||
<option value="nord">Nord · Polar Night</option>
|
||||
<option value="nord-snowstorm">Nord · Snowstorm</option>
|
||||
<option value="nord-frost">Nord · Frost</option>
|
||||
<option value="nord-aurora">Nord · Aurora</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -368,7 +374,7 @@ export default function Settings() {
|
||||
Psysonic
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
|
||||
{t('settings.aboutVersion')} 1.0.5
|
||||
{t('settings.aboutVersion')} 1.0.9
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+181
-47
@@ -10,6 +10,7 @@ export interface Track {
|
||||
artist: string;
|
||||
album: string;
|
||||
albumId: string;
|
||||
artistId?: string;
|
||||
duration: number;
|
||||
coverArt?: string;
|
||||
track?: number;
|
||||
@@ -25,6 +26,7 @@ interface PlayerState {
|
||||
queueIndex: number;
|
||||
isPlaying: boolean;
|
||||
progress: number; // 0–1
|
||||
buffered: number; // 0–1
|
||||
currentTime: number;
|
||||
volume: number;
|
||||
howl: Howl | null;
|
||||
@@ -74,6 +76,14 @@ interface PlayerState {
|
||||
}
|
||||
|
||||
let progressInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let seekDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
let gstSeeking = false; // true while GStreamer is processing a seek
|
||||
let pendingSeekTime: number | null = null; // queue at most one seek
|
||||
let gstSeekWatchdog: ReturnType<typeof setTimeout> | null = null; // safety release if onseek never fires
|
||||
let hangRecoveryPos: number | null = null; // set before a recovery playTrack call so onplay seeks here
|
||||
let gaplessPrewarmedId: string | null = null; // track id whose prefetched Howl has been silently pre-started
|
||||
let hangLastTime = -1; // last observed currentTime for hang detection
|
||||
let hangStallTime = 0; // Date.now() when currentTime last moved
|
||||
|
||||
function clearProgress() {
|
||||
if (progressInterval) {
|
||||
@@ -82,6 +92,18 @@ function clearProgress() {
|
||||
}
|
||||
}
|
||||
|
||||
function armGstWatchdog(cb: () => void) {
|
||||
if (gstSeekWatchdog) clearTimeout(gstSeekWatchdog);
|
||||
gstSeekWatchdog = setTimeout(() => {
|
||||
gstSeekWatchdog = null;
|
||||
cb();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function disarmGstWatchdog() {
|
||||
if (gstSeekWatchdog) { clearTimeout(gstSeekWatchdog); gstSeekWatchdog = null; }
|
||||
}
|
||||
|
||||
// Helper to debounce or fire queue syncs
|
||||
let syncTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTime: number) {
|
||||
@@ -105,6 +127,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
queueIndex: 0,
|
||||
isPlaying: false,
|
||||
progress: 0,
|
||||
buffered: 0,
|
||||
currentTime: 0,
|
||||
volume: 0.8,
|
||||
howl: null,
|
||||
@@ -135,7 +158,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
get().howl?.stop();
|
||||
get().howl?.seek(0);
|
||||
clearProgress();
|
||||
set({ isPlaying: false, progress: 0, currentTime: 0 });
|
||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||
},
|
||||
|
||||
playTrack: (track, queue) => {
|
||||
@@ -143,60 +166,148 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
// Stop current
|
||||
state.howl?.unload();
|
||||
clearProgress();
|
||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
|
||||
disarmGstWatchdog();
|
||||
gstSeeking = false;
|
||||
pendingSeekTime = null;
|
||||
hangLastTime = -1;
|
||||
hangStallTime = 0;
|
||||
gaplessPrewarmedId = null;
|
||||
|
||||
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);
|
||||
// Reuse a prefetched Howl if available — it's already connected and buffering
|
||||
const prefetchMap = state.prefetched;
|
||||
let howl: Howl;
|
||||
let gaplessHandoff = false;
|
||||
if (prefetchMap.has(track.id)) {
|
||||
howl = prefetchMap.get(track.id)!;
|
||||
prefetchMap.delete(track.id);
|
||||
set({ prefetched: new Map(prefetchMap) });
|
||||
if (howl.playing()) {
|
||||
// Gapless: pipeline already running — pause, seek to 0, then play
|
||||
gaplessHandoff = true;
|
||||
howl.pause();
|
||||
hangRecoveryPos = 0; // onplay will seek to position 0
|
||||
}
|
||||
howl.volume(state.volume);
|
||||
} else {
|
||||
howl = new Howl({ src: [buildStreamUrl(track.id)], html5: true, volume: state.volume });
|
||||
}
|
||||
|
||||
set({ scrobbled: false });
|
||||
progressInterval = setInterval(() => {
|
||||
const h = get().howl;
|
||||
if (!h) return;
|
||||
const cur = typeof h.seek() === 'number' ? h.seek() as number : 0;
|
||||
const dur = h.duration() || 1;
|
||||
const prog = cur / dur;
|
||||
howl.on('play', () => {
|
||||
set({ isPlaying: true });
|
||||
reportNowPlaying(track.id);
|
||||
|
||||
// If recovering from a pipeline hang, seek to the saved position
|
||||
if (hangRecoveryPos !== null) {
|
||||
const pos = hangRecoveryPos;
|
||||
hangRecoveryPos = null;
|
||||
gstSeeking = true;
|
||||
armGstWatchdog(() => { gstSeeking = false; pendingSeekTime = null; });
|
||||
setTimeout(() => { howl.seek(pos); }, 50);
|
||||
}
|
||||
|
||||
set({ scrobbled: false });
|
||||
hangStallTime = Date.now();
|
||||
hangLastTime = -1;
|
||||
|
||||
progressInterval = setInterval(() => {
|
||||
const h = get().howl;
|
||||
if (!h) return;
|
||||
const s = h.seek();
|
||||
const cur = typeof s === 'number' ? s : 0;
|
||||
const dur = h.duration() || 1;
|
||||
const prog = cur / dur;
|
||||
|
||||
// Read buffered ranges from the underlying <audio> element
|
||||
const audioNode = (h as any)._sounds?.[0]?._node as HTMLAudioElement | undefined;
|
||||
if (audioNode?.buffered && audioNode.duration > 0) {
|
||||
let totalBuf = 0;
|
||||
for (let i = 0; i < audioNode.buffered.length; i++) {
|
||||
totalBuf += audioNode.buffered.end(i) - audioNode.buffered.start(i);
|
||||
}
|
||||
set({ currentTime: cur, progress: prog, buffered: Math.min(1, totalBuf / audioNode.duration) });
|
||||
} else {
|
||||
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());
|
||||
// Hang detection: if playing but currentTime hasn't moved in 5s, recover
|
||||
if (Math.abs(cur - hangLastTime) > 0.05) {
|
||||
hangLastTime = cur;
|
||||
hangStallTime = Date.now();
|
||||
} else if (get().isPlaying && Date.now() - hangStallTime > 5000) {
|
||||
const { currentTrack: ct, queue: q } = get();
|
||||
if (ct) {
|
||||
hangRecoveryPos = cur;
|
||||
hangStallTime = Date.now(); // prevent re-trigger while recovering
|
||||
get().playTrack(ct, q);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Gapless pre-warm: start next track's Howl silently ~1s before end
|
||||
if (!gaplessPrewarmedId && dur > 2 && dur - cur < 1.0) {
|
||||
const { queue: q, queueIndex: qi, repeatMode: rm, prefetched: pf } = get();
|
||||
const nextIdx = qi + 1;
|
||||
const nextTrack = nextIdx < q.length ? q[nextIdx]
|
||||
: (rm === 'all' && q.length > 0 ? q[0] : null);
|
||||
if (nextTrack && pf.has(nextTrack.id)) {
|
||||
const nh = pf.get(nextTrack.id)!;
|
||||
if (!nh.playing()) { nh.volume(0); nh.play(); gaplessPrewarmedId = nextTrack.id; }
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// Prefetch next 3
|
||||
get().prefetchUpcoming(idx + 1, newQueue);
|
||||
},
|
||||
onend: () => {
|
||||
clearProgress();
|
||||
set({ isPlaying: false, progress: 0, currentTime: 0 });
|
||||
const { repeatMode, currentTrack, queue } = get();
|
||||
if (repeatMode === 'one' && currentTrack) {
|
||||
get().playTrack(currentTrack, queue);
|
||||
} else {
|
||||
get().next();
|
||||
// Scrobble at 50%
|
||||
if (prog >= 0.5 && !get().scrobbled) {
|
||||
set({ scrobbled: true });
|
||||
const { scrobblingEnabled } = useAuthStore.getState();
|
||||
if (scrobblingEnabled) scrobbleSong(track.id, Date.now());
|
||||
}
|
||||
},
|
||||
onstop: () => {
|
||||
clearProgress();
|
||||
set({ isPlaying: false });
|
||||
},
|
||||
}, 500);
|
||||
|
||||
// Prefetch next 3
|
||||
get().prefetchUpcoming(idx + 1, newQueue);
|
||||
});
|
||||
|
||||
howl.play();
|
||||
set({ currentTrack: track, queue: newQueue, queueIndex: idx >= 0 ? idx : 0, howl, progress: 0, currentTime: 0 });
|
||||
howl.on('end', () => {
|
||||
clearProgress();
|
||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||
const { repeatMode, currentTrack, queue } = get();
|
||||
if (repeatMode === 'one' && currentTrack) {
|
||||
get().playTrack(currentTrack, queue);
|
||||
} else {
|
||||
get().next();
|
||||
}
|
||||
});
|
||||
|
||||
howl.on('stop', () => {
|
||||
clearProgress();
|
||||
set({ isPlaying: false });
|
||||
});
|
||||
|
||||
howl.on('seek', () => {
|
||||
disarmGstWatchdog();
|
||||
gstSeeking = false;
|
||||
hangLastTime = -1;
|
||||
hangStallTime = Date.now();
|
||||
if (pendingSeekTime !== null) {
|
||||
const t = pendingSeekTime;
|
||||
pendingSeekTime = null;
|
||||
gstSeeking = true;
|
||||
armGstWatchdog(() => {
|
||||
gstSeeking = false;
|
||||
pendingSeekTime = null;
|
||||
const { currentTrack: ct, queue: q } = get();
|
||||
if (ct) { hangRecoveryPos = t; get().playTrack(ct, q); }
|
||||
});
|
||||
get().howl?.seek(t);
|
||||
}
|
||||
});
|
||||
|
||||
howl.play(); // for gapless: resumes from paused state, onplay fires and seeks to 0 via hangRecoveryPos
|
||||
set({ currentTrack: track, queue: newQueue, queueIndex: idx >= 0 ? idx : 0, howl, progress: 0, buffered: 0, currentTime: 0 });
|
||||
syncQueueToServer(newQueue, track, 0);
|
||||
},
|
||||
|
||||
@@ -207,8 +318,14 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
},
|
||||
|
||||
resume: () => {
|
||||
const { howl, currentTrack } = get();
|
||||
if (!howl || !currentTrack) return;
|
||||
const { howl, currentTrack, queue, currentTime } = get();
|
||||
if (!currentTrack) return;
|
||||
if (!howl) {
|
||||
// Cold start from restored state (e.g. app relaunch) — resume from saved position
|
||||
if (currentTime > 0) hangRecoveryPos = currentTime;
|
||||
get().playTrack(currentTrack, queue);
|
||||
return;
|
||||
}
|
||||
howl.play();
|
||||
set({ isPlaying: true });
|
||||
},
|
||||
@@ -243,8 +360,25 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
const { howl, currentTrack } = get();
|
||||
if (!howl || !currentTrack) return;
|
||||
const time = progress * (howl.duration() || currentTrack.duration);
|
||||
howl.seek(time);
|
||||
set({ progress, currentTime: time });
|
||||
if (seekDebounce) clearTimeout(seekDebounce);
|
||||
seekDebounce = setTimeout(() => {
|
||||
seekDebounce = null;
|
||||
if (gstSeeking) {
|
||||
// GStreamer busy — queue this position; onseek will send it when ready
|
||||
pendingSeekTime = time;
|
||||
return;
|
||||
}
|
||||
gstSeeking = true;
|
||||
const seekTarget = time;
|
||||
armGstWatchdog(() => {
|
||||
gstSeeking = false;
|
||||
pendingSeekTime = null;
|
||||
const { currentTrack: ct, queue: q } = get();
|
||||
if (ct) { hangRecoveryPos = seekTarget; get().playTrack(ct, q); }
|
||||
});
|
||||
get().howl?.seek(time);
|
||||
}, 100);
|
||||
},
|
||||
|
||||
setVolume: (v) => {
|
||||
@@ -268,7 +402,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
clearQueue: () => {
|
||||
get().howl?.unload();
|
||||
clearProgress();
|
||||
set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, currentTime: 0, howl: null });
|
||||
set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0, howl: null });
|
||||
syncQueueToServer([], null, 0);
|
||||
},
|
||||
|
||||
@@ -321,7 +455,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
if (q.songs.length > 0) {
|
||||
const mappedTracks: Track[] = q.songs.map((s: SubsonicSong) => ({
|
||||
id: s.id, title: s.title, artist: s.artist, album: s.album,
|
||||
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track,
|
||||
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
|
||||
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
type Theme = 'mocha' | 'latte';
|
||||
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora';
|
||||
|
||||
interface ThemeState {
|
||||
theme: Theme;
|
||||
|
||||
+152
-182
@@ -4,14 +4,14 @@
|
||||
.hero {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
height: 360px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hero-placeholder {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
height: 360px;
|
||||
background: linear-gradient(135deg, var(--ctp-surface0), var(--ctp-mantle));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -53,13 +53,13 @@
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
rgba(30, 30, 46, 0.92) 0%,
|
||||
rgba(30, 30, 46, 0.6) 50%,
|
||||
rgba(30, 30, 46, 0.3) 100%
|
||||
rgba(0, 0, 0, 0.78) 0%,
|
||||
rgba(0, 0, 0, 0.45) 50%,
|
||||
rgba(0, 0, 0, 0.18) 100%
|
||||
),
|
||||
linear-gradient(
|
||||
to top,
|
||||
rgba(30, 30, 46, 0.95) 0%,
|
||||
rgba(0, 0, 0, 0.88) 0%,
|
||||
transparent 60%
|
||||
);
|
||||
}
|
||||
@@ -74,8 +74,8 @@
|
||||
}
|
||||
|
||||
.hero-cover {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.6);
|
||||
object-fit: cover;
|
||||
@@ -94,7 +94,7 @@
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
color: var(--ctp-lavender);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
@@ -102,16 +102,16 @@
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(24px, 3vw, 36px);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
color: #ffffff;
|
||||
margin-bottom: var(--space-1);
|
||||
line-height: 1.2;
|
||||
text-shadow: 0 2px 12px rgba(0,0,0,0.5);
|
||||
text-shadow: 0 2px 12px rgba(0,0,0,0.6);
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.hero-artist {
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
@@ -884,19 +884,19 @@
|
||||
.empty-state { text-align: center; padding: var(--space-10); color: var(--text-muted); }
|
||||
|
||||
/* ─────────────────────────────────────────
|
||||
Fullscreen Player
|
||||
Fullscreen Player — Ambient Stage
|
||||
──────────────────────────────────────────── */
|
||||
.fs-player {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
will-change: transform;
|
||||
contain: layout style paint;
|
||||
animation: fsIn 280ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
/* Solid base — guarantees nothing shines through, even at edges */
|
||||
background: #0e0e1a;
|
||||
}
|
||||
|
||||
@@ -905,16 +905,50 @@
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Blurred background — GPU layer, will not repaint with React re-renders */
|
||||
/* ── Drifting color orbs ── */
|
||||
@keyframes orb-a {
|
||||
0% { transform: translate(0px, 0px) scale(1); }
|
||||
33% { transform: translate(90px, -50px) scale(1.12); }
|
||||
66% { transform: translate(-40px, 70px) scale(0.94); }
|
||||
100% { transform: translate(0px, 0px) scale(1); }
|
||||
}
|
||||
@keyframes orb-b {
|
||||
0% { transform: translate(0px, 0px) scale(1); }
|
||||
33% { transform: translate(-70px, 40px) scale(1.08); }
|
||||
66% { transform: translate(50px, -60px) scale(1.14); }
|
||||
100% { transform: translate(0px, 0px) scale(1); }
|
||||
}
|
||||
@keyframes orb-c {
|
||||
0% { transform: translate(0px, 0px) scale(1); }
|
||||
50% { transform: translate(60px, 50px) scale(0.9); }
|
||||
100% { transform: translate(0px, 0px) scale(1); }
|
||||
}
|
||||
|
||||
/* ── Cover breathing ── */
|
||||
@keyframes cover-breathe {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.018); }
|
||||
}
|
||||
|
||||
@keyframes ken-burns {
|
||||
0% { transform: scale(1.08) translate(0%, 0%); }
|
||||
25% { transform: scale(1.12) translate(-1.5%, 1%); }
|
||||
50% { transform: scale(1.10) translate(1%, -1.5%); }
|
||||
75% { transform: scale(1.13) translate(1.5%, 0.5%); }
|
||||
100% { transform: scale(1.08) translate(0%, 0%); }
|
||||
}
|
||||
|
||||
/* ── Blurred background ── */
|
||||
.fs-bg {
|
||||
position: absolute;
|
||||
inset: -10%;
|
||||
inset: -15%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
filter: blur(50px) brightness(0.28) saturate(1.6);
|
||||
transform: scale(1.2) translateZ(0);
|
||||
filter: blur(6px) brightness(0.25) saturate(1.6);
|
||||
animation: ken-burns 40s ease-in-out infinite;
|
||||
transform: scale(1.2);
|
||||
z-index: 0;
|
||||
will-change: auto;
|
||||
will-change: opacity;
|
||||
pointer-events: none;
|
||||
transition: opacity 700ms ease;
|
||||
}
|
||||
@@ -922,17 +956,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 +1002,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 +1011,43 @@
|
||||
}
|
||||
.fs-close:hover {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
color: var(--text-primary);
|
||||
color: #ffffff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* ── Main layout: two columns, left = cover+controls, right = playlist ── */
|
||||
.fs-layout {
|
||||
/* ── Center stage ── */
|
||||
.fs-stage {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: clamp(28px, 4vw, 72px);
|
||||
padding: 72px clamp(40px, 6vw, 100px) 48px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Left column: cover + track info + progress + controls */
|
||||
.fs-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
flex-shrink: 0;
|
||||
/* Width = cover size (cover fills 100% of this column) */
|
||||
width: min(clamp(260px, 36vw, 560px), calc(100vh - 310px));
|
||||
gap: 18px;
|
||||
width: min(440px, 88vw);
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
/* Cover fills the column width */
|
||||
/* Artist name — above cover */
|
||||
.fs-artist {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ctp-lavender);
|
||||
margin: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Cover — breathes slowly */
|
||||
.fs-cover-wrap {
|
||||
width: 100%;
|
||||
width: clamp(300px, 30vw, 480px);
|
||||
max-height: calc(100vh - 300px);
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255,255,255,0.06);
|
||||
box-shadow: none;
|
||||
flex-shrink: 0;
|
||||
transition: transform var(--transition-slow), box-shadow var(--transition-slow);
|
||||
}
|
||||
.fs-cover-wrap:hover {
|
||||
transform: scale(1.015);
|
||||
box-shadow: 0 32px 100px rgba(0,0,0,0.8), 0 0 0 1px rgba(255,255,255,0.1);
|
||||
animation: cover-breathe 9s ease-in-out infinite;
|
||||
}
|
||||
.fs-cover {
|
||||
width: 100%;
|
||||
@@ -1003,6 +1056,8 @@
|
||||
display: block;
|
||||
}
|
||||
.fs-cover-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--ctp-surface0);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1010,32 +1065,26 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Track metadata */
|
||||
/* Track info — below cover */
|
||||
.fs-track-info {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
.fs-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(16px, 1.8vw, 26px);
|
||||
font-size: clamp(20px, 3vw, 32px);
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 4px;
|
||||
line-height: 1.2;
|
||||
color: #ffffff;
|
||||
margin: 0 0 6px;
|
||||
line-height: 1.15;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.fs-artist {
|
||||
font-size: clamp(13px, 1.1vw, 16px);
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
margin: 0;
|
||||
}
|
||||
.fs-album {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin: 3px 0 0;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -1049,8 +1098,8 @@
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
color: var(--text-muted);
|
||||
margin-top: 6px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
@@ -1061,8 +1110,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 +1122,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 +1138,50 @@
|
||||
.fs-progress-bar input[type="range"]::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
background: #ffffff;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.5);
|
||||
box-shadow: 0 1px 6px rgba(0,0,0,0.6);
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
.fs-progress-bar input[type="range"]:hover::-webkit-slider-thumb { transform: scale(1.25); }
|
||||
.fs-progress-bar input[type="range"]:hover::-webkit-slider-thumb { transform: scale(1.3); }
|
||||
|
||||
/* Transport controls */
|
||||
.fs-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
gap: 14px;
|
||||
}
|
||||
.fs-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
color: var(--text-secondary);
|
||||
color: rgba(255,255,255,0.6);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
background: transparent;
|
||||
}
|
||||
.fs-btn:hover { color: var(--text-primary); background: rgba(255,255,255,0.08); }
|
||||
.fs-btn.active { color: var(--accent); }
|
||||
.fs-btn-sm { width: 36px; height: 36px; }
|
||||
.fs-btn:hover { color: #ffffff; background: rgba(255,255,255,0.1); }
|
||||
.fs-btn.active { color: var(--ctp-lavender); }
|
||||
.fs-btn-sm { width: 28px; height: 28px; }
|
||||
.fs-btn-play {
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
background: white;
|
||||
color: #1e1e2e;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
background: linear-gradient(135deg, var(--ctp-mauve), var(--ctp-lavender));
|
||||
color: var(--ctp-crust);
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.fs-btn-play:hover {
|
||||
background: var(--ctp-lavender);
|
||||
color: #1e1e2e;
|
||||
transform: scale(1.06);
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,0.6);
|
||||
}
|
||||
|
||||
/* Right column: upcoming tracks — same vertical center as left column */
|
||||
.fs-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
width: clamp(240px, 26vw, 380px);
|
||||
align-self: center;
|
||||
height: min(clamp(400px, 60vh, 840px), calc(100vh - 180px));
|
||||
overflow: hidden;
|
||||
}
|
||||
.fs-upcoming-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.fs-upcoming-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.fs-upcoming-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
transition: background var(--transition-fast);
|
||||
background: transparent;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.fs-upcoming-item:hover { background: rgba(255,255,255,0.07); }
|
||||
.fs-upcoming-art {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.fs-upcoming-placeholder {
|
||||
background: var(--ctp-surface1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.fs-upcoming-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.fs-upcoming-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.fs-upcoming-artist {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.fs-upcoming-dur {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(135deg, var(--ctp-lavender), var(--ctp-mauve));
|
||||
color: var(--ctp-crust);
|
||||
transform: scale(1.07);
|
||||
box-shadow: 0 10px 34px rgba(0,0,0,0.6);
|
||||
}
|
||||
|
||||
/* Chat */
|
||||
|
||||
+92
-18
@@ -164,6 +164,70 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ─── Update Toast ─── */
|
||||
@keyframes update-toast-in {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.update-toast {
|
||||
margin: 0 var(--space-1) var(--space-2);
|
||||
padding: var(--space-3) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--accent-dim);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
animation: update-toast-in 0.35s ease both;
|
||||
}
|
||||
|
||||
.update-toast-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.update-toast-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.update-toast-version {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.update-toast-link {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
padding-left: 22px;
|
||||
opacity: 0.8;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.update-toast-link:hover {
|
||||
opacity: 1;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.update-toast-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-2) 0;
|
||||
color: var(--accent);
|
||||
animation: update-toast-in 0.35s ease both;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ─── Main Content ─── */
|
||||
.main-content {
|
||||
grid-area: main;
|
||||
@@ -306,13 +370,14 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
gap: 2px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.player-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.player-btn {
|
||||
@@ -322,29 +387,31 @@
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-muted);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.player-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
transform: scale(1.12);
|
||||
}
|
||||
|
||||
.player-btn-primary {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
background: var(--text-primary);
|
||||
color: var(--bg-app);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: linear-gradient(135deg, var(--ctp-mauve), var(--ctp-lavender));
|
||||
color: var(--ctp-crust);
|
||||
border-radius: 50%;
|
||||
box-shadow: var(--shadow-sm);
|
||||
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.player-btn-primary:hover {
|
||||
background: var(--accent);
|
||||
background: linear-gradient(135deg, var(--ctp-lavender), var(--ctp-mauve));
|
||||
color: var(--ctp-crust);
|
||||
transform: scale(1.05);
|
||||
box-shadow: var(--shadow-glow);
|
||||
transform: scale(1.07);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4), var(--shadow-glow);
|
||||
}
|
||||
|
||||
.player-progress {
|
||||
@@ -441,23 +508,26 @@
|
||||
}
|
||||
|
||||
.queue-current-track {
|
||||
padding: var(--space-4);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.queue-current-cover {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--bg-surface);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.queue-current-cover img {
|
||||
@@ -474,10 +544,12 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.queue-current-info h3 {
|
||||
font-size: 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
@@ -501,7 +573,7 @@
|
||||
}
|
||||
|
||||
.queue-divider {
|
||||
padding: var(--space-4) var(--space-5) 0;
|
||||
padding: var(--space-3) var(--space-4) 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -528,6 +600,8 @@
|
||||
transition: background var(--transition-fast);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
/* Prevent child elements from stealing dragenter/dragleave events */
|
||||
.queue-item > * { pointer-events: none; }
|
||||
|
||||
.queue-item:hover {
|
||||
background: var(--bg-hover);
|
||||
|
||||
@@ -56,6 +56,106 @@
|
||||
--danger: var(--ctp-red);
|
||||
}
|
||||
|
||||
/* ─── Catppuccin Macchiato Variables ─── */
|
||||
[data-theme='macchiato'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23b8c0e0%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
--ctp-rosewater: #f4dbd6;
|
||||
--ctp-flamingo: #f0c6c6;
|
||||
--ctp-pink: #f5bde6;
|
||||
--ctp-mauve: #c6a0f6;
|
||||
--ctp-red: #ed8796;
|
||||
--ctp-maroon: #ee99a0;
|
||||
--ctp-peach: #f5a97f;
|
||||
--ctp-yellow: #eed49f;
|
||||
--ctp-green: #a6da95;
|
||||
--ctp-teal: #8bd5ca;
|
||||
--ctp-sky: #91d7e3;
|
||||
--ctp-sapphire: #7dc4e4;
|
||||
--ctp-blue: #8aadf4;
|
||||
--ctp-lavender: #b7bdf8;
|
||||
--ctp-text: #cad3f5;
|
||||
--ctp-subtext1: #b8c0e0;
|
||||
--ctp-subtext0: #a5adcb;
|
||||
--ctp-overlay2: #939ab7;
|
||||
--ctp-overlay1: #8087a2;
|
||||
--ctp-overlay0: #6e738d;
|
||||
--ctp-surface2: #5b6078;
|
||||
--ctp-surface1: #494d64;
|
||||
--ctp-surface0: #363a4f;
|
||||
--ctp-base: #24273a;
|
||||
--ctp-mantle: #1e2030;
|
||||
--ctp-crust: #181926;
|
||||
|
||||
--bg-app: var(--ctp-base);
|
||||
--bg-sidebar: var(--ctp-mantle);
|
||||
--bg-card: var(--ctp-surface0);
|
||||
--bg-hover: var(--ctp-surface1);
|
||||
--bg-player: var(--ctp-crust);
|
||||
--bg-glass: rgba(36, 39, 58, 0.75);
|
||||
--accent: var(--ctp-mauve);
|
||||
--accent-dim: rgba(198, 160, 246, 0.15);
|
||||
--accent-glow: rgba(198, 160, 246, 0.3);
|
||||
--text-primary: var(--ctp-text);
|
||||
--text-secondary:var(--ctp-subtext1);
|
||||
--text-muted: var(--ctp-overlay1);
|
||||
--border: var(--ctp-surface1);
|
||||
--border-subtle: var(--ctp-surface0);
|
||||
--positive: var(--ctp-green);
|
||||
--warning: var(--ctp-yellow);
|
||||
--danger: var(--ctp-red);
|
||||
}
|
||||
|
||||
/* ─── Catppuccin Frappé Variables ─── */
|
||||
[data-theme='frappe'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23b5bfe2%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
--ctp-rosewater: #f2d5cf;
|
||||
--ctp-flamingo: #eebebe;
|
||||
--ctp-pink: #f4b8e4;
|
||||
--ctp-mauve: #ca9ee6;
|
||||
--ctp-red: #e78284;
|
||||
--ctp-maroon: #ea999c;
|
||||
--ctp-peach: #ef9f76;
|
||||
--ctp-yellow: #e5c890;
|
||||
--ctp-green: #a6d189;
|
||||
--ctp-teal: #81c8be;
|
||||
--ctp-sky: #99d1db;
|
||||
--ctp-sapphire: #85c1dc;
|
||||
--ctp-blue: #8caaee;
|
||||
--ctp-lavender: #babbf1;
|
||||
--ctp-text: #c6d0f5;
|
||||
--ctp-subtext1: #b5bfe2;
|
||||
--ctp-subtext0: #a5adce;
|
||||
--ctp-overlay2: #949cbb;
|
||||
--ctp-overlay1: #838ba7;
|
||||
--ctp-overlay0: #737994;
|
||||
--ctp-surface2: #626880;
|
||||
--ctp-surface1: #51576d;
|
||||
--ctp-surface0: #414559;
|
||||
--ctp-base: #303446;
|
||||
--ctp-mantle: #292c3c;
|
||||
--ctp-crust: #232634;
|
||||
|
||||
--bg-app: var(--ctp-base);
|
||||
--bg-sidebar: var(--ctp-mantle);
|
||||
--bg-card: var(--ctp-surface0);
|
||||
--bg-hover: var(--ctp-surface1);
|
||||
--bg-player: var(--ctp-crust);
|
||||
--bg-glass: rgba(48, 52, 70, 0.75);
|
||||
--accent: var(--ctp-mauve);
|
||||
--accent-dim: rgba(202, 158, 230, 0.15);
|
||||
--accent-glow: rgba(202, 158, 230, 0.3);
|
||||
--text-primary: var(--ctp-text);
|
||||
--text-secondary:var(--ctp-subtext1);
|
||||
--text-muted: var(--ctp-overlay1);
|
||||
--border: var(--ctp-surface1);
|
||||
--border-subtle: var(--ctp-surface0);
|
||||
--positive: var(--ctp-green);
|
||||
--warning: var(--ctp-yellow);
|
||||
--danger: var(--ctp-red);
|
||||
}
|
||||
|
||||
/* ─── Catppuccin Latte Variables (Light Theme) ─── */
|
||||
[data-theme='latte'] {
|
||||
color-scheme: light;
|
||||
@@ -107,6 +207,221 @@
|
||||
--danger: var(--ctp-red);
|
||||
}
|
||||
|
||||
/* ─── Nord – Polar Night (Dark) ─── */
|
||||
[data-theme='nord'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23e5e9f0%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
/* Polar Night */
|
||||
--ctp-crust: #2e3440;
|
||||
--ctp-mantle: #2e3440;
|
||||
--ctp-base: #3b4252;
|
||||
--ctp-surface0: #434c5e;
|
||||
--ctp-surface1: #4c566a;
|
||||
--ctp-surface2: #576070;
|
||||
--ctp-overlay0: #7a8898;
|
||||
--ctp-overlay1: #8894a4;
|
||||
--ctp-overlay2: #96a2b0;
|
||||
/* Snow Storm as text */
|
||||
--ctp-text: #eceff4;
|
||||
--ctp-subtext1: #e5e9f0;
|
||||
--ctp-subtext0: #d8dee9;
|
||||
/* Frost */
|
||||
--ctp-teal: #8fbcbb;
|
||||
--ctp-sky: #88c0d0;
|
||||
--ctp-sapphire: #81a1c1;
|
||||
--ctp-blue: #5e81ac;
|
||||
--ctp-lavender: #81a1c1;
|
||||
--ctp-mauve: #88c0d0;
|
||||
/* Aurora */
|
||||
--ctp-red: #bf616a;
|
||||
--ctp-maroon: #d08770;
|
||||
--ctp-peach: #d08770;
|
||||
--ctp-yellow: #ebcb8b;
|
||||
--ctp-green: #a3be8c;
|
||||
--ctp-pink: #b48ead;
|
||||
--ctp-flamingo: #bf616a;
|
||||
--ctp-rosewater: #d08770;
|
||||
|
||||
--bg-app: #3b4252;
|
||||
--bg-sidebar: #2e3440;
|
||||
--bg-card: #434c5e;
|
||||
--bg-hover: #4c566a;
|
||||
--bg-player: #2e3440;
|
||||
--bg-glass: rgba(59, 66, 82, 0.75);
|
||||
--accent: #88c0d0;
|
||||
--accent-dim: rgba(136, 192, 208, 0.15);
|
||||
--accent-glow: rgba(136, 192, 208, 0.3);
|
||||
--text-primary: #eceff4;
|
||||
--text-secondary:#e5e9f0;
|
||||
--text-muted: #8894a4;
|
||||
--border: #434c5e;
|
||||
--border-subtle: #3b4252;
|
||||
--positive: #a3be8c;
|
||||
--warning: #ebcb8b;
|
||||
--danger: #bf616a;
|
||||
}
|
||||
|
||||
/* ─── Nord – Snow Storm (Light) ─── */
|
||||
[data-theme='nord-snowstorm'] {
|
||||
color-scheme: light;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%233b4252%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
/* Snow Storm as backgrounds */
|
||||
--ctp-crust: #d8dee9;
|
||||
--ctp-mantle: #e5e9f0;
|
||||
--ctp-base: #eceff4;
|
||||
--ctp-surface0: #d8dee9;
|
||||
--ctp-surface1: #c8d0de;
|
||||
--ctp-surface2: #b8c2d0;
|
||||
--ctp-overlay0: #7a8898;
|
||||
--ctp-overlay1: #636e7e;
|
||||
--ctp-overlay2: #4c566a;
|
||||
/* Polar Night as text */
|
||||
--ctp-text: #2e3440;
|
||||
--ctp-subtext1: #3b4252;
|
||||
--ctp-subtext0: #434c5e;
|
||||
/* Frost */
|
||||
--ctp-teal: #8fbcbb;
|
||||
--ctp-sky: #88c0d0;
|
||||
--ctp-sapphire: #81a1c1;
|
||||
--ctp-blue: #5e81ac;
|
||||
--ctp-lavender: #81a1c1;
|
||||
--ctp-mauve: #5e81ac;
|
||||
/* Aurora */
|
||||
--ctp-red: #bf616a;
|
||||
--ctp-maroon: #d08770;
|
||||
--ctp-peach: #d08770;
|
||||
--ctp-yellow: #9a7200;
|
||||
--ctp-green: #4e7c3f;
|
||||
--ctp-pink: #7e5d8c;
|
||||
--ctp-flamingo: #bf616a;
|
||||
--ctp-rosewater: #d08770;
|
||||
|
||||
--bg-app: #eceff4;
|
||||
--bg-sidebar: #e5e9f0;
|
||||
--bg-card: #d8dee9;
|
||||
--bg-hover: #c8d0de;
|
||||
--bg-player: #e5e9f0;
|
||||
--bg-glass: rgba(236, 239, 244, 0.80);
|
||||
--accent: #5e81ac;
|
||||
--accent-dim: rgba(94, 129, 172, 0.15);
|
||||
--accent-glow: rgba(94, 129, 172, 0.3);
|
||||
--text-primary: #2e3440;
|
||||
--text-secondary:#3b4252;
|
||||
--text-muted: #636e7e;
|
||||
--border: #c8d0de;
|
||||
--border-subtle: #d8dee9;
|
||||
--positive: #4e7c3f;
|
||||
--warning: #9a7200;
|
||||
--danger: #bf616a;
|
||||
}
|
||||
|
||||
/* ─── Nord – Frost (Deep Ocean Blue) ─── */
|
||||
[data-theme='nord-frost'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23e5e9f0%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
/* Deep ocean blue – Frost-inspired dark backgrounds */
|
||||
--ctp-crust: #1a2535;
|
||||
--ctp-mantle: #1d2b3a;
|
||||
--ctp-base: #253545;
|
||||
--ctp-surface0: #2d3f52;
|
||||
--ctp-surface1: #354a60;
|
||||
--ctp-surface2: #3d546e;
|
||||
--ctp-overlay0: #6888a4;
|
||||
--ctp-overlay1: #7898b4;
|
||||
--ctp-overlay2: #88a8c4;
|
||||
/* Snow Storm as text */
|
||||
--ctp-text: #eceff4;
|
||||
--ctp-subtext1: #e5e9f0;
|
||||
--ctp-subtext0: #d8dee9;
|
||||
/* Frost accent */
|
||||
--ctp-teal: #8fbcbb;
|
||||
--ctp-sky: #88c0d0;
|
||||
--ctp-sapphire: #81a1c1;
|
||||
--ctp-blue: #5e81ac;
|
||||
--ctp-lavender: #8fbcbb;
|
||||
--ctp-mauve: #88c0d0;
|
||||
/* Aurora */
|
||||
--ctp-red: #bf616a;
|
||||
--ctp-maroon: #d08770;
|
||||
--ctp-peach: #d08770;
|
||||
--ctp-yellow: #ebcb8b;
|
||||
--ctp-green: #a3be8c;
|
||||
--ctp-pink: #b48ead;
|
||||
--ctp-flamingo: #bf616a;
|
||||
--ctp-rosewater: #d08770;
|
||||
|
||||
--bg-app: #253545;
|
||||
--bg-sidebar: #1d2b3a;
|
||||
--bg-card: #2d3f52;
|
||||
--bg-hover: #354a60;
|
||||
--bg-player: #1a2535;
|
||||
--bg-glass: rgba(37, 53, 69, 0.75);
|
||||
--accent: #88c0d0;
|
||||
--accent-dim: rgba(136, 192, 208, 0.15);
|
||||
--accent-glow: rgba(136, 192, 208, 0.35);
|
||||
--text-primary: #eceff4;
|
||||
--text-secondary:#e5e9f0;
|
||||
--text-muted: #7898b4;
|
||||
--border: #2d3f52;
|
||||
--border-subtle: #253545;
|
||||
--positive: #a3be8c;
|
||||
--warning: #ebcb8b;
|
||||
--danger: #bf616a;
|
||||
}
|
||||
|
||||
/* ─── Nord – Aurora (Dark + Aurora Purple) ─── */
|
||||
[data-theme='nord-aurora'] {
|
||||
color-scheme: dark;
|
||||
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23e5e9f0%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
|
||||
/* Polar Night base */
|
||||
--ctp-crust: #2e3440;
|
||||
--ctp-mantle: #2e3440;
|
||||
--ctp-base: #3b4252;
|
||||
--ctp-surface0: #434c5e;
|
||||
--ctp-surface1: #4c566a;
|
||||
--ctp-surface2: #576070;
|
||||
--ctp-overlay0: #7a8898;
|
||||
--ctp-overlay1: #8894a4;
|
||||
--ctp-overlay2: #96a2b0;
|
||||
/* Snow Storm as text */
|
||||
--ctp-text: #eceff4;
|
||||
--ctp-subtext1: #e5e9f0;
|
||||
--ctp-subtext0: #d8dee9;
|
||||
/* Aurora accents — purple as mauve/lavender for button gradients */
|
||||
--ctp-mauve: #b48ead;
|
||||
--ctp-lavender: #a3be8c;
|
||||
--ctp-pink: #b48ead;
|
||||
--ctp-teal: #8fbcbb;
|
||||
--ctp-sky: #88c0d0;
|
||||
--ctp-sapphire: #81a1c1;
|
||||
--ctp-blue: #5e81ac;
|
||||
--ctp-red: #bf616a;
|
||||
--ctp-maroon: #d08770;
|
||||
--ctp-peach: #d08770;
|
||||
--ctp-yellow: #ebcb8b;
|
||||
--ctp-green: #a3be8c;
|
||||
--ctp-flamingo: #bf616a;
|
||||
--ctp-rosewater: #d08770;
|
||||
|
||||
--bg-app: #3b4252;
|
||||
--bg-sidebar: #2e3440;
|
||||
--bg-card: #434c5e;
|
||||
--bg-hover: #4c566a;
|
||||
--bg-player: #2e3440;
|
||||
--bg-glass: rgba(59, 66, 82, 0.75);
|
||||
--accent: #b48ead;
|
||||
--accent-dim: rgba(180, 142, 173, 0.15);
|
||||
--accent-glow: rgba(180, 142, 173, 0.3);
|
||||
--text-primary: #eceff4;
|
||||
--text-secondary:#e5e9f0;
|
||||
--text-muted: #8894a4;
|
||||
--border: #434c5e;
|
||||
--border-subtle: #3b4252;
|
||||
--positive: #a3be8c;
|
||||
--warning: #ebcb8b;
|
||||
--danger: #bf616a;
|
||||
}
|
||||
|
||||
/* ─── Global Base Settings ─── */
|
||||
:root {
|
||||
/* Typography */
|
||||
|
||||
Reference in New Issue
Block a user