mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
feat: Gapless playback, seek recovery, buffered indicator, and UI polish (v1.0.9)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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/),
|
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.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
|
## [1.0.8] - 2026-03-13
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "psysonic",
|
"name": "psysonic",
|
||||||
"version": "1.0.8",
|
"version": "1.0.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
Generated
+1
-1
@@ -2732,7 +2732,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "psysonic"
|
name = "psysonic"
|
||||||
version = "1.0.8"
|
version = "1.0.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "psysonic"
|
name = "psysonic"
|
||||||
version = "1.0.8"
|
version = "1.0.9"
|
||||||
description = "Psysonic Desktop Music Player"
|
description = "Psysonic Desktop Music Player"
|
||||||
authors = []
|
authors = []
|
||||||
license = ""
|
license = ""
|
||||||
|
|||||||
@@ -25,6 +25,8 @@
|
|||||||
"core:window:allow-set-title",
|
"core:window:allow-set-title",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
"core:window:allow-hide",
|
"core:window:allow-hide",
|
||||||
"core:window:allow-show"
|
"core:window:allow-show",
|
||||||
|
"core:window:allow-set-fullscreen",
|
||||||
|
"core:window:allow-is-fullscreen"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default","shell:allow-open","notification:default","global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show"],"platforms":["linux","macOS","windows"]}}
|
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default","shell:allow-open","notification:default","global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","core:window:allow-set-title","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen"],"platforms":["linux","macOS","windows"]}}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Psysonic",
|
"productName": "Psysonic",
|
||||||
"version": "1.0.8",
|
"version": "1.0.9",
|
||||||
"identifier": "dev.psysonic.app",
|
"identifier": "dev.psysonic.app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
+7
-1
@@ -184,9 +184,15 @@ function TauriEventBridge() {
|
|||||||
const previous = usePlayerStore(s => s.previous);
|
const previous = usePlayerStore(s => s.previous);
|
||||||
const { minimizeToTray } = useAuthStore();
|
const { minimizeToTray } = useAuthStore();
|
||||||
|
|
||||||
// Spacebar → play/pause (ignore when focus is in an input)
|
// Spacebar → play/pause, F11 → window fullscreen
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
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;
|
if (e.code !== 'Space') return;
|
||||||
const tag = (e.target as HTMLElement)?.tagName;
|
const tag = (e.target as HTMLElement)?.tagName;
|
||||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ const FsBg = memo(function FsBg({ url }: { url: string }) {
|
|||||||
// ─── Progress bar (isolated — re-renders every tick) ──────────────────────────
|
// ─── 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 buffered = usePlayerStore(s => s.buffered);
|
||||||
const currentTime = usePlayerStore(s => s.currentTime);
|
const currentTime = usePlayerStore(s => s.currentTime);
|
||||||
const seek = usePlayerStore(s => s.seek);
|
const seek = usePlayerStore(s => s.seek);
|
||||||
|
|
||||||
@@ -60,6 +61,9 @@ const FsProgress = memo(function FsProgress({ duration }: { duration: number })
|
|||||||
[seek]
|
[seek]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const pct = progress * 100;
|
||||||
|
const buf = Math.max(pct, buffered * 100);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fs-progress-wrap">
|
<div className="fs-progress-wrap">
|
||||||
<span className="fs-time">{formatTime(currentTime)}</span>
|
<span className="fs-time">{formatTime(currentTime)}</span>
|
||||||
@@ -68,7 +72,10 @@ const FsProgress = memo(function FsProgress({ duration }: { duration: number })
|
|||||||
type="range" min={0} max={1} step={0.001}
|
type="range" min={0} max={1} step={0.001}
|
||||||
value={progress}
|
value={progress}
|
||||||
onChange={handleSeek}
|
onChange={handleSeek}
|
||||||
style={{ '--pct': `${progress * 100}%` } as React.CSSProperties}
|
style={{
|
||||||
|
'--pct': `${pct}%`,
|
||||||
|
'--buf': `${buf}%`,
|
||||||
|
} as React.CSSProperties}
|
||||||
aria-label="progress"
|
aria-label="progress"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ function formatTime(seconds: number): string {
|
|||||||
|
|
||||||
export default function PlayerBar() {
|
export default function PlayerBar() {
|
||||||
const { t } = useTranslation();
|
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 duration = currentTrack?.duration ?? 0;
|
||||||
const coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]);
|
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(parseFloat(e.target.value));
|
||||||
}, [setVolume]);
|
}, [setVolume]);
|
||||||
|
|
||||||
|
const pct = progress * 100;
|
||||||
|
const buf = Math.max(pct, buffered * 100);
|
||||||
const progressStyle = {
|
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 = {
|
const volumeStyle = {
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ export default function QueuePanel() {
|
|||||||
<div className="queue-current-track">
|
<div className="queue-current-track">
|
||||||
<div className="queue-current-cover">
|
<div className="queue-current-cover">
|
||||||
{currentTrack.coverArt ? (
|
{currentTrack.coverArt ? (
|
||||||
<img src={buildCoverArtUrl(currentTrack.coverArt, 400)} alt="" loading="eager" />
|
<img src={buildCoverArtUrl(currentTrack.coverArt, 128)} alt="" loading="eager" />
|
||||||
) : (
|
) : (
|
||||||
<div className="fallback"><Music size={32} /></div>
|
<div className="fallback"><Music size={32} /></div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { version as appVersion } from '../../package.json';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
@@ -74,17 +75,15 @@ export default function Sidebar({
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const timer = setTimeout(async () => {
|
const timer = setTimeout(async () => {
|
||||||
try {
|
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');
|
const res = await fetch('https://api.github.com/repos/Psychotoxical/psysonic/releases/latest');
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const tag: string = data.tag_name ?? '';
|
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}`);
|
setLatestVersion(tag.startsWith('v') ? tag : `v${tag}`);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// network unavailable or not running in Tauri — silently skip
|
// network unavailable — silently skip
|
||||||
}
|
}
|
||||||
}, 1500);
|
}, 1500);
|
||||||
return () => { cancelled = true; clearTimeout(timer); };
|
return () => { cancelled = true; clearTimeout(timer); };
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ export default function AlbumDetail() {
|
|||||||
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
|
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
|
||||||
const [isStarred, setIsStarred] = useState(false);
|
const [isStarred, setIsStarred] = useState(false);
|
||||||
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
|
||||||
|
const [hoveredSongId, setHoveredSongId] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -405,6 +406,8 @@ export default function AlbumDetail() {
|
|||||||
<div
|
<div
|
||||||
key={song.id}
|
key={song.id}
|
||||||
className={`track-row${hasVariousArtists ? ' track-row-va' : ''}`}
|
className={`track-row${hasVariousArtists ? ' track-row-va' : ''}`}
|
||||||
|
onMouseEnter={() => setHoveredSongId(song.id)}
|
||||||
|
onMouseLeave={() => setHoveredSongId(null)}
|
||||||
onDoubleClick={() => handlePlaySong(song)}
|
onDoubleClick={() => handlePlaySong(song)}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -427,7 +430,15 @@ export default function AlbumDetail() {
|
|||||||
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
|
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="track-num" style={{ textAlign: 'center' }}>{song.track ?? i + 1}</div>
|
<div
|
||||||
|
className="track-num"
|
||||||
|
style={{ textAlign: 'center', cursor: hoveredSongId === song.id ? 'pointer' : 'default', color: hoveredSongId === song.id ? 'var(--accent)' : undefined }}
|
||||||
|
onClick={() => handlePlaySong(song)}
|
||||||
|
>
|
||||||
|
{hoveredSongId === song.id
|
||||||
|
? <Play size={13} fill="currentColor" />
|
||||||
|
: (song.track ?? i + 1)}
|
||||||
|
</div>
|
||||||
<div className="track-info">
|
<div className="track-info">
|
||||||
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
|
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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.8
|
{t('settings.aboutVersion')} 1.0.9
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+174
-71
@@ -26,6 +26,7 @@ interface PlayerState {
|
|||||||
queueIndex: number;
|
queueIndex: number;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
progress: number; // 0–1
|
progress: number; // 0–1
|
||||||
|
buffered: number; // 0–1
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
volume: number;
|
volume: number;
|
||||||
howl: Howl | null;
|
howl: Howl | null;
|
||||||
@@ -76,9 +77,13 @@ 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
|
let gstSeeking = false; // true while GStreamer is processing a seek
|
||||||
// processes a seek on the HTTP pipeline (can take several hundred ms on Linux).
|
let pendingSeekTime: number | null = null; // queue at most one seek
|
||||||
let isSeeking = false;
|
let gstSeekWatchdog: ReturnType<typeof setTimeout> | null = null; // safety release if onseek never fires
|
||||||
|
let hangRecoveryPos: number | null = null; // set before a recovery playTrack call so onplay seeks here
|
||||||
|
let gaplessPrewarmedId: string | null = null; // track id whose prefetched Howl has been silently pre-started
|
||||||
|
let hangLastTime = -1; // last observed currentTime for hang detection
|
||||||
|
let hangStallTime = 0; // Date.now() when currentTime last moved
|
||||||
|
|
||||||
function clearProgress() {
|
function clearProgress() {
|
||||||
if (progressInterval) {
|
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
|
// Helper to debounce or fire queue syncs
|
||||||
let syncTimeout: ReturnType<typeof setTimeout> | null = null;
|
let syncTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTime: number) {
|
function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTime: number) {
|
||||||
@@ -110,6 +127,7 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
queueIndex: 0,
|
queueIndex: 0,
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
|
buffered: 0,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
volume: 0.8,
|
volume: 0.8,
|
||||||
howl: null,
|
howl: null,
|
||||||
@@ -140,7 +158,7 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
get().howl?.stop();
|
get().howl?.stop();
|
||||||
get().howl?.seek(0);
|
get().howl?.seek(0);
|
||||||
clearProgress();
|
clearProgress();
|
||||||
set({ isPlaying: false, progress: 0, currentTime: 0 });
|
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||||
},
|
},
|
||||||
|
|
||||||
playTrack: (track, queue) => {
|
playTrack: (track, queue) => {
|
||||||
@@ -149,64 +167,147 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
state.howl?.unload();
|
state.howl?.unload();
|
||||||
clearProgress();
|
clearProgress();
|
||||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
|
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 newQueue = queue ?? state.queue;
|
||||||
const idx = newQueue.findIndex(t => t.id === track.id);
|
const idx = newQueue.findIndex(t => t.id === track.id);
|
||||||
|
|
||||||
const url = buildStreamUrl(track.id);
|
// Reuse a prefetched Howl if available — it's already connected and buffering
|
||||||
const howl = new Howl({
|
const prefetchMap = state.prefetched;
|
||||||
src: [url],
|
let howl: Howl;
|
||||||
html5: true,
|
let gaplessHandoff = false;
|
||||||
volume: state.volume,
|
if (prefetchMap.has(track.id)) {
|
||||||
onplay: () => {
|
howl = prefetchMap.get(track.id)!;
|
||||||
set({ isPlaying: true });
|
prefetchMap.delete(track.id);
|
||||||
// Subsonic / Navidrome Now Playing
|
set({ prefetched: new Map(prefetchMap) });
|
||||||
reportNowPlaying(track.id);
|
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 });
|
howl.on('play', () => {
|
||||||
progressInterval = setInterval(() => {
|
set({ isPlaying: true });
|
||||||
const h = get().howl;
|
reportNowPlaying(track.id);
|
||||||
if (!h) return;
|
|
||||||
// Skip position updates while a seek is in flight — GStreamer may
|
// If recovering from a pipeline hang, seek to the saved position
|
||||||
// still report the old position and would overwrite the UI value.
|
if (hangRecoveryPos !== null) {
|
||||||
if (isSeeking) return;
|
const pos = hangRecoveryPos;
|
||||||
const cur = typeof h.seek() === 'number' ? h.seek() as number : 0;
|
hangRecoveryPos = null;
|
||||||
const dur = h.duration() || 1;
|
gstSeeking = true;
|
||||||
const prog = cur / dur;
|
armGstWatchdog(() => { gstSeeking = false; pendingSeekTime = null; });
|
||||||
|
setTimeout(() => { howl.seek(pos); }, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ scrobbled: false });
|
||||||
|
hangStallTime = Date.now();
|
||||||
|
hangLastTime = -1;
|
||||||
|
|
||||||
|
progressInterval = setInterval(() => {
|
||||||
|
const h = get().howl;
|
||||||
|
if (!h) return;
|
||||||
|
const s = h.seek();
|
||||||
|
const cur = typeof s === 'number' ? s : 0;
|
||||||
|
const dur = h.duration() || 1;
|
||||||
|
const prog = cur / dur;
|
||||||
|
|
||||||
|
// Read buffered ranges from the underlying <audio> element
|
||||||
|
const audioNode = (h as any)._sounds?.[0]?._node as HTMLAudioElement | undefined;
|
||||||
|
if (audioNode?.buffered && audioNode.duration > 0) {
|
||||||
|
let totalBuf = 0;
|
||||||
|
for (let i = 0; i < audioNode.buffered.length; i++) {
|
||||||
|
totalBuf += audioNode.buffered.end(i) - audioNode.buffered.start(i);
|
||||||
|
}
|
||||||
|
set({ currentTime: cur, progress: prog, buffered: Math.min(1, totalBuf / audioNode.duration) });
|
||||||
|
} else {
|
||||||
set({ currentTime: cur, progress: prog });
|
set({ currentTime: cur, progress: prog });
|
||||||
|
}
|
||||||
|
|
||||||
// Scrobble at 50%
|
// Hang detection: if playing but currentTime hasn't moved in 5s, recover
|
||||||
if (prog >= 0.5 && !get().scrobbled) {
|
if (Math.abs(cur - hangLastTime) > 0.05) {
|
||||||
set({ scrobbled: true });
|
hangLastTime = cur;
|
||||||
const { scrobblingEnabled } = useAuthStore.getState();
|
hangStallTime = Date.now();
|
||||||
if (scrobblingEnabled) {
|
} else if (get().isPlaying && Date.now() - hangStallTime > 5000) {
|
||||||
scrobbleSong(track.id, Date.now());
|
const { currentTrack: ct, queue: q } = get();
|
||||||
|
if (ct) {
|
||||||
|
hangRecoveryPos = cur;
|
||||||
|
hangStallTime = Date.now(); // prevent re-trigger while recovering
|
||||||
|
get().playTrack(ct, q);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gapless pre-warm: start next track's Howl silently ~1s before end
|
||||||
|
if (!gaplessPrewarmedId && dur > 2 && dur - cur < 1.0) {
|
||||||
|
const { queue: q, queueIndex: qi, repeatMode: rm, prefetched: pf } = get();
|
||||||
|
const nextIdx = qi + 1;
|
||||||
|
const nextTrack = nextIdx < q.length ? q[nextIdx]
|
||||||
|
: (rm === 'all' && q.length > 0 ? q[0] : null);
|
||||||
|
if (nextTrack && pf.has(nextTrack.id)) {
|
||||||
|
const nh = pf.get(nextTrack.id)!;
|
||||||
|
if (!nh.playing()) { nh.volume(0); nh.play(); gaplessPrewarmedId = nextTrack.id; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 500);
|
|
||||||
|
|
||||||
// Prefetch next 3
|
// Scrobble at 50%
|
||||||
get().prefetchUpcoming(idx + 1, newQueue);
|
if (prog >= 0.5 && !get().scrobbled) {
|
||||||
},
|
set({ scrobbled: true });
|
||||||
onend: () => {
|
const { scrobblingEnabled } = useAuthStore.getState();
|
||||||
clearProgress();
|
if (scrobblingEnabled) scrobbleSong(track.id, Date.now());
|
||||||
set({ isPlaying: false, progress: 0, currentTime: 0 });
|
|
||||||
const { repeatMode, currentTrack, queue } = get();
|
|
||||||
if (repeatMode === 'one' && currentTrack) {
|
|
||||||
get().playTrack(currentTrack, queue);
|
|
||||||
} else {
|
|
||||||
get().next();
|
|
||||||
}
|
}
|
||||||
},
|
}, 500);
|
||||||
onstop: () => {
|
|
||||||
clearProgress();
|
// Prefetch next 3
|
||||||
set({ isPlaying: false });
|
get().prefetchUpcoming(idx + 1, newQueue);
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
howl.play();
|
howl.on('end', () => {
|
||||||
set({ currentTrack: track, queue: newQueue, queueIndex: idx >= 0 ? idx : 0, howl, progress: 0, currentTime: 0 });
|
clearProgress();
|
||||||
|
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||||
|
const { repeatMode, currentTrack, queue } = get();
|
||||||
|
if (repeatMode === 'one' && currentTrack) {
|
||||||
|
get().playTrack(currentTrack, queue);
|
||||||
|
} else {
|
||||||
|
get().next();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
howl.on('stop', () => {
|
||||||
|
clearProgress();
|
||||||
|
set({ isPlaying: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
howl.on('seek', () => {
|
||||||
|
disarmGstWatchdog();
|
||||||
|
gstSeeking = false;
|
||||||
|
hangLastTime = -1;
|
||||||
|
hangStallTime = Date.now();
|
||||||
|
if (pendingSeekTime !== null) {
|
||||||
|
const t = pendingSeekTime;
|
||||||
|
pendingSeekTime = null;
|
||||||
|
gstSeeking = true;
|
||||||
|
armGstWatchdog(() => {
|
||||||
|
gstSeeking = false;
|
||||||
|
pendingSeekTime = null;
|
||||||
|
const { currentTrack: ct, queue: q } = get();
|
||||||
|
if (ct) { hangRecoveryPos = t; get().playTrack(ct, q); }
|
||||||
|
});
|
||||||
|
get().howl?.seek(t);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
howl.play(); // for gapless: resumes from paused state, onplay fires and seeks to 0 via hangRecoveryPos
|
||||||
|
set({ currentTrack: track, queue: newQueue, queueIndex: idx >= 0 ? idx : 0, howl, progress: 0, buffered: 0, currentTime: 0 });
|
||||||
syncQueueToServer(newQueue, track, 0);
|
syncQueueToServer(newQueue, track, 0);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -217,8 +318,14 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
resume: () => {
|
resume: () => {
|
||||||
const { howl, currentTrack } = get();
|
const { howl, currentTrack, queue, currentTime } = get();
|
||||||
if (!howl || !currentTrack) return;
|
if (!currentTrack) return;
|
||||||
|
if (!howl) {
|
||||||
|
// Cold start from restored state (e.g. app relaunch) — resume from saved position
|
||||||
|
if (currentTime > 0) hangRecoveryPos = currentTime;
|
||||||
|
get().playTrack(currentTrack, queue);
|
||||||
|
return;
|
||||||
|
}
|
||||||
howl.play();
|
howl.play();
|
||||||
set({ isPlaying: true });
|
set({ isPlaying: true });
|
||||||
},
|
},
|
||||||
@@ -253,28 +360,24 @@ 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 and block interval from overwriting it.
|
|
||||||
set({ progress, currentTime: time });
|
set({ progress, currentTime: time });
|
||||||
isSeeking = true;
|
|
||||||
// Debounce so rapid slider drags collapse into one seek.
|
|
||||||
if (seekDebounce) clearTimeout(seekDebounce);
|
if (seekDebounce) clearTimeout(seekDebounce);
|
||||||
seekDebounce = setTimeout(() => {
|
seekDebounce = setTimeout(() => {
|
||||||
const h = get().howl;
|
seekDebounce = null;
|
||||||
if (!h) { isSeeking = false; seekDebounce = null; return; }
|
if (gstSeeking) {
|
||||||
// GStreamer HTTP pipelines require the element to be paused before a
|
// GStreamer busy — queue this position; onseek will send it when ready
|
||||||
// seek is accepted reliably. Seek while playing can silently fail on
|
pendingSeekTime = time;
|
||||||
// Linux (tested on Mint + CachyOS), leaving the pipeline at the old
|
return;
|
||||||
// position on the next seek.
|
}
|
||||||
const wasPlaying = h.playing();
|
gstSeeking = true;
|
||||||
if (wasPlaying) h.pause();
|
const seekTarget = time;
|
||||||
h.seek(time);
|
armGstWatchdog(() => {
|
||||||
// Resume after GStreamer has had time to flush and re-buffer.
|
gstSeeking = false;
|
||||||
seekDebounce = setTimeout(() => {
|
pendingSeekTime = null;
|
||||||
const h2 = get().howl;
|
const { currentTrack: ct, queue: q } = get();
|
||||||
if (h2 && wasPlaying) h2.play();
|
if (ct) { hangRecoveryPos = seekTarget; get().playTrack(ct, q); }
|
||||||
isSeeking = false;
|
});
|
||||||
seekDebounce = null;
|
get().howl?.seek(time);
|
||||||
}, 300);
|
|
||||||
}, 100);
|
}, 100);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -299,7 +402,7 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
clearQueue: () => {
|
clearQueue: () => {
|
||||||
get().howl?.unload();
|
get().howl?.unload();
|
||||||
clearProgress();
|
clearProgress();
|
||||||
set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, currentTime: 0, howl: null });
|
set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0, howl: null });
|
||||||
syncQueueToServer([], null, 0);
|
syncQueueToServer([], null, 0);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
+19
-11
@@ -4,14 +4,14 @@
|
|||||||
.hero {
|
.hero {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 300px;
|
height: 360px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-placeholder {
|
.hero-placeholder {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 300px;
|
height: 360px;
|
||||||
background: linear-gradient(135deg, var(--ctp-surface0), var(--ctp-mantle));
|
background: linear-gradient(135deg, var(--ctp-surface0), var(--ctp-mantle));
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -74,8 +74,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hero-cover {
|
.hero-cover {
|
||||||
width: 180px;
|
width: 220px;
|
||||||
height: 180px;
|
height: 220px;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
box-shadow: 0 4px 20px rgba(0,0,0,0.6);
|
box-shadow: 0 4px 20px rgba(0,0,0,0.6);
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
@@ -930,13 +930,22 @@
|
|||||||
50% { transform: scale(1.018); }
|
50% { transform: scale(1.018); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes ken-burns {
|
||||||
|
0% { transform: scale(1.08) translate(0%, 0%); }
|
||||||
|
25% { transform: scale(1.12) translate(-1.5%, 1%); }
|
||||||
|
50% { transform: scale(1.10) translate(1%, -1.5%); }
|
||||||
|
75% { transform: scale(1.13) translate(1.5%, 0.5%); }
|
||||||
|
100% { transform: scale(1.08) translate(0%, 0%); }
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Blurred background ── */
|
/* ── Blurred background ── */
|
||||||
.fs-bg {
|
.fs-bg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: -10%;
|
inset: -15%;
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
filter: blur(6px) brightness(0.25) saturate(1.6);
|
filter: blur(6px) brightness(0.25) saturate(1.6);
|
||||||
|
animation: ken-burns 40s ease-in-out infinite;
|
||||||
transform: scale(1.2);
|
transform: scale(1.2);
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
will-change: opacity;
|
will-change: opacity;
|
||||||
@@ -1036,10 +1045,7 @@
|
|||||||
aspect-ratio: 1 / 1;
|
aspect-ratio: 1 / 1;
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-xl);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow:
|
box-shadow: none;
|
||||||
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;
|
||||||
animation: cover-breathe 9s ease-in-out infinite;
|
animation: cover-breathe 9s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
@@ -1119,8 +1125,10 @@
|
|||||||
height: 3px;
|
height: 3px;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
to right,
|
to right,
|
||||||
rgba(255,255,255,0.85) var(--pct),
|
rgba(255,255,255,0.85) var(--pct, 0%),
|
||||||
rgba(255,255,255,0.15) var(--pct)
|
rgba(255,255,255,0.32) var(--pct, 0%),
|
||||||
|
rgba(255,255,255,0.32) var(--buf, 0%),
|
||||||
|
rgba(255,255,255,0.15) var(--buf, 0%)
|
||||||
);
|
);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
+14
-8
@@ -370,7 +370,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: 2px;
|
||||||
|
padding: 12px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-buttons {
|
.player-buttons {
|
||||||
@@ -507,23 +508,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.queue-current-track {
|
.queue-current-track {
|
||||||
padding: var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-current-cover {
|
.queue-current-cover {
|
||||||
width: 100%;
|
width: 72px;
|
||||||
aspect-ratio: 1 / 1;
|
height: 72px;
|
||||||
|
flex-shrink: 0;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-current-cover img {
|
.queue-current-cover img {
|
||||||
@@ -540,10 +544,12 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-current-info h3 {
|
.queue-current-info h3 {
|
||||||
font-size: 16px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -567,7 +573,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.queue-divider {
|
.queue-divider {
|
||||||
padding: var(--space-4) var(--space-5) 0;
|
padding: var(--space-3) var(--space-4) 0;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user