From 32571a2986f7ea8bd33064bd5de39a4f59b2d9e6 Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Fri, 13 Mar 2026 23:54:32 +0100 Subject: [PATCH] feat: Gapless playback, seek recovery, buffered indicator, and UI polish (v1.0.9) Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 20 ++ package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/capabilities/default.json | 4 +- src-tauri/gen/schemas/capabilities.json | 2 +- src-tauri/tauri.conf.json | 2 +- src/App.tsx | 8 +- src/components/FullscreenPlayer.tsx | 9 +- src/components/PlayerBar.tsx | 6 +- src/components/QueuePanel.tsx | 2 +- src/components/Sidebar.tsx | 7 +- src/pages/AlbumDetail.tsx | 13 +- src/pages/Settings.tsx | 2 +- src/store/playerStore.ts | 245 +++++++++++++++++------- src/styles/components.css | 30 +-- src/styles/layout.css | 22 ++- 17 files changed, 271 insertions(+), 107 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d5864c6..6942b786 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.9] - 2026-03-13 + +### Added +- **Gapless Playback**: The next track's audio pipeline is silently pre-warmed before the current track ends, eliminating the gap between songs — especially noticeable on live albums and concept records. +- **Pre-caching**: Prefetched Howl instances are now actually reused for playback, giving near-instant track transitions instead of a new HTTP connection each time. +- **Buffered Progress Indicator**: The seek bar now shows a secondary fill indicating how much of the current track has been buffered by the browser — visible in both the Player Bar and Fullscreen Player. +- **Resume on Startup**: Pressing Play after launching the app now resumes the last track at the saved playback position instead of doing nothing. +- **Album Track Hover Play Button**: Hovering over a track number in Album Detail reveals a play button for quick single-click playback. +- **Ken Burns Background**: The Fullscreen Player background now slowly drifts and zooms (Ken Burns effect) for a more cinematic feel. +- **F11 Fullscreen**: Toggle native borderless fullscreen with F11. +- **Compact Queue Now-Playing**: The current track block in the Queue Panel is now a slim horizontal strip (72 px thumbnail) instead of a full-width cover, freeing up significantly more space for the queue list on smaller screens. + +### Fixed +- **GStreamer Seek Stability**: Implemented a three-layer recovery system for Linux/GStreamer seek hangs: (1) seek queuing to prevent overlapping GStreamer seeks, (2) a 2-second watchdog that triggers automatic recovery if a seek never completes, (3) an 8-second hang detector that silently recreates the audio pipeline and resumes from the last known position if playback freezes entirely. +- **Fullscreen Player**: Removed drop shadow from cover art — looks cleaner on lighter artist backgrounds. + +### Changed +- **Hero Section**: Increased height (300 → 360 px) and cover art size (180 → 220 px) to prevent long album titles from clipping. +- **Player Bar**: Controls and progress bar moved closer together for a more balanced layout. + ## [1.0.8] - 2026-03-13 ### Added diff --git a/package.json b/package.json index a411c6d4..19b55714 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "psysonic", - "version": "1.0.8", + "version": "1.0.9", "private": true, "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index aaace869..a6d3f38a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2732,7 +2732,7 @@ dependencies = [ [[package]] name = "psysonic" -version = "1.0.8" +version = "1.0.9" dependencies = [ "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ef1ba9f4..8f85e118 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psysonic" -version = "1.0.8" +version = "1.0.9" description = "Psysonic Desktop Music Player" authors = [] license = "" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index b5f014a9..bd7f7cba 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -25,6 +25,8 @@ "core:window:allow-set-title", "core:window:allow-close", "core:window:allow-hide", - "core:window:allow-show" + "core:window:allow-show", + "core:window:allow-set-fullscreen", + "core:window:allow-is-fullscreen" ] } diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index c0e94a76..522ed2db 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default","shell:allow-open","notification:default","global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show"],"platforms":["linux","macOS","windows"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default","shell:allow-open","notification:default","global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen"],"platforms":["linux","macOS","windows"]}} \ No newline at end of file diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5f31a01c..49560d02 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.8", + "version": "1.0.9", "identifier": "dev.psysonic.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index 7ed5fb43..0a54541a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -184,9 +184,15 @@ function TauriEventBridge() { const previous = usePlayerStore(s => s.previous); const { minimizeToTray } = useAuthStore(); - // Spacebar → play/pause (ignore when focus is in an input) + // Spacebar → play/pause, F11 → window fullscreen useEffect(() => { const onKey = (e: KeyboardEvent) => { + if (e.code === 'F11') { + e.preventDefault(); + const win = getCurrentWindow(); + win.isFullscreen().then(fs => win.setFullscreen(!fs)); + return; + } if (e.code !== 'Space') return; const tag = (e.target as HTMLElement)?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; diff --git a/src/components/FullscreenPlayer.tsx b/src/components/FullscreenPlayer.tsx index 9642b673..db35271c 100644 --- a/src/components/FullscreenPlayer.tsx +++ b/src/components/FullscreenPlayer.tsx @@ -52,6 +52,7 @@ const FsBg = memo(function FsBg({ url }: { url: string }) { // ─── Progress bar (isolated — re-renders every tick) ────────────────────────── const FsProgress = memo(function FsProgress({ duration }: { duration: number }) { const progress = usePlayerStore(s => s.progress); + const buffered = usePlayerStore(s => s.buffered); const currentTime = usePlayerStore(s => s.currentTime); const seek = usePlayerStore(s => s.seek); @@ -60,6 +61,9 @@ const FsProgress = memo(function FsProgress({ duration }: { duration: number }) [seek] ); + const pct = progress * 100; + const buf = Math.max(pct, buffered * 100); + return (
{formatTime(currentTime)} @@ -68,7 +72,10 @@ const FsProgress = memo(function FsProgress({ duration }: { duration: number }) type="range" min={0} max={1} step={0.001} value={progress} onChange={handleSeek} - style={{ '--pct': `${progress * 100}%` } as React.CSSProperties} + style={{ + '--pct': `${pct}%`, + '--buf': `${buf}%`, + } as React.CSSProperties} aria-label="progress" />
diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index 7493aba9..5ffd8d36 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -16,7 +16,7 @@ function formatTime(seconds: number): string { export default function PlayerBar() { const { t } = useTranslation(); - const { currentTrack, isPlaying, progress, currentTime, volume, togglePlay, next, previous, seek, setVolume, isQueueVisible, toggleQueue, stop, toggleRepeat, repeatMode, toggleFullscreen } = usePlayerStore(); + const { currentTrack, isPlaying, progress, buffered, currentTime, volume, togglePlay, next, previous, seek, setVolume, isQueueVisible, toggleQueue, stop, toggleRepeat, repeatMode, toggleFullscreen } = usePlayerStore(); const duration = currentTrack?.duration ?? 0; const coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]); @@ -30,8 +30,10 @@ export default function PlayerBar() { setVolume(parseFloat(e.target.value)); }, [setVolume]); + const pct = progress * 100; + const buf = Math.max(pct, buffered * 100); const progressStyle = { - background: `linear-gradient(to right, var(--ctp-mauve) ${progress * 100}%, var(--ctp-surface2) ${progress * 100}%)`, + background: `linear-gradient(to right, var(--ctp-mauve) ${pct}%, var(--ctp-overlay0) ${pct}%, var(--ctp-overlay0) ${buf}%, var(--ctp-surface2) ${buf}%)`, }; const volumeStyle = { diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index f0f59b82..b719ed1c 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -253,7 +253,7 @@ export default function QueuePanel() {
{currentTrack.coverArt ? ( - + ) : (
)} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 10d0ff58..9e22f0b7 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react'; +import { version as appVersion } from '../../package.json'; import { NavLink } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { @@ -74,17 +75,15 @@ export default function Sidebar({ let cancelled = false; const timer = setTimeout(async () => { try { - const { getVersion } = await import('@tauri-apps/api/app'); - const current = await getVersion(); const res = await fetch('https://api.github.com/repos/Psychotoxical/psysonic/releases/latest'); if (!res.ok) return; const data = await res.json(); const tag: string = data.tag_name ?? ''; - if (!cancelled && tag && isNewer(tag, current)) { + if (!cancelled && tag && isNewer(tag, appVersion)) { setLatestVersion(tag.startsWith('v') ? tag : `v${tag}`); } } catch { - // network unavailable or not running in Tauri — silently skip + // network unavailable — silently skip } }, 1500); return () => { cancelled = true; clearTimeout(timer); }; diff --git a/src/pages/AlbumDetail.tsx b/src/pages/AlbumDetail.tsx index c0d3cb50..5527994a 100644 --- a/src/pages/AlbumDetail.tsx +++ b/src/pages/AlbumDetail.tsx @@ -110,6 +110,7 @@ export default function AlbumDetail() { const [downloadProgress, setDownloadProgress] = useState(null); const [isStarred, setIsStarred] = useState(false); const [starredSongs, setStarredSongs] = useState>(new Set()); + const [hoveredSongId, setHoveredSongId] = useState(null); useEffect(() => { if (!id) return; @@ -405,6 +406,8 @@ export default function AlbumDetail() {
setHoveredSongId(song.id)} + onMouseLeave={() => setHoveredSongId(null)} onDoubleClick={() => handlePlaySong(song)} onContextMenu={(e) => { e.preventDefault(); @@ -427,7 +430,15 @@ export default function AlbumDetail() { e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track })); }} > -
{song.track ?? i + 1}
+
handlePlaySong(song)} + > + {hoveredSongId === song.id + ? + : (song.track ?? i + 1)} +
{song.title}
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 285a69a6..bc059313 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -374,7 +374,7 @@ export default function Settings() { Psysonic
- {t('settings.aboutVersion')} 1.0.8 + {t('settings.aboutVersion')} 1.0.9
diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 1ccd6f31..553c7bc0 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -26,6 +26,7 @@ interface PlayerState { queueIndex: number; isPlaying: boolean; progress: number; // 0–1 + buffered: number; // 0–1 currentTime: number; volume: number; howl: Howl | null; @@ -76,9 +77,13 @@ 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; +let gstSeeking = false; // true while GStreamer is processing a seek +let pendingSeekTime: number | null = null; // queue at most one seek +let gstSeekWatchdog: ReturnType | null = null; // safety release if onseek never fires +let hangRecoveryPos: number | null = null; // set before a recovery playTrack call so onplay seeks here +let gaplessPrewarmedId: string | null = null; // track id whose prefetched Howl has been silently pre-started +let hangLastTime = -1; // last observed currentTime for hang detection +let hangStallTime = 0; // Date.now() when currentTime last moved function clearProgress() { if (progressInterval) { @@ -87,6 +92,18 @@ function clearProgress() { } } +function armGstWatchdog(cb: () => void) { + if (gstSeekWatchdog) clearTimeout(gstSeekWatchdog); + gstSeekWatchdog = setTimeout(() => { + gstSeekWatchdog = null; + cb(); + }, 2000); +} + +function disarmGstWatchdog() { + if (gstSeekWatchdog) { clearTimeout(gstSeekWatchdog); gstSeekWatchdog = null; } +} + // Helper to debounce or fire queue syncs let syncTimeout: ReturnType | null = null; function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTime: number) { @@ -110,6 +127,7 @@ export const usePlayerStore = create()( queueIndex: 0, isPlaying: false, progress: 0, + buffered: 0, currentTime: 0, volume: 0.8, howl: null, @@ -140,7 +158,7 @@ export const usePlayerStore = create()( get().howl?.stop(); get().howl?.seek(0); clearProgress(); - set({ isPlaying: false, progress: 0, currentTime: 0 }); + set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); }, playTrack: (track, queue) => { @@ -149,64 +167,147 @@ export const usePlayerStore = create()( state.howl?.unload(); clearProgress(); if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } - isSeeking = false; + disarmGstWatchdog(); + gstSeeking = false; + pendingSeekTime = null; + hangLastTime = -1; + hangStallTime = 0; + gaplessPrewarmedId = null; const newQueue = queue ?? state.queue; const idx = newQueue.findIndex(t => t.id === track.id); - const url = buildStreamUrl(track.id); - const howl = new Howl({ - src: [url], - html5: true, - volume: state.volume, - onplay: () => { - set({ isPlaying: true }); - // Subsonic / Navidrome Now Playing - reportNowPlaying(track.id); + // Reuse a prefetched Howl if available — it's already connected and buffering + const prefetchMap = state.prefetched; + let howl: Howl; + let gaplessHandoff = false; + if (prefetchMap.has(track.id)) { + howl = prefetchMap.get(track.id)!; + prefetchMap.delete(track.id); + set({ prefetched: new Map(prefetchMap) }); + if (howl.playing()) { + // Gapless: pipeline already running — pause, seek to 0, then play + gaplessHandoff = true; + howl.pause(); + hangRecoveryPos = 0; // onplay will seek to position 0 + } + howl.volume(state.volume); + } else { + howl = new Howl({ src: [buildStreamUrl(track.id)], html5: true, volume: state.volume }); + } - set({ scrobbled: false }); - progressInterval = setInterval(() => { - const h = get().howl; - if (!h) return; - // 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; + howl.on('play', () => { + set({ isPlaying: true }); + reportNowPlaying(track.id); + + // If recovering from a pipeline hang, seek to the saved position + if (hangRecoveryPos !== null) { + const pos = hangRecoveryPos; + hangRecoveryPos = null; + gstSeeking = true; + armGstWatchdog(() => { gstSeeking = false; pendingSeekTime = null; }); + setTimeout(() => { howl.seek(pos); }, 50); + } + + set({ scrobbled: false }); + hangStallTime = Date.now(); + hangLastTime = -1; + + progressInterval = setInterval(() => { + const h = get().howl; + if (!h) return; + const s = h.seek(); + const cur = typeof s === 'number' ? s : 0; + const dur = h.duration() || 1; + const prog = cur / dur; + + // Read buffered ranges from the underlying