diff --git a/CHANGELOG.md b/CHANGELOG.md index bedcf54e..9d5864c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/), 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 diff --git a/package.json b/package.json index 70f4f419..a411c6d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "psysonic", - "version": "1.0.7", + "version": "1.0.8", "private": true, "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index de05f768..aaace869 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2732,7 +2732,7 @@ dependencies = [ [[package]] name = "psysonic" -version = "1.0.6" +version = "1.0.8" dependencies = [ "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index dbd54a72..ef1ba9f4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psysonic" -version = "1.0.7" +version = "1.0.8" description = "Psysonic Desktop Music Player" authors = [] license = "" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 250c75d9..5f31a01c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Psysonic", - "version": "1.0.7", + "version": "1.0.8", "identifier": "dev.psysonic.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index 12768d8d..d8907239 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -59,6 +59,7 @@ export interface SubsonicSong { artist: string; album: string; albumId: string; + artistId?: string; duration: number; track?: number; discNumber?: number; diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 87167fe7..35287441 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -62,7 +62,7 @@ export default function ContextMenu() { const top = await getTopSongs(artistName); const radioTracks = [...top, ...similar].map(s => ({ id: s.id, title: s.title, artist: s.artist, album: s.album, - albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track, + albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, })); playTrack(radioTracks[0], radioTracks); @@ -137,7 +137,7 @@ export default function ContextMenu() { const albumData = await getAlbum(song.albumId); const tracks = albumData.songs.map(s => ({ id: s.id, title: s.title, artist: s.artist, album: s.album, - albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track, + albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, })); enqueue(tracks); diff --git a/src/components/FullscreenPlayer.tsx b/src/components/FullscreenPlayer.tsx index 09ad3e2c..9642b673 100644 --- a/src/components/FullscreenPlayer.tsx +++ b/src/components/FullscreenPlayer.tsx @@ -1,10 +1,10 @@ import React, { useCallback, useEffect, useState, useRef, memo } from 'react'; import { - Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, + Play, Pause, SkipBack, SkipForward, ChevronDown, Repeat, Repeat1, Square, Music } from 'lucide-react'; import { usePlayerStore } from '../store/playerStore'; -import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; +import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo } from '../api/subsonic'; import CachedImage, { useCachedUrl } from './CachedImage'; import { useTranslation } from 'react-i18next'; @@ -15,9 +15,8 @@ function formatTime(seconds: number): string { return `${m}:${s.toString().padStart(2, '0')}`; } -// ─── Crossfading blurred background — two stacked divs for true crossfade ─── +// ─── Crossfading blurred background ─────────────────────────────────────────── const FsBg = memo(function FsBg({ url }: { url: string }) { - // Each layer: {url, id, visible} const [layers, setLayers] = useState>(() => url ? [{ url, id: 0, visible: true }] : [] ); @@ -26,15 +25,10 @@ const FsBg = memo(function FsBg({ url }: { url: string }) { useEffect(() => { if (!url) return; const id = counterRef.current++; - // Add the new layer (opacity 0) setLayers(prev => [...prev, { url, id, visible: false }]); - // One frame later: make new layer visible (0→1) and old ones invisible (1→0) const t1 = setTimeout(() => { - setLayers(prev => - prev.map(l => ({ ...l, visible: l.id === id })) - ); + setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id }))); }, 20); - // After transition: clean up old layers const t2 = setTimeout(() => { setLayers(prev => prev.filter(l => l.id === id)); }, 800); @@ -55,13 +49,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 progress = usePlayerStore(s => s.progress); const currentTime = usePlayerStore(s => s.currentTime); const seek = usePlayerStore(s => s.seek); - const handleSeek = useCallback((e: React.ChangeEvent) => seek(parseFloat(e.target.value)), [seek]); + const handleSeek = useCallback( + (e: React.ChangeEvent) => seek(parseFloat(e.target.value)), + [seek] + ); return (
@@ -80,9 +77,7 @@ const FsProgress = memo(function FsProgress({ duration }: { duration: number }) ); }); - - -// ─── Isolated play/pause button (subscribes to isPlaying only) ─── +// ─── Play/Pause button (isolated — subscribes to isPlaying only) ────────────── const FsPlayBtn = memo(function FsPlayBtn() { const { t } = useTranslation(); const isPlaying = usePlayerStore(s => s.isPlaying); @@ -94,30 +89,41 @@ const FsPlayBtn = memo(function FsPlayBtn() { ); }); +// ─── Main component ──────────────────────────────────────────────────────────── interface FullscreenPlayerProps { onClose: () => void; } export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) { const { t } = useTranslation(); - // Static/slow-changing state only — does NOT subscribe to progress or currentTime const currentTrack = usePlayerStore(s => s.currentTrack); const repeatMode = usePlayerStore(s => s.repeatMode); - const queue = usePlayerStore(s => s.queue); - const queueIndex = usePlayerStore(s => s.queueIndex); const next = usePlayerStore(s => s.next); const previous = usePlayerStore(s => s.previous); const stop = usePlayerStore(s => s.stop); const toggleRepeat = usePlayerStore(s => s.toggleRepeat); - const playTrack = usePlayerStore(s => s.playTrack); - const duration = currentTrack?.duration ?? 0; - const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : ''; - const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : ''; + const duration = currentTrack?.duration ?? 0; + const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : ''; + const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : ''; + // useCachedUrl must be called unconditionally (hook rules) const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey); - const upcoming = queue.slice(queueIndex + 1, queueIndex + 15); - // Close on Escape + // Fetch artist image for background — fall back to cover art if unavailable + const [artistBgUrl, setArtistBgUrl] = useState(''); + useEffect(() => { + setArtistBgUrl(''); + const artistId = currentTrack?.artistId; + if (!artistId) return; + let cancelled = false; + getArtistInfo(artistId).then(info => { + if (!cancelled && info.largeImageUrl) setArtistBgUrl(info.largeImageUrl); + }).catch(() => {}); + return () => { cancelled = true; }; + }, [currentTrack?.artistId]); + + const bgUrl = artistBgUrl || resolvedCoverUrl; + useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); @@ -126,88 +132,77 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) { return (
- {/* Crossfading blurred background */} - + + {/* Layer 1 — blurred artist image (falls back to cover art) */} + diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 2690f13d..1ccd6f31 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -10,6 +10,7 @@ export interface Track { artist: string; album: string; albumId: string; + artistId?: string; duration: number; coverArt?: string; track?: number; @@ -75,6 +76,9 @@ interface PlayerState { let progressInterval: ReturnType | null = null; let seekDebounce: ReturnType | 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) { @@ -144,6 +148,8 @@ export const usePlayerStore = create()( // 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); @@ -162,6 +168,9 @@ export const usePlayerStore = create()( 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; @@ -244,14 +253,28 @@ export const usePlayerStore = create()( const { howl, currentTrack } = get(); if (!howl || !currentTrack) return; 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 }); - // Debounce the actual seek — GStreamer on Linux stalls if two seeks arrive - // before the first one completes (reproducible after the 2nd seek) + isSeeking = true; + // Debounce so rapid slider drags collapse into one seek. if (seekDebounce) clearTimeout(seekDebounce); seekDebounce = setTimeout(() => { - get().howl?.seek(time); - seekDebounce = null; + 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); }, 100); }, @@ -329,7 +352,7 @@ export const usePlayerStore = create()( if (q.songs.length > 0) { const mappedTracks: Track[] = q.songs.map((s: SubsonicSong) => ({ id: s.id, title: s.title, artist: s.artist, album: s.album, - albumId: s.albumId, duration: s.duration, coverArt: s.coverArt, track: s.track, + albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, })); diff --git a/src/styles/components.css b/src/styles/components.css index a3fcdbff..8c47469a 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -884,19 +884,19 @@ .empty-state { text-align: center; padding: var(--space-10); color: var(--text-muted); } /* ───────────────────────────────────────── - Fullscreen Player + Fullscreen Player — Ambient Stage ──────────────────────────────────────────── */ .fs-player { position: fixed; inset: 0; z-index: 9000; display: flex; - flex-direction: column; + align-items: center; + justify-content: center; overflow: hidden; will-change: transform; contain: layout style paint; animation: fsIn 280ms cubic-bezier(0.22, 1, 0.36, 1) both; - /* Solid base — guarantees nothing shines through, even at edges */ background: #0e0e1a; } @@ -905,13 +905,38 @@ 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 { position: absolute; inset: -10%; background-size: cover; 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); z-index: 0; will-change: opacity; @@ -922,17 +947,42 @@ .fs-bg-overlay { position: absolute; inset: 0; - background: linear-gradient( - 180deg, - rgba(17, 17, 27, 0.45) 0%, - rgba(17, 17, 27, 0.75) 65%, - rgba(17, 17, 27, 0.96) 100% - ); + background: rgba(0, 0, 0, 0.45); z-index: 0; pointer-events: none; } -/* Close button */ +/* ── Drifting color orbs ── */ +.fs-orb { + position: absolute; + border-radius: 50%; + filter: blur(110px); + opacity: 0.22; + pointer-events: none; + z-index: 0; +} +.fs-orb-1 { + background: var(--ctp-mauve); + width: 700px; height: 700px; + top: -220px; left: -180px; + animation: orb-a 20s ease-in-out infinite; +} +.fs-orb-2 { + background: var(--ctp-blue); + width: 600px; height: 600px; + bottom: -180px; right: -120px; + animation: orb-b 26s ease-in-out infinite; + animation-delay: -9s; +} +.fs-orb-3 { + background: var(--ctp-lavender); + width: 480px; height: 480px; + top: 35%; right: 5%; + animation: orb-c 17s ease-in-out infinite; + animation-delay: -14s; +} + +/* ── Close button ── */ .fs-close { position: absolute; top: 20px; @@ -943,7 +993,7 @@ border-radius: 50%; background: rgba(255, 255, 255, 0.08); backdrop-filter: blur(8px); - color: var(--text-secondary); + color: rgba(255,255,255,0.7); display: flex; align-items: center; justify-content: center; @@ -952,49 +1002,46 @@ } .fs-close:hover { background: rgba(255, 255, 255, 0.16); - color: var(--text-primary); + color: #ffffff; transform: translateY(-1px); } -/* ── Main layout: two columns, left = cover+controls, right = playlist ── */ -.fs-layout { +/* ── Center stage ── */ +.fs-stage { position: relative; z-index: 1; - display: flex; - flex: 1; - flex-direction: row; - align-items: flex-start; - justify-content: center; - gap: clamp(28px, 4vw, 72px); - padding: 72px clamp(40px, 6vw, 100px) 48px; - min-height: 0; - overflow: hidden; -} - -/* Left column: cover + track info + progress + controls */ -.fs-left { display: flex; flex-direction: column; align-items: center; - gap: 20px; - flex-shrink: 0; - /* Width = cover size (cover fills 100% of this column) */ - width: min(clamp(260px, 36vw, 560px), calc(100vh - 310px)); + gap: 18px; + width: min(440px, 88vw); + padding: 16px 0; } -/* Cover fills the column width */ +/* Artist name — above cover */ +.fs-artist { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--ctp-lavender); + margin: 0; + opacity: 0.9; +} + +/* Cover — breathes slowly */ .fs-cover-wrap { - width: 100%; + width: clamp(300px, 30vw, 480px); + max-height: calc(100vh - 300px); aspect-ratio: 1 / 1; border-radius: var(--radius-xl); overflow: hidden; - box-shadow: 0 24px 80px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255,255,255,0.06); + box-shadow: + 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; - transition: transform var(--transition-slow), box-shadow var(--transition-slow); -} -.fs-cover-wrap:hover { - transform: scale(1.015); - box-shadow: 0 32px 100px rgba(0,0,0,0.8), 0 0 0 1px rgba(255,255,255,0.1); + animation: cover-breathe 9s ease-in-out infinite; } .fs-cover { width: 100%; @@ -1003,6 +1050,8 @@ display: block; } .fs-cover-placeholder { + width: 100%; + height: 100%; background: var(--ctp-surface0); display: flex; align-items: center; @@ -1010,32 +1059,26 @@ color: var(--text-muted); } -/* Track metadata */ +/* Track info — below cover */ .fs-track-info { text-align: center; width: 100%; } .fs-title { font-family: var(--font-display); - font-size: clamp(16px, 1.8vw, 26px); + font-size: clamp(20px, 3vw, 32px); font-weight: 800; color: #ffffff; - margin: 0 0 4px; - line-height: 1.2; + margin: 0 0 6px; + line-height: 1.15; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.fs-artist { - font-size: clamp(13px, 1.1vw, 16px); - font-weight: 600; - color: var(--ctp-lavender); - margin: 0; -} .fs-album { - font-size: 13px; - color: rgba(255, 255, 255, 0.6); - margin: 3px 0 0; + font-size: 14px; + color: rgba(255, 255, 255, 0.5); + margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -1049,8 +1092,8 @@ border: 1px solid rgba(255,255,255,0.1); padding: 2px 8px; border-radius: var(--radius-full); - color: rgba(255, 255, 255, 0.45); - margin-top: 6px; + color: rgba(255, 255, 255, 0.35); + margin-top: 8px; } /* Progress bar */ @@ -1061,8 +1104,8 @@ width: 100%; } .fs-time { - font-size: 12px; - color: var(--text-muted); + font-size: 11px; + color: rgba(255,255,255,0.4); font-variant-numeric: tabular-nums; min-width: 36px; text-align: center; @@ -1073,8 +1116,12 @@ } .fs-progress-bar input[type="range"] { width: 100%; - height: 4px; - background: linear-gradient(to right, var(--ctp-mauve) var(--pct), rgba(255,255,255,0.12) var(--pct)); + height: 3px; + background: linear-gradient( + to right, + rgba(255,255,255,0.85) var(--pct), + rgba(255,255,255,0.15) var(--pct) + ); border-radius: 2px; cursor: pointer; appearance: none; @@ -1083,135 +1130,50 @@ .fs-progress-bar input[type="range"]::-webkit-slider-thumb { appearance: none; -webkit-appearance: none; - width: 14px; - height: 14px; + width: 13px; + height: 13px; border-radius: 50%; - background: white; + background: #ffffff; cursor: pointer; - box-shadow: 0 1px 4px rgba(0,0,0,0.5); + box-shadow: 0 1px 6px rgba(0,0,0,0.6); transition: transform var(--transition-fast); } -.fs-progress-bar input[type="range"]:hover::-webkit-slider-thumb { transform: scale(1.25); } +.fs-progress-bar input[type="range"]:hover::-webkit-slider-thumb { transform: scale(1.3); } /* Transport controls */ .fs-controls { display: flex; align-items: center; justify-content: center; - gap: 11px; + gap: 14px; } .fs-btn { display: flex; align-items: center; justify-content: center; - width: 34px; - height: 34px; + width: 36px; + height: 36px; border-radius: 50%; - color: var(--text-secondary); + color: rgba(255,255,255,0.6); cursor: pointer; transition: all var(--transition-fast); background: transparent; } -.fs-btn:hover { color: var(--text-primary); background: rgba(255,255,255,0.08); } -.fs-btn.active { color: var(--accent); } -.fs-btn-sm { width: 25px; height: 25px; } +.fs-btn:hover { color: #ffffff; background: rgba(255,255,255,0.1); } +.fs-btn.active { color: var(--ctp-lavender); } +.fs-btn-sm { width: 28px; height: 28px; } .fs-btn-play { - width: 48px; - height: 48px; + width: 54px; + height: 54px; background: linear-gradient(135deg, var(--ctp-mauve), var(--ctp-lavender)); color: var(--ctp-crust); - box-shadow: 0 6px 22px rgba(0, 0, 0, 0.5); + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5); } .fs-btn-play:hover { background: linear-gradient(135deg, var(--ctp-lavender), var(--ctp-mauve)); color: var(--ctp-crust); - transform: scale(1.06); - box-shadow: 0 8px 28px rgba(0,0,0,0.6); -} - -/* Right column: upcoming tracks — top-aligned with cover */ -.fs-right { - display: flex; - flex-direction: column; - gap: 12px; - flex-shrink: 0; - width: clamp(240px, 26vw, 380px); - align-self: flex-start; - height: min(clamp(400px, 60vh, 840px), calc(100vh - 180px)); - overflow: hidden; -} -.fs-upcoming-title { - font-size: 11px; - font-weight: 700; - letter-spacing: 0.1em; - text-transform: uppercase; - color: rgba(255, 255, 255, 0.4); - margin: 0; - flex-shrink: 0; -} -.fs-upcoming-list { - display: flex; - flex-direction: column; - gap: 2px; - overflow-y: auto; - flex: 1; - min-height: 0; -} -.fs-upcoming-item { - display: flex; - align-items: center; - gap: 12px; - padding: 10px 14px; - border-radius: var(--radius-md); - cursor: pointer; - width: 100%; - text-align: left; - transition: background var(--transition-fast); - background: transparent; - flex-shrink: 0; -} -.fs-upcoming-item:hover { background: rgba(255,255,255,0.07); } -.fs-upcoming-art { - width: 44px; - height: 44px; - border-radius: var(--radius-sm); - object-fit: cover; - flex-shrink: 0; -} -.fs-upcoming-placeholder { - background: var(--ctp-surface1); - display: flex; - align-items: center; - justify-content: center; - color: var(--text-muted); -} -.fs-upcoming-info { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 3px; -} -.fs-upcoming-name { - font-size: 14px; - font-weight: 500; - color: rgba(255, 255, 255, 0.9); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.fs-upcoming-artist { - font-size: 12px; - color: rgba(255, 255, 255, 0.5); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.fs-upcoming-dur { - font-size: 12px; - color: rgba(255, 255, 255, 0.4); - font-variant-numeric: tabular-nums; - flex-shrink: 0; + transform: scale(1.07); + box-shadow: 0 10px 34px rgba(0,0,0,0.6); } /* Chat */