mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
feat(player-stats): local listening history tab with heatmap and summaries (#849)
* feat(player-stats): local listening history tab with heatmap and summaries Record play sessions in library.sqlite when the library index is enabled, add Rust read APIs and Tauri commands for year/day aggregates, and ship the Player stats UI with session clustering, event-driven live refresh, and a notice when some servers are excluded from indexing. * test(player-stats): split play_session repo and expand test coverage Move the repository into play_session/ (completion, cluster, integration tests), add remap/purge/FK coverage in Rust, and cover ingestion gates plus live-refresh hooks on the frontend per spec v0.3. * docs(release): CHANGELOG and credits for player stats (PR #849) * fix(player-stats): satisfy tsc and clippy CI gates Use InternetRadioStation field names in the radio skip test and replace manual month/day range checks with RangeInclusive::contains.
This commit is contained in:
@@ -4,6 +4,12 @@ import { invoke } from '@tauri-apps/api/core';
|
||||
import { lastfmGetTrackLoved, lastfmScrobble, lastfmUpdateNowPlaying } from '../api/lastfm';
|
||||
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
|
||||
import { notifyLibraryPlaybackHint } from './libraryPlaybackHint';
|
||||
import {
|
||||
playListenSessionFinalize,
|
||||
playListenSessionOnProgress,
|
||||
playListenSessionOnTrackSwitched,
|
||||
playListenSessionOpen,
|
||||
} from './playListenSession';
|
||||
import { getPerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
import { bumpPerfCounter } from '../utils/perf/perfTelemetry';
|
||||
import { getPlaybackServerId } from '../utils/playback/playbackServer';
|
||||
@@ -75,13 +81,15 @@ export type NormalizationStatePayload = {
|
||||
targetLufs: number;
|
||||
};
|
||||
|
||||
export function handleAudioPlaying(_duration: number): void {
|
||||
export function handleAudioPlaying(duration: number): void {
|
||||
setDeferHotCachePrefetch(false);
|
||||
resetProgressEmitThrottles();
|
||||
usePlayerStore.setState({ isPlaying: true, isPlaybackBuffering: false });
|
||||
// Tell the library scheduler to throttle bulk crawl while a stream
|
||||
// is active (spec §6.2.4). No-op unless the index is enabled.
|
||||
notifyLibraryPlaybackHint('playing');
|
||||
const track = usePlayerStore.getState().currentTrack;
|
||||
if (track) {
|
||||
void playListenSessionOpen(track, getPlaybackServerId(), duration);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleAudioProgress(
|
||||
@@ -142,6 +150,7 @@ export function handleAudioProgress(
|
||||
const dur = duration > 0 ? duration : track.duration;
|
||||
if (dur <= 0) return;
|
||||
const progress = displayTime / dur;
|
||||
playListenSessionOnProgress(current_time, buffering, dur).catch(() => {});
|
||||
if (!progressUiDisabled) {
|
||||
const nowLive = Date.now();
|
||||
const live = getPlaybackProgressSnapshot();
|
||||
@@ -311,16 +320,14 @@ export function handleAudioProgress(
|
||||
}
|
||||
|
||||
export function handleAudioEnded(): void {
|
||||
// Playback stopped — let the library scheduler resume normal crawl
|
||||
// parallelism (spec §6.2.4). No-op unless the index is enabled.
|
||||
notifyLibraryPlaybackHint('idle');
|
||||
|
||||
// If a gapless switch happened recently, this ended event is stale — the
|
||||
// progress task fired it for the OLD source before seeing the chained one.
|
||||
if (Date.now() - getLastGaplessSwitchTime() < 600) {
|
||||
return;
|
||||
}
|
||||
|
||||
void playListenSessionFinalize('ended');
|
||||
|
||||
// Radio stream disconnected — just stop; don't advance queue.
|
||||
if (usePlayerStore.getState().currentRadio) {
|
||||
setIsAudioPaused(false);
|
||||
@@ -392,6 +399,8 @@ export function handleAudioTrackSwitched(_duration: number): void {
|
||||
|
||||
if (!nextTrack) return;
|
||||
|
||||
void playListenSessionOnTrackSwitched(nextTrack);
|
||||
|
||||
const switchServerId = getPlaybackServerId();
|
||||
const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId);
|
||||
const switchPlaybackSource = playbackSourceHintForResolvedUrl(nextTrack.id, switchServerId, switchResolvedUrl);
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { onInvoke } from '../test/mocks/tauri';
|
||||
import { useLibraryIndexStore } from './libraryIndexStore';
|
||||
import {
|
||||
_resetPlayListenSessionForTest,
|
||||
playListenSessionFinalize,
|
||||
playListenSessionOnProgress,
|
||||
playListenSessionOpen,
|
||||
resolveDurationSecHint,
|
||||
} from './playListenSession';
|
||||
import { onPlaySessionRecorded } from './playSessionRecorded';
|
||||
|
||||
vi.mock('../utils/library/libraryReady', () => ({
|
||||
libraryIsReady: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/playback/playbackServer', () => ({
|
||||
getPlaybackServerId: vi.fn(() => 'server-1'),
|
||||
}));
|
||||
|
||||
import type { Track } from './playerStoreTypes';
|
||||
|
||||
const testTrack: Track = {
|
||||
id: 't1',
|
||||
title: 'A',
|
||||
artist: 'B',
|
||||
album: '',
|
||||
albumId: '',
|
||||
duration: 180,
|
||||
};
|
||||
|
||||
describe('playListenSession', () => {
|
||||
beforeEach(async () => {
|
||||
_resetPlayListenSessionForTest();
|
||||
useLibraryIndexStore.setState({
|
||||
masterEnabled: true,
|
||||
syncExcludedByServer: {},
|
||||
});
|
||||
const { usePlayerStore } = await import('./playerStore');
|
||||
const { usePreviewStore } = await import('./previewStore');
|
||||
usePlayerStore.setState({
|
||||
currentRadio: null,
|
||||
isPlaying: true,
|
||||
currentTrack: testTrack,
|
||||
});
|
||||
usePreviewStore.setState({ previewingId: null });
|
||||
onInvoke('library_record_play_session', () => undefined);
|
||||
});
|
||||
|
||||
it('does not invoke when listenedSec <= 10', async () => {
|
||||
await playListenSessionOpen(testTrack, 'server-1');
|
||||
await playListenSessionOnProgress(5, false);
|
||||
await playListenSessionFinalize('ended');
|
||||
expect(invoke).not.toHaveBeenCalledWith('library_record_play_session', expect.anything());
|
||||
});
|
||||
|
||||
it('invokes once after >10s listened', async () => {
|
||||
vi.useFakeTimers();
|
||||
await playListenSessionOpen(testTrack, 'server-1');
|
||||
vi.setSystemTime(Date.now() + 15_000);
|
||||
await playListenSessionOnProgress(12, false);
|
||||
await playListenSessionFinalize('ended');
|
||||
vi.useRealTimers();
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'library_record_play_session',
|
||||
expect.objectContaining({
|
||||
input: expect.objectContaining({
|
||||
trackId: 't1',
|
||||
serverId: 'server-1',
|
||||
endReason: 'ended',
|
||||
durationSecHint: 180,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('picks up engine duration from progress ticks', async () => {
|
||||
const { usePlayerStore } = await import('./playerStore');
|
||||
usePlayerStore.setState({
|
||||
currentTrack: { ...testTrack, duration: 0 },
|
||||
});
|
||||
vi.useFakeTimers();
|
||||
await playListenSessionOpen({ ...testTrack, duration: 0 }, 'server-1', 240);
|
||||
vi.setSystemTime(Date.now() + 15_000);
|
||||
await playListenSessionOnProgress(12, false, 240);
|
||||
await playListenSessionFinalize('ended');
|
||||
vi.useRealTimers();
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'library_record_play_session',
|
||||
expect.objectContaining({
|
||||
input: expect.objectContaining({ durationSecHint: 240 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('skips when index disabled', async () => {
|
||||
useLibraryIndexStore.setState({ masterEnabled: false });
|
||||
await playListenSessionOpen(testTrack, 'server-1');
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(Date.now() + 20_000);
|
||||
await playListenSessionOnProgress(15, false);
|
||||
await playListenSessionFinalize('ended');
|
||||
vi.useRealTimers();
|
||||
expect(invoke).not.toHaveBeenCalledWith('library_record_play_session', expect.anything());
|
||||
});
|
||||
|
||||
it('skips preview playback', async () => {
|
||||
const { usePreviewStore } = await import('./previewStore');
|
||||
usePreviewStore.setState({ previewingId: 'preview-1' });
|
||||
vi.useFakeTimers();
|
||||
await playListenSessionOpen(testTrack, 'server-1');
|
||||
vi.setSystemTime(Date.now() + 20_000);
|
||||
await playListenSessionOnProgress(15, false);
|
||||
await playListenSessionFinalize('ended');
|
||||
vi.useRealTimers();
|
||||
expect(invoke).not.toHaveBeenCalledWith('library_record_play_session', expect.anything());
|
||||
});
|
||||
|
||||
it('skips radio playback', async () => {
|
||||
const { usePlayerStore } = await import('./playerStore');
|
||||
usePlayerStore.setState({
|
||||
currentRadio: { id: 'r1', name: 'Radio', streamUrl: 'http://x' },
|
||||
});
|
||||
vi.useFakeTimers();
|
||||
await playListenSessionOpen(testTrack, 'server-1');
|
||||
vi.setSystemTime(Date.now() + 20_000);
|
||||
await playListenSessionOnProgress(15, false);
|
||||
await playListenSessionFinalize('ended');
|
||||
vi.useRealTimers();
|
||||
expect(invoke).not.toHaveBeenCalledWith('library_record_play_session', expect.anything());
|
||||
});
|
||||
|
||||
it('does not accumulate listened time while paused or buffering', async () => {
|
||||
const { usePlayerStore } = await import('./playerStore');
|
||||
vi.useFakeTimers();
|
||||
await playListenSessionOpen(testTrack, 'server-1');
|
||||
vi.setSystemTime(Date.now() + 15_000);
|
||||
usePlayerStore.setState({ isPlaying: false });
|
||||
await playListenSessionOnProgress(12, false);
|
||||
vi.setSystemTime(Date.now() + 30_000);
|
||||
await playListenSessionOnProgress(12, true);
|
||||
await playListenSessionFinalize('ended');
|
||||
vi.useRealTimers();
|
||||
expect(invoke).not.toHaveBeenCalledWith('library_record_play_session', expect.anything());
|
||||
});
|
||||
|
||||
it('skips when library is not ready', async () => {
|
||||
const { libraryIsReady } = await import('../utils/library/libraryReady');
|
||||
vi.mocked(libraryIsReady).mockResolvedValueOnce(false);
|
||||
vi.useFakeTimers();
|
||||
await playListenSessionOpen(testTrack, 'server-1');
|
||||
vi.setSystemTime(Date.now() + 20_000);
|
||||
await playListenSessionOnProgress(15, false);
|
||||
await playListenSessionFinalize('ended');
|
||||
vi.useRealTimers();
|
||||
expect(invoke).not.toHaveBeenCalledWith('library_record_play_session', expect.anything());
|
||||
});
|
||||
|
||||
it('emits play-session-recorded after a persisted listen', async () => {
|
||||
const listener = vi.fn();
|
||||
const unsub = onPlaySessionRecorded(listener);
|
||||
vi.useFakeTimers();
|
||||
await playListenSessionOpen(testTrack, 'server-1');
|
||||
vi.setSystemTime(Date.now() + 15_000);
|
||||
await playListenSessionOnProgress(12, false);
|
||||
await playListenSessionFinalize('ended');
|
||||
vi.useRealTimers();
|
||||
unsub();
|
||||
expect(listener).toHaveBeenCalledWith({
|
||||
serverId: 'server-1',
|
||||
trackId: 't1',
|
||||
startedAtMs: expect.any(Number),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDurationSecHint', () => {
|
||||
it('returns zero when no positive durations are available', () => {
|
||||
expect(resolveDurationSecHint(null)).toBe(0);
|
||||
expect(resolveDurationSecHint({ duration: 0 }, 0, undefined)).toBe(0);
|
||||
});
|
||||
|
||||
it('prefers the largest finite positive hint', () => {
|
||||
expect(resolveDurationSecHint({ duration: 180 }, 240, 200)).toBe(240);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { Track } from './playerStoreTypes';
|
||||
import {
|
||||
libraryRecordPlaySession,
|
||||
type PlaySessionEndReason,
|
||||
} from '../api/library';
|
||||
import { libraryIsReady } from '../utils/library/libraryReady';
|
||||
import { getPlaybackServerId } from '../utils/playback/playbackServer';
|
||||
import { emitPlaySessionRecorded } from './playSessionRecorded';
|
||||
import { useLibraryIndexStore } from './libraryIndexStore';
|
||||
|
||||
const MIN_LISTENED_SEC = 10;
|
||||
|
||||
type OpenSession = {
|
||||
serverId: string;
|
||||
trackId: string;
|
||||
startedAtMs: number;
|
||||
listenedSec: number;
|
||||
positionMaxSec: number;
|
||||
durationSecHint: number;
|
||||
lastTickMs: number;
|
||||
recordingEnabled: boolean;
|
||||
};
|
||||
|
||||
let open: OpenSession | null = null;
|
||||
let finalizeInFlight: Promise<void> | null = null;
|
||||
|
||||
function clearOpen(): void {
|
||||
open = null;
|
||||
}
|
||||
|
||||
/** Best-known track length in seconds from player metadata and/or engine. */
|
||||
export function resolveDurationSecHint(
|
||||
track: Pick<Track, 'duration'> | null | undefined,
|
||||
...extraSec: (number | undefined)[]
|
||||
): number {
|
||||
const values = [track?.duration, ...extraSec].filter(
|
||||
(d): d is number => typeof d === 'number' && Number.isFinite(d) && d > 0,
|
||||
);
|
||||
if (values.length === 0) return 0;
|
||||
return Math.round(Math.max(...values));
|
||||
}
|
||||
|
||||
function noteDurationHint(durationSec?: number): void {
|
||||
if (!open || !durationSec || durationSec <= 0) return;
|
||||
const rounded = Math.round(durationSec);
|
||||
if (rounded > open.durationSecHint) {
|
||||
open.durationSecHint = rounded;
|
||||
}
|
||||
}
|
||||
|
||||
async function playerGateBlocks(): Promise<boolean> {
|
||||
const { usePlayerStore } = await import('./playerStore');
|
||||
const { usePreviewStore } = await import('./previewStore');
|
||||
if (usePreviewStore.getState().previewingId) return true;
|
||||
if (usePlayerStore.getState().currentRadio) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
async function recordingEnabledForServer(serverId: string): Promise<boolean> {
|
||||
if (!serverId) return false;
|
||||
if (!useLibraryIndexStore.getState().isIndexEnabled(serverId)) return false;
|
||||
if (await playerGateBlocks()) return false;
|
||||
return libraryIsReady(serverId);
|
||||
}
|
||||
|
||||
export async function playListenSessionOpen(
|
||||
track: Track,
|
||||
serverId: string,
|
||||
engineDurationSec?: number,
|
||||
): Promise<void> {
|
||||
if (open && open.trackId === track.id && open.serverId === serverId) {
|
||||
noteDurationHint(resolveDurationSecHint(track, engineDurationSec));
|
||||
return;
|
||||
}
|
||||
await playListenSessionFinalize('skip');
|
||||
const enabled = await recordingEnabledForServer(serverId);
|
||||
if (!enabled) return;
|
||||
open = {
|
||||
serverId,
|
||||
trackId: track.id,
|
||||
startedAtMs: Date.now(),
|
||||
listenedSec: 0,
|
||||
positionMaxSec: 0,
|
||||
durationSecHint: resolveDurationSecHint(track, engineDurationSec),
|
||||
lastTickMs: Date.now(),
|
||||
recordingEnabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function playListenSessionOnProgress(
|
||||
currentTime: number,
|
||||
buffering: boolean,
|
||||
durationSecHint?: number,
|
||||
): Promise<void> {
|
||||
if (!open?.recordingEnabled) return;
|
||||
noteDurationHint(durationSecHint);
|
||||
const { usePlayerStore } = await import('./playerStore');
|
||||
const store = usePlayerStore.getState();
|
||||
const track = store.currentTrack;
|
||||
if (track?.id === open.trackId) {
|
||||
noteDurationHint(resolveDurationSecHint(track, durationSecHint));
|
||||
}
|
||||
if (!store.isPlaying || buffering) {
|
||||
open.lastTickMs = Date.now();
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
const deltaSec = Math.max(0, (now - open.lastTickMs) / 1000);
|
||||
open.lastTickMs = now;
|
||||
open.listenedSec += deltaSec;
|
||||
if (Number.isFinite(currentTime) && currentTime > open.positionMaxSec) {
|
||||
open.positionMaxSec = currentTime;
|
||||
}
|
||||
}
|
||||
|
||||
export async function playListenSessionFinalize(reason: PlaySessionEndReason): Promise<void> {
|
||||
if (finalizeInFlight) {
|
||||
await finalizeInFlight;
|
||||
}
|
||||
if (!open) return;
|
||||
|
||||
const session = open;
|
||||
clearOpen();
|
||||
|
||||
if (!session.recordingEnabled || session.listenedSec <= MIN_LISTENED_SEC) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { usePlayerStore } = await import('./playerStore');
|
||||
const track = usePlayerStore.getState().currentTrack;
|
||||
const durationSecHint = resolveDurationSecHint(
|
||||
track?.id === session.trackId ? track : null,
|
||||
session.durationSecHint,
|
||||
);
|
||||
|
||||
finalizeInFlight = libraryRecordPlaySession({
|
||||
serverId: session.serverId,
|
||||
trackId: session.trackId,
|
||||
startedAtMs: session.startedAtMs,
|
||||
listenedSec: session.listenedSec,
|
||||
positionMaxSec: session.positionMaxSec,
|
||||
endReason: reason,
|
||||
durationSecHint: durationSecHint > 0 ? durationSecHint : undefined,
|
||||
})
|
||||
.then(() => {
|
||||
emitPlaySessionRecorded({
|
||||
serverId: session.serverId,
|
||||
trackId: session.trackId,
|
||||
startedAtMs: session.startedAtMs,
|
||||
});
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
finalizeInFlight = null;
|
||||
});
|
||||
|
||||
await finalizeInFlight;
|
||||
}
|
||||
|
||||
export async function playListenSessionOnTrackSwitched(nextTrack: Track): Promise<void> {
|
||||
const serverId = getPlaybackServerId();
|
||||
await playListenSessionFinalize('switch');
|
||||
await playListenSessionOpen(nextTrack, serverId, nextTrack.duration);
|
||||
}
|
||||
|
||||
/** Test-only reset */
|
||||
export function _resetPlayListenSessionForTest(): void {
|
||||
open = null;
|
||||
finalizeInFlight = null;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { emitPlaySessionRecorded, onPlaySessionRecorded } from './playSessionRecorded';
|
||||
|
||||
describe('playSessionRecorded', () => {
|
||||
it('notifies subscribers when a listen is persisted', () => {
|
||||
const listener = vi.fn();
|
||||
const unsub = onPlaySessionRecorded(listener);
|
||||
const detail = { serverId: 's1', trackId: 't1', startedAtMs: 123 };
|
||||
emitPlaySessionRecorded(detail);
|
||||
expect(listener).toHaveBeenCalledWith(detail);
|
||||
unsub();
|
||||
listener.mockClear();
|
||||
emitPlaySessionRecorded(detail);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
export type PlaySessionRecordedDetail = {
|
||||
serverId: string;
|
||||
trackId: string;
|
||||
startedAtMs: number;
|
||||
};
|
||||
|
||||
const EVENT_NAME = 'psysonic:play-session-recorded';
|
||||
|
||||
export function emitPlaySessionRecorded(detail: PlaySessionRecordedDetail): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.dispatchEvent(new CustomEvent<PlaySessionRecordedDetail>(EVENT_NAME, { detail }));
|
||||
}
|
||||
|
||||
export function onPlaySessionRecorded(
|
||||
listener: (detail: PlaySessionRecordedDetail) => void,
|
||||
): () => void {
|
||||
if (typeof window === 'undefined') return () => {};
|
||||
const wrapped = (evt: Event) => {
|
||||
const ce = evt as CustomEvent<PlaySessionRecordedDetail>;
|
||||
if (!ce?.detail) return;
|
||||
listener(ce.detail);
|
||||
};
|
||||
window.addEventListener(EVENT_NAME, wrapped as EventListener);
|
||||
return () => window.removeEventListener(EVENT_NAME, wrapped as EventListener);
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
import type { PlayerState, Track } from './playerStoreTypes';
|
||||
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
|
||||
import { syncQueueToServer } from './queueSync';
|
||||
import { playListenSessionFinalize } from './playListenSession';
|
||||
import { pushQueueUndoFromGetter } from './queueUndo';
|
||||
import { stopRadio } from './radioPlayer';
|
||||
import { clearAllPlaybackScheduleTimers } from './scheduleTimers';
|
||||
@@ -152,6 +153,8 @@ export function runPlayTrack(
|
||||
return;
|
||||
}
|
||||
|
||||
void playListenSessionFinalize('skip');
|
||||
|
||||
clearAllPlaybackScheduleTimers();
|
||||
set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { clearSeekDebounce } from './seekDebounce';
|
||||
import { clearSeekFallbackRetry } from './seekFallbackState';
|
||||
import { clearSeekTarget } from './seekTargetState';
|
||||
import i18n from '../i18n';
|
||||
import { playListenSessionFinalize } from './playListenSession';
|
||||
import { playbackServerDiffersFromActive, clearQueueServerForPlayback } from '../utils/playback/playbackServer';
|
||||
import { useLuckyMixStore } from './luckyMixStore';
|
||||
import { showToast } from '../utils/ui/toast';
|
||||
@@ -207,6 +208,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
|
||||
},
|
||||
|
||||
clearQueue: () => {
|
||||
void playListenSessionFinalize('stop');
|
||||
invoke('audio_stop').catch(console.error);
|
||||
setIsAudioPaused(false);
|
||||
clearSeekFallbackRetry();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { invoke } from '@tauri-apps/api/core';
|
||||
import { setIsAudioPaused } from './engineState';
|
||||
import type { PlayerState } from './playerStoreTypes';
|
||||
import { flushQueueSyncToServer } from './queueSync';
|
||||
import { playListenSessionFinalize } from './playListenSession';
|
||||
import { pauseRadio, stopRadio } from './radioPlayer';
|
||||
import { clearAllPlaybackScheduleTimers } from './scheduleTimers';
|
||||
import { clearSeekDebounce } from './seekDebounce';
|
||||
@@ -28,6 +29,7 @@ export function createTransportLightActions(set: SetState, get: GetState): Pick<
|
||||
> {
|
||||
return {
|
||||
stop: () => {
|
||||
void playListenSessionFinalize('stop');
|
||||
clearAllPlaybackScheduleTimers();
|
||||
if (get().currentRadio) {
|
||||
stopRadio();
|
||||
|
||||
Reference in New Issue
Block a user