mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
891ab0dd5b
* feat(now-playing): adopt OpenSubsonic playbackReport for live now-playing Drive a small playback state machine (starting → playing ↔ paused → stopped) on the Subsonic-server channel when the server advertises the OpenSubsonic `playbackReport` extension (Navidrome ≥ 0.62), giving `getNowPlaying` a real transport state and an extrapolated position. Reports send `ignoreScrobble=true` so play counts stay on the existing `scrobble.view` 50% path (no double count), and the effective playback speed is included so the server extrapolates position correctly with the speed feature on. - New `playbackReportSession` FSM mirrors the existing `playListenSession` lifecycle hooks and is wired at the same player call sites (start / gapless switch / queue restore / resume / 15s heartbeat / pause / seek / stop / ended / error / app quit). Servers without the extension degrade to the unchanged legacy `scrobble.view?submission=false` presence call. - Gated through the existing serverCapabilities framework: a new `FEATURE_PLAYBACK_REPORT` (auto, extension-detected). The OpenSubsonic extensions probe now stores the full advertised list once and serves both AudioMuse `sonicSimilarity` and `playbackReport` from it, without disturbing the legacy Instant Mix opt-in on pre-0.62 servers. - Now Playing dropdown shows a live position bar and a paused indicator. - reportPlayback uses the real request params (mediaId / mediaType / positionMs). Tests: FSM transitions + gating + legacy fallback, capability resolution, extension-list probe storage/decoupling. Full suite green; no Tauri-boundary changes. * feat(now-playing): glide the Live position bar between polls Extrapolate the position of `playing` entries locally (elapsed × reported playbackRate from the last 10 s poll) and re-render once a second, so the Live progress bar advances smoothly instead of jumping on each refresh. Paused and position-less entries stay frozen. A linear width transition matched to the tick keeps the fill gliding. Only applies to clients that report a position via the playbackReport extension. * fix(now-playing): tighten Live timer layout and report resume immediately Keep the progress-bar width stable without a wide empty gap before the timer: reserve ~2ch inside the current-time span only (right-aligned), not on the whole clock block. Report `playing` to the server as soon as resume() runs, matching the immediate `paused` report on pause instead of waiting for the Rust `audio:playing` event. * docs: CHANGELOG and credits for playbackReport live now-playing (PR #1080)
103 lines
3.9 KiB
TypeScript
103 lines
3.9 KiB
TypeScript
import { invoke } from '@tauri-apps/api/core';
|
|
import { setIsAudioPaused } from './engineState';
|
|
import type { PlayerState } from './playerStoreTypes';
|
|
import { flushQueueSyncToServer } from './queueSync';
|
|
import { playListenSessionFinalize, playListenSessionOnPause } from './playListenSession';
|
|
import { playbackReportPaused, playbackReportStopped } from './playbackReportSession';
|
|
import { pauseRadio, stopRadio } from './radioPlayer';
|
|
import { clearAllPlaybackScheduleTimers } from './scheduleTimers';
|
|
import { clearSeekDebounce } from './seekDebounce';
|
|
import { clearSeekFallbackRetry } from './seekFallbackState';
|
|
import { clearSeekTarget } from './seekTargetState';
|
|
import { tryAcquireTogglePlayLock } from './togglePlayLock';
|
|
import { refreshWaveformForTrack } from './waveformRefresh';
|
|
|
|
type SetState = (
|
|
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
|
|
) => void;
|
|
type GetState = () => PlayerState;
|
|
|
|
/**
|
|
* Light transport actions — everything except `resume` (own module,
|
|
* see `resumeAction.ts`) and scheduled timers (`scheduleActions.ts`).
|
|
* `togglePlay` is guarded so a double media-key tap can't race
|
|
* pause + resume into a stuck state. `resetAudioPause` flips the
|
|
* engine-paused flag without touching the UI `isPlaying`, used by
|
|
* `audio:ended` paths.
|
|
*/
|
|
export function createTransportLightActions(set: SetState, get: GetState): Pick<
|
|
PlayerState,
|
|
'stop' | 'pause' | 'resetAudioPause' | 'togglePlay'
|
|
> {
|
|
return {
|
|
stop: () => {
|
|
void playListenSessionFinalize('stop');
|
|
// Report stopped before the position is reset below so the server drops the
|
|
// now-playing entry at the right point (playbackReport extension).
|
|
void playbackReportStopped();
|
|
clearAllPlaybackScheduleTimers();
|
|
const wasRadio = !!get().currentRadio;
|
|
if (wasRadio) {
|
|
stopRadio();
|
|
} else {
|
|
invoke('audio_stop').catch(console.error);
|
|
}
|
|
setIsAudioPaused(false);
|
|
clearSeekFallbackRetry();
|
|
clearSeekDebounce(); clearSeekTarget();
|
|
// Stop keeps `currentTrack` (the bar still shows the stopped song), so its
|
|
// waveform stays valid. Radio has no analysis waveform — drop the bins.
|
|
const keptTrackId = wasRadio ? null : get().currentTrack?.id ?? null;
|
|
set({
|
|
isPlaying: false,
|
|
progress: 0,
|
|
buffered: 0,
|
|
currentTime: 0,
|
|
currentRadio: null,
|
|
...(keptTrackId ? {} : { waveformBins: null }),
|
|
normalizationNowDb: null,
|
|
normalizationTargetLufs: null,
|
|
normalizationEngineLive: 'off',
|
|
currentPlaybackSource: null,
|
|
enginePreloadedTrackId: null,
|
|
scheduledPauseAtMs: null,
|
|
scheduledPauseStartMs: null,
|
|
scheduledResumeAtMs: null,
|
|
scheduledResumeStartMs: null,
|
|
});
|
|
// Re-hydrate from the analysis DB in case the bins were never loaded or
|
|
// only partially filled while the (now stopped) track was playing.
|
|
if (keptTrackId) void refreshWaveformForTrack(keptTrackId);
|
|
},
|
|
|
|
pause: () => {
|
|
clearAllPlaybackScheduleTimers();
|
|
playListenSessionOnPause();
|
|
if (get().currentRadio) {
|
|
pauseRadio();
|
|
} else {
|
|
invoke('audio_pause').catch(console.error);
|
|
setIsAudioPaused(true);
|
|
playbackReportPaused(get().currentTime);
|
|
// Flush position so a quick close after pause still leaves the
|
|
// server with the right resume point for other devices.
|
|
const s = get();
|
|
if (s.currentTrack) {
|
|
void flushQueueSyncToServer(s.queueItems, s.currentTrack, s.currentTime);
|
|
}
|
|
}
|
|
set({ isPlaying: false, scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
|
|
},
|
|
|
|
resetAudioPause: () => {
|
|
setIsAudioPaused(false);
|
|
},
|
|
|
|
togglePlay: () => {
|
|
if (!tryAcquireTogglePlayLock()) return;
|
|
const { isPlaying } = get();
|
|
isPlaying ? get().pause() : get().resume();
|
|
},
|
|
};
|
|
}
|