Compare commits

..

1 Commits

Author SHA1 Message Date
Psychotoxical 492f069bf1 feat: Nord themes, Wayland compatibility, and stability fixes (v1.0.6) 2026-03-13 18:59:18 +01:00
23 changed files with 359 additions and 485 deletions
-20
View File
@@ -5,26 +5,6 @@ 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.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
+17 -13
View File
@@ -16,29 +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.
Known Limitations
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.
![Psysonic Screenshot](public/screenshot.png)
## ✨ Features
- 🎨 **Gorgeous UI**: 8 deeply integrated themes (Catppuccin series + Nord series) with smooth glassmorphism effects and micro-animations.
- 🎨 **Gorgeous UI**: Deeply integrated Catppuccin themes (Mocha & Latte) 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.
- 🌍 **Internationalization (i18n)**: Fully translated into English and German (v1.0.0).
- 📻 **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.
- 💾 **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.
- 💾 **Local Caching**: Fast loading times with customizable image caching thresholds.
- 💿 **Album & Artist Views**: Beautiful grid displays and detailed artist pages with related albums.
- 🎛️ **Queue Management**: Drag & drop support, playlist saving, and loading directly built into the queue. Server-side queue synchronization is fully supported.
- 🔄 **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).
- 🖥️ **Cross-Platform**: Available natively for Windows, macOS, and Linux.
## ● Known Limitations
## 🛡️ Security
- **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.
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.
## 📥 Installation
@@ -94,3 +91,10 @@ Contributions are completely welcome! Whether it is translating the app into a n
## 📄 License
Distributed under the MIT License. See `LICENSE` for more information.
---
<p align="center">
<i>Core Components developed with <b>Claude Code</b></i><br/>
<i>UI/UX developed with <b>Gemini 2.0 Flash</b> using <b>Antigravity</b></i>
</p>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.0.8",
"version": "1.0.6",
"private": true,
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -2732,7 +2732,7 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.0.8"
version = "1.0.6"
dependencies = [
"serde",
"serde_json",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.0.8"
version = "1.0.6"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.0.8",
"version": "1.0.6",
"identifier": "dev.psysonic.app",
"build": {
"beforeDevCommand": "npm run dev",
-1
View File
@@ -59,7 +59,6 @@ export interface SubsonicSong {
artist: string;
album: string;
albumId: string;
artistId?: string;
duration: number;
track?: number;
discNumber?: number;
+2 -2
View File
@@ -62,7 +62,7 @@ export default function ContextMenu() {
const top = await getTopSongs(artistName);
const radioTracks = [...top, ...similar].map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
albumId: s.albumId, 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, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
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);
+97 -92
View File
@@ -1,10 +1,10 @@
import React, { useCallback, useEffect, useState, useRef, memo } from 'react';
import {
Play, Pause, SkipBack, SkipForward,
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX,
ChevronDown, Repeat, Repeat1, Square, Music
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo } from '../api/subsonic';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import CachedImage, { useCachedUrl } from './CachedImage';
import { useTranslation } from 'react-i18next';
@@ -15,8 +15,9 @@ function formatTime(seconds: number): string {
return `${m}:${s.toString().padStart(2, '0')}`;
}
// ─── Crossfading blurred background ───────────────────────────────────────────
// ─── Crossfading blurred background — two stacked divs for true crossfade ───
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 }] : []
);
@@ -25,10 +26,15 @@ 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);
@@ -49,16 +55,13 @@ const FsBg = memo(function FsBg({ url }: { url: string }) {
);
});
// ─── Progress bar (isolated — re-renders every tick) ──────────────────────────
// ─── Isolated progress sub-component (re-renders every tick, nothing else does) ───
const FsProgress = memo(function FsProgress({ duration }: { duration: number }) {
const progress = usePlayerStore(s => s.progress);
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]);
return (
<div className="fs-progress-wrap">
@@ -77,7 +80,9 @@ const FsProgress = memo(function FsProgress({ duration }: { duration: number })
);
});
// ─── Play/Pause button (isolated — subscribes to isPlaying only) ──────────────
// ─── Isolated play/pause button (subscribes to isPlaying only) ───
const FsPlayBtn = memo(function FsPlayBtn() {
const { t } = useTranslation();
const isPlaying = usePlayerStore(s => s.isPlaying);
@@ -89,41 +94,30 @@ const FsPlayBtn = memo(function FsPlayBtn() {
);
});
// ─── Main component ────────────────────────────────────────────────────────────
interface FullscreenPlayerProps {
onClose: () => void;
}
export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
const { t } = useTranslation();
// Static/slow-changing state only — does NOT subscribe to progress or currentTime
const currentTrack = usePlayerStore(s => s.currentTrack);
const repeatMode = usePlayerStore(s => s.repeatMode);
const queue = usePlayerStore(s => s.queue);
const queueIndex = usePlayerStore(s => s.queueIndex);
const next = usePlayerStore(s => s.next);
const previous = usePlayerStore(s => s.previous);
const stop = usePlayerStore(s => s.stop);
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
const playTrack = usePlayerStore(s => s.playTrack);
const duration = currentTrack?.duration ?? 0;
const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '';
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
// useCachedUrl must be called unconditionally (hook rules)
const duration = currentTrack?.duration ?? 0;
const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '';
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey);
const upcoming = queue.slice(queueIndex + 1, queueIndex + 15);
// 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;
// Close on Escape
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
@@ -132,77 +126,88 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
return (
<div className="fs-player" role="dialog" aria-modal="true" aria-label={t('player.fullscreen')}>
{/* Layer 1 — blurred artist image (falls back to cover art) */}
<FsBg url={bgUrl} />
{/* Crossfading blurred background */}
<FsBg url={resolvedCoverUrl} />
<div className="fs-bg-overlay" aria-hidden="true" />
{/* 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')}>
{/* Close button */}
<button className="fs-close" onClick={onClose} aria-label={t('player.closeFullscreen')} data-tooltip={t('player.closeTooltip')} data-tooltip-pos="bottom">
<ChevronDown size={28} />
</button>
{/* Center stage — everything vertically + horizontally centered */}
<div className="fs-stage">
{/* Two-column layout: left = cover + info + controls, right = playlist */}
<div className="fs-layout">
<p className="fs-artist">{currentTrack?.artist ?? '—'}</p>
{/* 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>
<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 className="fs-track-info">
<h1 className="fs-title">{currentTrack?.title ?? '—'}</h1>
<p className="fs-artist">{currentTrack?.artist ?? '—'}</p>
<p className="fs-album">{currentTrack?.album ?? '—'}{currentTrack?.year ? ` · ${currentTrack.year}` : ''}</p>
{(currentTrack?.bitRate || currentTrack?.suffix) && (
<span className="fs-codec">
{[currentTrack.suffix?.toUpperCase(), currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : ''].filter(Boolean).join(' · ')}
</span>
)}
</div>
<FsProgress duration={duration} />
<div className="fs-controls">
<button className="fs-btn fs-btn-sm" onClick={stop} data-tooltip="Stop">
<Square size={14} fill="currentColor" />
</button>
<button className="fs-btn" onClick={previous} aria-label={t('player.prev')}>
<SkipBack size={20} />
</button>
<FsPlayBtn />
<button className="fs-btn" onClick={next} aria-label={t('player.next')}>
<SkipForward size={20} />
</button>
<button
className={`fs-btn fs-btn-sm ${repeatMode !== 'off' ? 'active' : ''}`}
onClick={toggleRepeat}
data-tooltip={`${t('player.repeat')}: ${repeatMode === 'off' ? t('player.repeatOff') : repeatMode === 'all' ? t('player.repeatAll') : t('player.repeatOne')}`}
>
{repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />}
</button>
</div>
</div>
{/* Right column: upcoming tracks */}
{upcoming.length > 0 && (
<div className="fs-right">
<h2 className="fs-upcoming-title">{t('queue.nextTracks')}</h2>
<div className="fs-upcoming-list">
{upcoming.map((track, i) => (
<button
key={`${track.id}-${queueIndex + 1 + i}`}
className="fs-upcoming-item"
onClick={() => playTrack(track, queue)}
>
{track.coverArt ? (
<CachedImage src={buildCoverArtUrl(track.coverArt, 80)} cacheKey={coverArtCacheKey(track.coverArt, 80)} alt="" className="fs-upcoming-art" />
) : (
<div className="fs-upcoming-art fs-upcoming-placeholder"><Music size={14} /></div>
)}
<div className="fs-upcoming-info">
<span className="fs-upcoming-name">{track.title}</span>
<span className="fs-upcoming-artist">{track.artist}</span>
</div>
<span className="fs-upcoming-dur">{formatTime(track.duration)}</span>
</button>
))}
</div>
</div>
)}
</div>
</div>
);
+1 -1
View File
@@ -133,7 +133,7 @@ export default function Hero() {
const albumData = await getAlbum(album.id);
const tracks = albumData.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
albumId: s.albumId, 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);
+1 -1
View File
@@ -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, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt,
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating
});
setOpen(false); setQuery('');
+50 -48
View File
@@ -125,10 +125,6 @@ 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);
@@ -148,12 +144,12 @@ export default function QueuePanel() {
const onDragStart = (e: React.DragEvent, index: number) => {
isDraggingInternalRef.current = true;
draggedIdxRef.current = index;
setDraggedIdx(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 }));
e.dataTransfer.setData('text/plain', JSON.stringify({
type: 'queue_reorder',
index
}));
};
const onDragEnterItem = (e: React.DragEvent) => {
@@ -164,58 +160,60 @@ export default function QueuePanel() {
const onDragOverItem = (e: React.DragEvent, index: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy';
dragOverIdxRef.current = index;
setDragOverIdx(index);
};
const onDragEnd = () => {
isDraggingInternalRef.current = false;
draggedIdxRef.current = null;
dragOverIdxRef.current = null;
setDraggedIdx(null);
setDragOverIdx(null);
};
const onDropQueue = async (e: React.DragEvent) => {
const onDropQueue = async (e: React.DragEvent, dropIndex?: number) => {
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 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);
// Handle internal queue reorder using state — more reliable than dataTransfer on Windows/WebView2
if (draggedIdx !== null) {
const targetIdx = dropIndex !== undefined ? dropIndex : queue.length;
if (draggedIdx !== targetIdx) {
reorderQueue(draggedIdx, targetIdx);
}
isDraggingInternalRef.current = false;
setDraggedIdx(null);
setDragOverIdx(null);
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);
isDraggingInternalRef.current = false;
setDraggedIdx(null);
setDragOverIdx(null);
try {
const dataStr = e.dataTransfer.getData('text/plain');
if (!dataStr) return;
const data = JSON.parse(dataStr);
if (data.type === 'song') {
const track = data.track;
if (dropIndex !== undefined) {
// If dropped on a specific item, we might want to insert it there.
// For now we just enqueue it at the end to keep it simple, or insert it.
// Since we don't have an insert method, we use enqueue (appends to end).
enqueue([track]);
} else {
enqueue([track]);
}
} else if (data.type === 'album') {
const albumData = await getAlbum(data.id);
const tracks: Track[] = albumData.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
enqueue(tracks);
}
} catch (err) {
console.error('Drop error', err);
}
};
@@ -224,7 +222,7 @@ export default function QueuePanel() {
className="queue-panel"
onDragEnter={e => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; }}
onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; }}
onDrop={onDropQueue}
onDrop={e => onDropQueue(e)}
style={{
borderLeftWidth: isQueueVisible ? 1 : 0
}}
@@ -316,6 +314,10 @@ export default function QueuePanel() {
onDragEnter={(e) => onDragEnterItem(e)}
onDragOver={(e) => onDragOverItem(e, idx)}
onDragEnd={onDragEnd}
onDrop={(e) => {
e.stopPropagation();
onDropQueue(e, idx);
}}
style={dragStyle}
>
<div className="queue-item-info">
@@ -356,7 +358,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, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
albumId: s.albumId, 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) {
+7 -68
View File
@@ -1,9 +1,9 @@
import React, { useEffect, useState } from 'react';
import React from 'react';
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, ArrowUpCircle
PanelLeftClose, PanelLeft, HelpCircle, Dices
} from 'lucide-react';
const PsysonicLogo = () => (
@@ -21,74 +21,14 @@ const navItems = [
{ icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites' },
];
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
}: {
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 { getVersion } = await import('@tauri-apps/api/app');
const current = await getVersion();
const res = await fetch('https://api.github.com/repos/Psychotoxical/psysonic/releases/latest');
if (!res.ok) return;
const data = await res.json();
const tag: string = data.tag_name ?? '';
if (!cancelled && tag && isNewer(tag, current)) {
setLatestVersion(tag.startsWith('v') ? tag : `v${tag}`);
}
} catch {
// network unavailable or not running in Tauri — silently skip
}
}, 1500);
return () => { cancelled = true; clearTimeout(timer); };
}, []);
return (
<aside className={`sidebar animate-slide-in ${isCollapsed ? 'collapsed' : ''}`}>
@@ -121,11 +61,10 @@ 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 && !latestVersion ? { marginTop: 'auto' } : undefined}
style={isCollapsed ? { marginTop: 'auto' } : undefined}
title={isCollapsed ? t('sidebar.statistics') : undefined}
>
<BarChart3 size={isCollapsed ? 22 : 18} />
+2 -8
View File
@@ -18,10 +18,7 @@ const enTranslation = {
settings: 'Settings',
help: 'Help',
expand: 'Expand Sidebar',
collapse: 'Collapse Sidebar',
updateAvailable: 'Update available',
updateReady: '{{version}} is ready',
updateLink: 'Go to release →'
collapse: 'Collapse Sidebar'
},
home: {
starred: 'Personal Favorites',
@@ -375,10 +372,7 @@ const deTranslation = {
settings: 'Einstellungen',
help: 'Hilfe',
expand: 'Sidebar einblenden',
collapse: 'Sidebar ausblenden',
updateAvailable: 'Update verfügbar',
updateReady: '{{version}} ist bereit',
updateLink: 'Zum Release →'
collapse: 'Sidebar ausblenden'
},
home: {
starred: 'Persönliche Favoriten',
+5 -5
View File
@@ -135,7 +135,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, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
albumId: s.albumId, 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 +145,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, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
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);
@@ -154,7 +154,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, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt,
albumId: song.albumId, duration: song.duration, coverArt: song.coverArt,
track: song.track, year: song.year, bitRate: song.bitRate,
suffix: song.suffix, userRating: song.userRating
};
@@ -410,7 +410,7 @@ export default function AlbumDetail() {
e.preventDefault();
const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track,
albumId: song.albumId, 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,7 +421,7 @@ 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, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track,
albumId: song.albumId, duration: song.duration, coverArt: song.coverArt, track: song.track,
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
};
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
+1 -1
View File
@@ -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, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track,
albumId: song.albumId, duration: song.duration, coverArt: song.coverArt, track: song.track,
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
};
openContextMenu(e.clientX, e.clientY, track, 'song');
+1 -1
View File
@@ -72,7 +72,7 @@ export default function Favorites() {
e.dataTransfer.effectAllowed = 'copy';
const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track,
albumId: song.albumId, duration: song.duration, coverArt: song.coverArt, track: song.track,
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
};
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
+1 -1
View File
@@ -34,7 +34,7 @@ export default function Playlists() {
const data = await getPlaylist(id);
const tracks = data.songs.map((s: any) => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
albumId: s.albumId, 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 -1
View File
@@ -99,7 +99,7 @@ export default function RandomMix() {
e.dataTransfer.effectAllowed = 'copy';
const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track,
albumId: song.albumId, duration: song.duration, coverArt: song.coverArt, track: song.track,
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
};
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
+1 -1
View File
@@ -374,7 +374,7 @@ export default function Settings() {
Psysonic
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
{t('settings.aboutVersion')} 1.0.8
{t('settings.aboutVersion')} 1.0.6
</div>
</div>
</div>
+6 -29
View File
@@ -10,7 +10,6 @@ export interface Track {
artist: string;
album: string;
albumId: string;
artistId?: string;
duration: number;
coverArt?: string;
track?: number;
@@ -76,9 +75,6 @@ interface PlayerState {
let progressInterval: ReturnType<typeof setInterval> | null = null;
let seekDebounce: ReturnType<typeof setTimeout> | null = null;
// Blocks progress interval from overwriting the UI position while GStreamer
// processes a seek on the HTTP pipeline (can take several hundred ms on Linux).
let isSeeking = false;
function clearProgress() {
if (progressInterval) {
@@ -148,8 +144,6 @@ export const usePlayerStore = create<PlayerState>()(
// Stop current
state.howl?.unload();
clearProgress();
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
isSeeking = false;
const newQueue = queue ?? state.queue;
const idx = newQueue.findIndex(t => t.id === track.id);
@@ -168,9 +162,6 @@ export const usePlayerStore = create<PlayerState>()(
progressInterval = setInterval(() => {
const h = get().howl;
if (!h) return;
// Skip position updates while a seek is in flight — GStreamer may
// still report the old position and would overwrite the UI value.
if (isSeeking) return;
const cur = typeof h.seek() === 'number' ? h.seek() as number : 0;
const dur = h.duration() || 1;
const prog = cur / dur;
@@ -253,28 +244,14 @@ export const usePlayerStore = create<PlayerState>()(
const { howl, currentTrack } = get();
if (!howl || !currentTrack) return;
const time = progress * (howl.duration() || currentTrack.duration);
// Update UI immediately and block interval from overwriting it.
// Update UI immediately for responsiveness
set({ progress, currentTime: time });
isSeeking = true;
// Debounce so rapid slider drags collapse into one seek.
// Debounce the actual seek — GStreamer on Linux stalls if two seeks arrive
// before the first one completes (reproducible after the 2nd seek)
if (seekDebounce) clearTimeout(seekDebounce);
seekDebounce = setTimeout(() => {
const h = get().howl;
if (!h) { isSeeking = false; seekDebounce = null; return; }
// GStreamer HTTP pipelines require the element to be paused before a
// seek is accepted reliably. Seek while playing can silently fail on
// Linux (tested on Mint + CachyOS), leaving the pipeline at the old
// position on the next seek.
const wasPlaying = h.playing();
if (wasPlaying) h.pause();
h.seek(time);
// Resume after GStreamer has had time to flush and re-buffer.
seekDebounce = setTimeout(() => {
const h2 = get().howl;
if (h2 && wasPlaying) h2.play();
isSeeking = false;
seekDebounce = null;
}, 300);
get().howl?.seek(time);
seekDebounce = null;
}, 100);
},
@@ -352,7 +329,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, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track,
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
+162 -124
View File
@@ -884,19 +884,19 @@
.empty-state { text-align: center; padding: var(--space-10); color: var(--text-muted); }
/* ─────────────────────────────────────────
Fullscreen Player — Ambient Stage
Fullscreen Player
──────────────────────────────────────────── */
.fs-player {
position: fixed;
inset: 0;
z-index: 9000;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
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,38 +905,13 @@
to { transform: translateY(0); opacity: 1; }
}
/* ── 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); }
}
/* ── Blurred background ── */
/* Blurred background */
.fs-bg {
position: absolute;
inset: -10%;
background-size: cover;
background-position: center;
filter: blur(6px) brightness(0.25) saturate(1.6);
filter: blur(50px) brightness(0.28) saturate(1.6);
transform: scale(1.2);
z-index: 0;
will-change: opacity;
@@ -947,42 +922,17 @@
.fs-bg-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.45);
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%
);
z-index: 0;
pointer-events: none;
}
/* ── 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 ── */
/* Close button */
.fs-close {
position: absolute;
top: 20px;
@@ -993,7 +943,7 @@
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(8px);
color: rgba(255,255,255,0.7);
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
@@ -1002,46 +952,49 @@
}
.fs-close:hover {
background: rgba(255, 255, 255, 0.16);
color: #ffffff;
color: var(--text-primary);
transform: translateY(-1px);
}
/* ── Center stage ── */
.fs-stage {
/* ── Main layout: two columns, left = cover+controls, right = playlist ── */
.fs-layout {
position: relative;
z-index: 1;
display: flex;
flex: 1;
flex-direction: row;
align-items: flex-start;
justify-content: center;
gap: clamp(28px, 4vw, 72px);
padding: 72px clamp(40px, 6vw, 100px) 48px;
min-height: 0;
overflow: hidden;
}
/* Left column: cover + track info + progress + controls */
.fs-left {
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
width: min(440px, 88vw);
padding: 16px 0;
gap: 20px;
flex-shrink: 0;
/* Width = cover size (cover fills 100% of this column) */
width: min(clamp(260px, 36vw, 560px), calc(100vh - 310px));
}
/* 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 */
/* Cover fills the column width */
.fs-cover-wrap {
width: clamp(300px, 30vw, 480px);
max-height: calc(100vh - 300px);
width: 100%;
aspect-ratio: 1 / 1;
border-radius: var(--radius-xl);
overflow: hidden;
box-shadow:
0 30px 90px rgba(0, 0, 0, 0.75),
0 0 0 1px rgba(255, 255, 255, 0.07),
0 0 80px rgba(0, 0, 0, 0.4);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255,255,255,0.06);
flex-shrink: 0;
animation: cover-breathe 9s ease-in-out infinite;
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);
}
.fs-cover {
width: 100%;
@@ -1050,8 +1003,6 @@
display: block;
}
.fs-cover-placeholder {
width: 100%;
height: 100%;
background: var(--ctp-surface0);
display: flex;
align-items: center;
@@ -1059,26 +1010,32 @@
color: var(--text-muted);
}
/* Track info — below cover */
/* Track metadata */
.fs-track-info {
text-align: center;
width: 100%;
}
.fs-title {
font-family: var(--font-display);
font-size: clamp(20px, 3vw, 32px);
font-size: clamp(16px, 1.8vw, 26px);
font-weight: 800;
color: #ffffff;
margin: 0 0 6px;
line-height: 1.15;
margin: 0 0 4px;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fs-album {
font-size: 14px;
color: rgba(255, 255, 255, 0.5);
.fs-artist {
font-size: clamp(13px, 1.1vw, 16px);
font-weight: 600;
color: var(--ctp-lavender);
margin: 0;
}
.fs-album {
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
margin: 3px 0 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -1092,8 +1049,8 @@
border: 1px solid rgba(255,255,255,0.1);
padding: 2px 8px;
border-radius: var(--radius-full);
color: rgba(255, 255, 255, 0.35);
margin-top: 8px;
color: rgba(255, 255, 255, 0.45);
margin-top: 6px;
}
/* Progress bar */
@@ -1104,8 +1061,8 @@
width: 100%;
}
.fs-time {
font-size: 11px;
color: rgba(255,255,255,0.4);
font-size: 12px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
min-width: 36px;
text-align: center;
@@ -1116,12 +1073,8 @@
}
.fs-progress-bar input[type="range"] {
width: 100%;
height: 3px;
background: linear-gradient(
to right,
rgba(255,255,255,0.85) var(--pct),
rgba(255,255,255,0.15) var(--pct)
);
height: 4px;
background: linear-gradient(to right, var(--ctp-mauve) var(--pct), rgba(255,255,255,0.12) var(--pct));
border-radius: 2px;
cursor: pointer;
appearance: none;
@@ -1130,50 +1083,135 @@
.fs-progress-bar input[type="range"]::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 13px;
height: 13px;
width: 14px;
height: 14px;
border-radius: 50%;
background: #ffffff;
background: white;
cursor: pointer;
box-shadow: 0 1px 6px rgba(0,0,0,0.6);
box-shadow: 0 1px 4px rgba(0,0,0,0.5);
transition: transform var(--transition-fast);
}
.fs-progress-bar input[type="range"]:hover::-webkit-slider-thumb { transform: scale(1.3); }
.fs-progress-bar input[type="range"]:hover::-webkit-slider-thumb { transform: scale(1.25); }
/* Transport controls */
.fs-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
gap: 11px;
}
.fs-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
width: 34px;
height: 34px;
border-radius: 50%;
color: rgba(255,255,255,0.6);
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
background: transparent;
}
.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:hover { color: var(--text-primary); background: rgba(255,255,255,0.08); }
.fs-btn.active { color: var(--accent); }
.fs-btn-sm { width: 25px; height: 25px; }
.fs-btn-play {
width: 54px;
height: 54px;
width: 48px;
height: 48px;
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);
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.5);
}
.fs-btn-play:hover {
background: linear-gradient(135deg, var(--ctp-lavender), var(--ctp-mauve));
color: var(--ctp-crust);
transform: scale(1.07);
box-shadow: 0 10px 34px rgba(0,0,0,0.6);
transform: scale(1.06);
box-shadow: 0 8px 28px rgba(0,0,0,0.6);
}
/* Right column: upcoming tracks — top-aligned with cover */
.fs-right {
display: flex;
flex-direction: column;
gap: 12px;
flex-shrink: 0;
width: clamp(240px, 26vw, 380px);
align-self: flex-start;
height: min(clamp(400px, 60vh, 840px), calc(100vh - 180px));
overflow: hidden;
}
.fs-upcoming-title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.4);
margin: 0;
flex-shrink: 0;
}
.fs-upcoming-list {
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
flex: 1;
min-height: 0;
}
.fs-upcoming-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-radius: var(--radius-md);
cursor: pointer;
width: 100%;
text-align: left;
transition: background var(--transition-fast);
background: transparent;
flex-shrink: 0;
}
.fs-upcoming-item:hover { background: rgba(255,255,255,0.07); }
.fs-upcoming-art {
width: 44px;
height: 44px;
border-radius: var(--radius-sm);
object-fit: cover;
flex-shrink: 0;
}
.fs-upcoming-placeholder {
background: var(--ctp-surface1);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
}
.fs-upcoming-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.fs-upcoming-name {
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fs-upcoming-artist {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fs-upcoming-dur {
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
/* Chat */
-64
View File
@@ -164,70 +164,6 @@
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;