Files
Psychotoxical-psysonic/src/store/transportLightActions.ts
T
cucadmuh 891ab0dd5b feat(now-playing): OpenSubsonic playbackReport for live now-playing (#1080)
* 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)
2026-06-13 05:31:26 +03:00

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();
},
};
}