feat: Ambient Stage, Queue DnD overhaul, and GStreamer fixes (v1.0.8)

This commit is contained in:
Psychotoxical
2026-03-13 21:56:57 +01:00
parent 409c20a79e
commit c9b4bc091e
19 changed files with 318 additions and 329 deletions
+10
View File
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.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 ## [1.0.7] - 2026-03-13
### Added ### Added
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "psysonic", "name": "psysonic",
"version": "1.0.7", "version": "1.0.8",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -2732,7 +2732,7 @@ dependencies = [
[[package]] [[package]]
name = "psysonic" name = "psysonic"
version = "1.0.6" version = "1.0.8"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "psysonic" name = "psysonic"
version = "1.0.7" version = "1.0.8"
description = "Psysonic Desktop Music Player" description = "Psysonic Desktop Music Player"
authors = [] authors = []
license = "" license = ""
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic", "productName": "Psysonic",
"version": "1.0.7", "version": "1.0.8",
"identifier": "dev.psysonic.app", "identifier": "dev.psysonic.app",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+1
View File
@@ -59,6 +59,7 @@ export interface SubsonicSong {
artist: string; artist: string;
album: string; album: string;
albumId: string; albumId: string;
artistId?: string;
duration: number; duration: number;
track?: number; track?: number;
discNumber?: number; discNumber?: number;
+2 -2
View File
@@ -62,7 +62,7 @@ export default function ContextMenu() {
const top = await getTopSongs(artistName); const top = await getTopSongs(artistName);
const radioTracks = [...top, ...similar].map(s => ({ const radioTracks = [...top, ...similar].map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album, 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, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
})); }));
playTrack(radioTracks[0], radioTracks); playTrack(radioTracks[0], radioTracks);
@@ -137,7 +137,7 @@ export default function ContextMenu() {
const albumData = await getAlbum(song.albumId); const albumData = await getAlbum(song.albumId);
const tracks = albumData.songs.map(s => ({ const tracks = albumData.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album, 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, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
})); }));
enqueue(tracks); enqueue(tracks);
+92 -97
View File
@@ -1,10 +1,10 @@
import React, { useCallback, useEffect, useState, useRef, memo } from 'react'; import React, { useCallback, useEffect, useState, useRef, memo } from 'react';
import { import {
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Play, Pause, SkipBack, SkipForward,
ChevronDown, Repeat, Repeat1, Square, Music ChevronDown, Repeat, Repeat1, Square, Music
} from 'lucide-react'; } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo } from '../api/subsonic';
import CachedImage, { useCachedUrl } from './CachedImage'; import CachedImage, { useCachedUrl } from './CachedImage';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -15,9 +15,8 @@ function formatTime(seconds: number): string {
return `${m}:${s.toString().padStart(2, '0')}`; 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 }) { const FsBg = memo(function FsBg({ url }: { url: string }) {
// Each layer: {url, id, visible}
const [layers, setLayers] = useState<Array<{ url: string; id: number; visible: boolean }>>(() => const [layers, setLayers] = useState<Array<{ url: string; id: number; visible: boolean }>>(() =>
url ? [{ url, id: 0, visible: true }] : [] url ? [{ url, id: 0, visible: true }] : []
); );
@@ -26,15 +25,10 @@ const FsBg = memo(function FsBg({ url }: { url: string }) {
useEffect(() => { useEffect(() => {
if (!url) return; if (!url) return;
const id = counterRef.current++; const id = counterRef.current++;
// Add the new layer (opacity 0)
setLayers(prev => [...prev, { url, id, visible: false }]); 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(() => { const t1 = setTimeout(() => {
setLayers(prev => setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id })));
prev.map(l => ({ ...l, visible: l.id === id }))
);
}, 20); }, 20);
// After transition: clean up old layers
const t2 = setTimeout(() => { const t2 = setTimeout(() => {
setLayers(prev => prev.filter(l => l.id === id)); setLayers(prev => prev.filter(l => l.id === id));
}, 800); }, 800);
@@ -55,13 +49,16 @@ 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 FsProgress = memo(function FsProgress({ duration }: { duration: number }) {
const progress = usePlayerStore(s => s.progress); const progress = usePlayerStore(s => s.progress);
const currentTime = usePlayerStore(s => s.currentTime); const currentTime = usePlayerStore(s => s.currentTime);
const seek = usePlayerStore(s => s.seek); 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 ( return (
<div className="fs-progress-wrap"> <div className="fs-progress-wrap">
@@ -80,9 +77,7 @@ 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 FsPlayBtn = memo(function FsPlayBtn() {
const { t } = useTranslation(); const { t } = useTranslation();
const isPlaying = usePlayerStore(s => s.isPlaying); const isPlaying = usePlayerStore(s => s.isPlaying);
@@ -94,30 +89,41 @@ const FsPlayBtn = memo(function FsPlayBtn() {
); );
}); });
// ─── Main component ────────────────────────────────────────────────────────────
interface FullscreenPlayerProps { interface FullscreenPlayerProps {
onClose: () => void; onClose: () => void;
} }
export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) { export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
const { t } = useTranslation(); const { t } = useTranslation();
// Static/slow-changing state only — does NOT subscribe to progress or currentTime
const currentTrack = usePlayerStore(s => s.currentTrack); const currentTrack = usePlayerStore(s => s.currentTrack);
const repeatMode = usePlayerStore(s => s.repeatMode); 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 next = usePlayerStore(s => s.next);
const previous = usePlayerStore(s => s.previous); const previous = usePlayerStore(s => s.previous);
const stop = usePlayerStore(s => s.stop); const stop = usePlayerStore(s => s.stop);
const toggleRepeat = usePlayerStore(s => s.toggleRepeat); const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
const playTrack = usePlayerStore(s => s.playTrack);
const duration = currentTrack?.duration ?? 0; const duration = currentTrack?.duration ?? 0;
const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : ''; const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '';
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : ''; const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
// useCachedUrl must be called unconditionally (hook rules)
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey); 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(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
@@ -126,88 +132,77 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
return ( return (
<div className="fs-player" role="dialog" aria-modal="true" aria-label={t('player.fullscreen')}> <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" /> <div className="fs-bg-overlay" aria-hidden="true" />
{/* Close button */} {/* Layer 2 — drifting color orbs */}
<button className="fs-close" onClick={onClose} aria-label={t('player.closeFullscreen')} data-tooltip={t('player.closeTooltip')} data-tooltip-pos="bottom"> <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} /> <ChevronDown size={28} />
</button> </button>
{/* Two-column layout: left = cover + info + controls, right = playlist */} {/* Center stage — everything vertically + horizontally centered */}
<div className="fs-layout"> <div className="fs-stage">
{/* Left column */} <p className="fs-artist">{currentTrack?.artist ?? '—'}</p>
<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-track-info"> <div className="fs-cover-wrap">
<h1 className="fs-title">{currentTrack?.title ?? '—'}</h1> {coverUrl ? (
<p className="fs-artist">{currentTrack?.artist ?? '—'}</p> <CachedImage
<p className="fs-album">{currentTrack?.album ?? '—'}{currentTrack?.year ? ` · ${currentTrack.year}` : ''}</p> src={coverUrl}
{(currentTrack?.bitRate || currentTrack?.suffix) && ( cacheKey={coverKey}
<span className="fs-codec"> alt={`${currentTrack?.album} Cover`}
{[currentTrack.suffix?.toUpperCase(), currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : ''].filter(Boolean).join(' · ')} className="fs-cover"
</span> />
)} ) : (
</div> <div className="fs-cover fs-cover-placeholder"><Music size={72} /></div>
)}
<FsProgress duration={duration} /> </div>
<div className="fs-controls"> <div className="fs-track-info">
<button className="fs-btn fs-btn-sm" onClick={stop} data-tooltip="Stop"> <h1 className="fs-title">{currentTrack?.title ?? '—'}</h1>
<Square size={14} fill="currentColor" /> <p className="fs-album">
</button> {currentTrack?.album ?? ''}
<button className="fs-btn" onClick={previous} aria-label={t('player.prev')}> {currentTrack?.year ? ` · ${currentTrack.year}` : ''}
<SkipBack size={20} /> </p>
</button> {(currentTrack?.bitRate || currentTrack?.suffix) && (
<FsPlayBtn /> <span className="fs-codec">
<button className="fs-btn" onClick={next} aria-label={t('player.next')}> {[
<SkipForward size={20} /> currentTrack.suffix?.toUpperCase(),
</button> currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : ''
<button ].filter(Boolean).join(' · ')}
className={`fs-btn fs-btn-sm ${repeatMode !== 'off' ? 'active' : ''}`} </span>
onClick={toggleRepeat} )}
data-tooltip={`${t('player.repeat')}: ${repeatMode === 'off' ? t('player.repeatOff') : repeatMode === 'all' ? t('player.repeatAll') : t('player.repeatOne')}`} </div>
>
{repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />} <FsProgress duration={duration} />
</button>
</div> <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> </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>
</div> </div>
); );
+1 -1
View File
@@ -133,7 +133,7 @@ export default function Hero() {
const albumData = await getAlbum(album.id); const albumData = await getAlbum(album.id);
const tracks = albumData.songs.map(s => ({ const tracks = albumData.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album, 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, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
})); }));
usePlayerStore.getState().enqueue(tracks); usePlayerStore.getState().enqueue(tracks);
+1 -1
View File
@@ -138,7 +138,7 @@ export default function LiveSearch() {
onClick={() => { onClick={() => {
playTrack({ playTrack({
id: s.id, title: s.title, artist: s.artist, album: s.album, 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 year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating
}); });
setOpen(false); setQuery(''); setOpen(false); setQuery('');
+44 -46
View File
@@ -125,6 +125,10 @@ export default function QueuePanel() {
const [draggedIdx, setDraggedIdx] = useState<number | null>(null); const [draggedIdx, setDraggedIdx] = useState<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null); const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
const isDraggingInternalRef = useRef(false); 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 [saveModalOpen, setSaveModalOpen] = useState(false);
const [loadModalOpen, setLoadModalOpen] = useState(false); const [loadModalOpen, setLoadModalOpen] = useState(false);
@@ -144,12 +148,12 @@ export default function QueuePanel() {
const onDragStart = (e: React.DragEvent, index: number) => { const onDragStart = (e: React.DragEvent, index: number) => {
isDraggingInternalRef.current = true; isDraggingInternalRef.current = true;
draggedIdxRef.current = index;
setDraggedIdx(index); setDraggedIdx(index);
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', JSON.stringify({ // Store index in dataTransfer too — on macOS WKWebView dragend fires before
type: 'queue_reorder', // drop, so the ref will already be null; dataTransfer survives that race.
index e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'queue_reorder', index }));
}));
}; };
const onDragEnterItem = (e: React.DragEvent) => { const onDragEnterItem = (e: React.DragEvent) => {
@@ -160,60 +164,58 @@ export default function QueuePanel() {
const onDragOverItem = (e: React.DragEvent, index: number) => { const onDragOverItem = (e: React.DragEvent, index: number) => {
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy';
dragOverIdxRef.current = index;
setDragOverIdx(index); setDragOverIdx(index);
}; };
const onDragEnd = () => { const onDragEnd = () => {
isDraggingInternalRef.current = false; isDraggingInternalRef.current = false;
draggedIdxRef.current = null;
dragOverIdxRef.current = null;
setDraggedIdx(null); setDraggedIdx(null);
setDragOverIdx(null); setDragOverIdx(null);
}; };
const onDropQueue = async (e: React.DragEvent, dropIndex?: number) => { const onDropQueue = async (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
// Handle internal queue reorder using state — more reliable than dataTransfer on Windows/WebView2 // Capture refs before resetting — dragend may have already cleared them on Mac.
if (draggedIdx !== null) { const fromIdx = draggedIdxRef.current;
const targetIdx = dropIndex !== undefined ? dropIndex : queue.length; const toIdx = dragOverIdxRef.current ?? queue.length;
if (draggedIdx !== targetIdx) {
reorderQueue(draggedIdx, targetIdx);
}
isDraggingInternalRef.current = false;
setDraggedIdx(null);
setDragOverIdx(null);
return;
}
isDraggingInternalRef.current = false; isDraggingInternalRef.current = false;
draggedIdxRef.current = null;
dragOverIdxRef.current = null;
setDraggedIdx(null); setDraggedIdx(null);
setDragOverIdx(null); setDragOverIdx(null);
// Read dataTransfer — set during dragstart, outlives dragend on all platforms.
let parsedData: any = null;
try { try {
const dataStr = e.dataTransfer.getData('text/plain'); const raw = e.dataTransfer.getData('text/plain');
if (!dataStr) return; if (raw) parsedData = JSON.parse(raw);
const data = JSON.parse(dataStr); } catch { /* ignore */ }
if (data.type === 'song') { // Internal reorder: prefer ref value (fast path), fall back to dataTransfer
const track = data.track; // for the Mac dragend-before-drop race condition.
if (dropIndex !== undefined) { const reorderFrom = fromIdx ?? (parsedData?.type === 'queue_reorder' ? parsedData.index : null);
// If dropped on a specific item, we might want to insert it there. if (reorderFrom !== null) {
// For now we just enqueue it at the end to keep it simple, or insert it. if (reorderFrom !== toIdx) reorderQueue(reorderFrom, toIdx);
// Since we don't have an insert method, we use enqueue (appends to end). return;
enqueue([track]); }
} else {
enqueue([track]); // External drop (song / album dragged from elsewhere in the app)
} if (!parsedData) return;
} else if (data.type === 'album') { if (parsedData.type === 'song') {
const albumData = await getAlbum(data.id); enqueue([parsedData.track]);
const tracks: Track[] = albumData.songs.map(s => ({ } else if (parsedData.type === 'album') {
id: s.id, title: s.title, artist: s.artist, album: s.album, const albumData = await getAlbum(parsedData.id);
albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track, const tracks: Track[] = albumData.songs.map(s => ({
year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, 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,
enqueue(tracks); year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
} }));
} catch (err) { enqueue(tracks);
console.error('Drop error', err);
} }
}; };
@@ -222,7 +224,7 @@ export default function QueuePanel() {
className="queue-panel" className="queue-panel"
onDragEnter={e => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; }} onDragEnter={e => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; }}
onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; }} onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = isDraggingInternalRef.current ? 'move' : 'copy'; }}
onDrop={e => onDropQueue(e)} onDrop={onDropQueue}
style={{ style={{
borderLeftWidth: isQueueVisible ? 1 : 0 borderLeftWidth: isQueueVisible ? 1 : 0
}} }}
@@ -314,10 +316,6 @@ export default function QueuePanel() {
onDragEnter={(e) => onDragEnterItem(e)} onDragEnter={(e) => onDragEnterItem(e)}
onDragOver={(e) => onDragOverItem(e, idx)} onDragOver={(e) => onDragOverItem(e, idx)}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
onDrop={(e) => {
e.stopPropagation();
onDropQueue(e, idx);
}}
style={dragStyle} style={dragStyle}
> >
<div className="queue-item-info"> <div className="queue-item-info">
@@ -358,7 +356,7 @@ export default function QueuePanel() {
const data = await getPlaylist(id); const data = await getPlaylist(id);
const tracks: Track[] = data.songs.map(s => ({ const tracks: Track[] = data.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album, 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, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
})); }));
if (tracks.length > 0) { if (tracks.length > 0) {
+5 -5
View File
@@ -135,7 +135,7 @@ export default function AlbumDetail() {
if (!album) return; if (!album) return;
const tracks = album.songs.map(s => ({ const tracks = album.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album, 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, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
})); }));
if (tracks[0]) playTrack(tracks[0], tracks); if (tracks[0]) playTrack(tracks[0], tracks);
@@ -145,7 +145,7 @@ export default function AlbumDetail() {
if (!album) return; if (!album) return;
const tracks = album.songs.map(s => ({ const tracks = album.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album, 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, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
})); }));
enqueue(tracks); enqueue(tracks);
@@ -154,7 +154,7 @@ export default function AlbumDetail() {
const handlePlaySong = (song: SubsonicSong) => { const handlePlaySong = (song: SubsonicSong) => {
const track = { const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album, 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, track: song.track, year: song.year, bitRate: song.bitRate,
suffix: song.suffix, userRating: song.userRating suffix: song.suffix, userRating: song.userRating
}; };
@@ -410,7 +410,7 @@ export default function AlbumDetail() {
e.preventDefault(); e.preventDefault();
const track = { const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album, 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, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
}; };
openContextMenu(e.clientX, e.clientY, track, 'album-song'); openContextMenu(e.clientX, e.clientY, track, 'album-song');
@@ -421,7 +421,7 @@ export default function AlbumDetail() {
e.dataTransfer.effectAllowed = 'copy'; e.dataTransfer.effectAllowed = 'copy';
const track = { const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album, 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, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
}; };
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track })); e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
+1 -1
View File
@@ -258,7 +258,7 @@ export default function ArtistDetail() {
e.preventDefault(); e.preventDefault();
const track = { const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album, 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, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
}; };
openContextMenu(e.clientX, e.clientY, track, 'song'); openContextMenu(e.clientX, e.clientY, track, 'song');
+1 -1
View File
@@ -72,7 +72,7 @@ export default function Favorites() {
e.dataTransfer.effectAllowed = 'copy'; e.dataTransfer.effectAllowed = 'copy';
const track = { const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album, 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, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
}; };
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track })); 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 data = await getPlaylist(id);
const tracks = data.songs.map((s: any) => ({ const tracks = data.songs.map((s: any) => ({
id: s.id, title: s.title, artist: s.artist, album: s.album, 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, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
})); }));
if (tracks.length > 0) { if (tracks.length > 0) {
+1 -1
View File
@@ -99,7 +99,7 @@ export default function RandomMix() {
e.dataTransfer.effectAllowed = 'copy'; e.dataTransfer.effectAllowed = 'copy';
const track = { const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album, 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, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
}; };
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track })); e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
+1 -1
View File
@@ -374,7 +374,7 @@ export default function Settings() {
Psysonic Psysonic
</div> </div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}> <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
{t('settings.aboutVersion')} 1.0.7 {t('settings.aboutVersion')} 1.0.8
</div> </div>
</div> </div>
</div> </div>
+29 -6
View File
@@ -10,6 +10,7 @@ export interface Track {
artist: string; artist: string;
album: string; album: string;
albumId: string; albumId: string;
artistId?: string;
duration: number; duration: number;
coverArt?: string; coverArt?: string;
track?: number; track?: number;
@@ -75,6 +76,9 @@ interface PlayerState {
let progressInterval: ReturnType<typeof setInterval> | null = null; let progressInterval: ReturnType<typeof setInterval> | null = null;
let seekDebounce: ReturnType<typeof setTimeout> | 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() { function clearProgress() {
if (progressInterval) { if (progressInterval) {
@@ -144,6 +148,8 @@ export const usePlayerStore = create<PlayerState>()(
// Stop current // Stop current
state.howl?.unload(); state.howl?.unload();
clearProgress(); clearProgress();
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
isSeeking = false;
const newQueue = queue ?? state.queue; const newQueue = queue ?? state.queue;
const idx = newQueue.findIndex(t => t.id === track.id); const idx = newQueue.findIndex(t => t.id === track.id);
@@ -162,6 +168,9 @@ export const usePlayerStore = create<PlayerState>()(
progressInterval = setInterval(() => { progressInterval = setInterval(() => {
const h = get().howl; const h = get().howl;
if (!h) return; 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 cur = typeof h.seek() === 'number' ? h.seek() as number : 0;
const dur = h.duration() || 1; const dur = h.duration() || 1;
const prog = cur / dur; const prog = cur / dur;
@@ -244,14 +253,28 @@ export const usePlayerStore = create<PlayerState>()(
const { howl, currentTrack } = get(); const { howl, currentTrack } = get();
if (!howl || !currentTrack) return; if (!howl || !currentTrack) return;
const time = progress * (howl.duration() || currentTrack.duration); const time = progress * (howl.duration() || currentTrack.duration);
// Update UI immediately for responsiveness // Update UI immediately and block interval from overwriting it.
set({ progress, currentTime: time }); set({ progress, currentTime: time });
// Debounce the actual seek — GStreamer on Linux stalls if two seeks arrive isSeeking = true;
// before the first one completes (reproducible after the 2nd seek) // Debounce so rapid slider drags collapse into one seek.
if (seekDebounce) clearTimeout(seekDebounce); if (seekDebounce) clearTimeout(seekDebounce);
seekDebounce = setTimeout(() => { seekDebounce = setTimeout(() => {
get().howl?.seek(time); const h = get().howl;
seekDebounce = null; 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);
}, 100); }, 100);
}, },
@@ -329,7 +352,7 @@ export const usePlayerStore = create<PlayerState>()(
if (q.songs.length > 0) { if (q.songs.length > 0) {
const mappedTracks: Track[] = q.songs.map((s: SubsonicSong) => ({ const mappedTracks: Track[] = q.songs.map((s: SubsonicSong) => ({
id: s.id, title: s.title, artist: s.artist, album: s.album, 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, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
})); }));
+124 -162
View File
@@ -884,19 +884,19 @@
.empty-state { text-align: center; padding: var(--space-10); color: var(--text-muted); } .empty-state { text-align: center; padding: var(--space-10); color: var(--text-muted); }
/* ───────────────────────────────────────── /* ─────────────────────────────────────────
Fullscreen Player Fullscreen Player — Ambient Stage
──────────────────────────────────────────── */ ──────────────────────────────────────────── */
.fs-player { .fs-player {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 9000; z-index: 9000;
display: flex; display: flex;
flex-direction: column; align-items: center;
justify-content: center;
overflow: hidden; overflow: hidden;
will-change: transform; will-change: transform;
contain: layout style paint; contain: layout style paint;
animation: fsIn 280ms cubic-bezier(0.22, 1, 0.36, 1) both; animation: fsIn 280ms cubic-bezier(0.22, 1, 0.36, 1) both;
/* Solid base — guarantees nothing shines through, even at edges */
background: #0e0e1a; background: #0e0e1a;
} }
@@ -905,13 +905,38 @@
to { transform: translateY(0); opacity: 1; } to { transform: translateY(0); opacity: 1; }
} }
/* Blurred background */ /* ── Drifting color orbs ── */
@keyframes orb-a {
0% { transform: translate(0px, 0px) scale(1); }
33% { transform: translate(90px, -50px) scale(1.12); }
66% { transform: translate(-40px, 70px) scale(0.94); }
100% { transform: translate(0px, 0px) scale(1); }
}
@keyframes orb-b {
0% { transform: translate(0px, 0px) scale(1); }
33% { transform: translate(-70px, 40px) scale(1.08); }
66% { transform: translate(50px, -60px) scale(1.14); }
100% { transform: translate(0px, 0px) scale(1); }
}
@keyframes orb-c {
0% { transform: translate(0px, 0px) scale(1); }
50% { transform: translate(60px, 50px) scale(0.9); }
100% { transform: translate(0px, 0px) scale(1); }
}
/* ── Cover breathing ── */
@keyframes cover-breathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.018); }
}
/* ── Blurred background ── */
.fs-bg { .fs-bg {
position: absolute; position: absolute;
inset: -10%; inset: -10%;
background-size: cover; background-size: cover;
background-position: center; background-position: center;
filter: blur(50px) brightness(0.28) saturate(1.6); filter: blur(6px) brightness(0.25) saturate(1.6);
transform: scale(1.2); transform: scale(1.2);
z-index: 0; z-index: 0;
will-change: opacity; will-change: opacity;
@@ -922,17 +947,42 @@
.fs-bg-overlay { .fs-bg-overlay {
position: absolute; position: absolute;
inset: 0; inset: 0;
background: linear-gradient( background: rgba(0, 0, 0, 0.45);
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; z-index: 0;
pointer-events: none; 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 { .fs-close {
position: absolute; position: absolute;
top: 20px; top: 20px;
@@ -943,7 +993,7 @@
border-radius: 50%; border-radius: 50%;
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
color: var(--text-secondary); color: rgba(255,255,255,0.7);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -952,49 +1002,46 @@
} }
.fs-close:hover { .fs-close:hover {
background: rgba(255, 255, 255, 0.16); background: rgba(255, 255, 255, 0.16);
color: var(--text-primary); color: #ffffff;
transform: translateY(-1px); transform: translateY(-1px);
} }
/* ── Main layout: two columns, left = cover+controls, right = playlist ── */ /* ── Center stage ── */
.fs-layout { .fs-stage {
position: relative; position: relative;
z-index: 1; 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; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 20px; gap: 18px;
flex-shrink: 0; width: min(440px, 88vw);
/* Width = cover size (cover fills 100% of this column) */ padding: 16px 0;
width: min(clamp(260px, 36vw, 560px), calc(100vh - 310px));
} }
/* 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 { .fs-cover-wrap {
width: 100%; width: clamp(300px, 30vw, 480px);
max-height: calc(100vh - 300px);
aspect-ratio: 1 / 1; aspect-ratio: 1 / 1;
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
overflow: hidden; 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:
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);
flex-shrink: 0; flex-shrink: 0;
transition: transform var(--transition-slow), box-shadow var(--transition-slow); animation: cover-breathe 9s ease-in-out infinite;
}
.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 { .fs-cover {
width: 100%; width: 100%;
@@ -1003,6 +1050,8 @@
display: block; display: block;
} }
.fs-cover-placeholder { .fs-cover-placeholder {
width: 100%;
height: 100%;
background: var(--ctp-surface0); background: var(--ctp-surface0);
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1010,32 +1059,26 @@
color: var(--text-muted); color: var(--text-muted);
} }
/* Track metadata */ /* Track info — below cover */
.fs-track-info { .fs-track-info {
text-align: center; text-align: center;
width: 100%; width: 100%;
} }
.fs-title { .fs-title {
font-family: var(--font-display); font-family: var(--font-display);
font-size: clamp(16px, 1.8vw, 26px); font-size: clamp(20px, 3vw, 32px);
font-weight: 800; font-weight: 800;
color: #ffffff; color: #ffffff;
margin: 0 0 4px; margin: 0 0 6px;
line-height: 1.2; line-height: 1.15;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.fs-artist {
font-size: clamp(13px, 1.1vw, 16px);
font-weight: 600;
color: var(--ctp-lavender);
margin: 0;
}
.fs-album { .fs-album {
font-size: 13px; font-size: 14px;
color: rgba(255, 255, 255, 0.6); color: rgba(255, 255, 255, 0.5);
margin: 3px 0 0; margin: 0;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -1049,8 +1092,8 @@
border: 1px solid rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.1);
padding: 2px 8px; padding: 2px 8px;
border-radius: var(--radius-full); border-radius: var(--radius-full);
color: rgba(255, 255, 255, 0.45); color: rgba(255, 255, 255, 0.35);
margin-top: 6px; margin-top: 8px;
} }
/* Progress bar */ /* Progress bar */
@@ -1061,8 +1104,8 @@
width: 100%; width: 100%;
} }
.fs-time { .fs-time {
font-size: 12px; font-size: 11px;
color: var(--text-muted); color: rgba(255,255,255,0.4);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
min-width: 36px; min-width: 36px;
text-align: center; text-align: center;
@@ -1073,8 +1116,12 @@
} }
.fs-progress-bar input[type="range"] { .fs-progress-bar input[type="range"] {
width: 100%; width: 100%;
height: 4px; height: 3px;
background: linear-gradient(to right, var(--ctp-mauve) var(--pct), rgba(255,255,255,0.12) var(--pct)); background: linear-gradient(
to right,
rgba(255,255,255,0.85) var(--pct),
rgba(255,255,255,0.15) var(--pct)
);
border-radius: 2px; border-radius: 2px;
cursor: pointer; cursor: pointer;
appearance: none; appearance: none;
@@ -1083,135 +1130,50 @@
.fs-progress-bar input[type="range"]::-webkit-slider-thumb { .fs-progress-bar input[type="range"]::-webkit-slider-thumb {
appearance: none; appearance: none;
-webkit-appearance: none; -webkit-appearance: none;
width: 14px; width: 13px;
height: 14px; height: 13px;
border-radius: 50%; border-radius: 50%;
background: white; background: #ffffff;
cursor: pointer; 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); 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 */ /* Transport controls */
.fs-controls { .fs-controls {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 11px; gap: 14px;
} }
.fs-btn { .fs-btn {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 34px; width: 36px;
height: 34px; height: 36px;
border-radius: 50%; border-radius: 50%;
color: var(--text-secondary); color: rgba(255,255,255,0.6);
cursor: pointer; cursor: pointer;
transition: all var(--transition-fast); transition: all var(--transition-fast);
background: transparent; background: transparent;
} }
.fs-btn:hover { color: var(--text-primary); background: rgba(255,255,255,0.08); } .fs-btn:hover { color: #ffffff; background: rgba(255,255,255,0.1); }
.fs-btn.active { color: var(--accent); } .fs-btn.active { color: var(--ctp-lavender); }
.fs-btn-sm { width: 25px; height: 25px; } .fs-btn-sm { width: 28px; height: 28px; }
.fs-btn-play { .fs-btn-play {
width: 48px; width: 54px;
height: 48px; height: 54px;
background: linear-gradient(135deg, var(--ctp-mauve), var(--ctp-lavender)); background: linear-gradient(135deg, var(--ctp-mauve), var(--ctp-lavender));
color: var(--ctp-crust); color: var(--ctp-crust);
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.5); box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
} }
.fs-btn-play:hover { .fs-btn-play:hover {
background: linear-gradient(135deg, var(--ctp-lavender), var(--ctp-mauve)); background: linear-gradient(135deg, var(--ctp-lavender), var(--ctp-mauve));
color: var(--ctp-crust); color: var(--ctp-crust);
transform: scale(1.06); transform: scale(1.07);
box-shadow: 0 8px 28px rgba(0,0,0,0.6); box-shadow: 0 10px 34px 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 */ /* Chat */