import type { PlaybackSource } from '/@/renderer/store/playback-owner.store'; import { create, type StoreApi, type UseBoundStore } from 'zustand'; import { persist, subscribeWithSelector } from 'zustand/middleware'; import { type AbsPlaybackSyncHandle, createAbsPlaybackSyncHandle, POSITION_PERSIST_DEBOUNCE_S, wireAbsPauseProgressFlush, wireAbsPlaybackOwnerHandoff, } from '/@/renderer/store/abs-playback-sync'; import { clampPosition } from '/@/renderer/store/audiobook-resume-math'; import { identityPersistMigrate, PERSIST_VERSION_INITIAL } from '/@/renderer/store/persist-migrate'; import { usePlaybackOwnerStore } from '/@/renderer/store/playback-owner.store'; import { usePlayerStoreBase } from '/@/renderer/store/player.store'; import { LongFormLibraryItem, LongFormPodcastEpisode } from '/@/shared/api/long-form-types'; import { toast } from '/@/shared/components/toast/toast'; import { ServerListItemWithCredential } from '/@/shared/types/domain-types'; import { PlayerStatus } from '/@/shared/types/types'; import { LogCategory, logFn } from '/@/shared/utils/logger'; export interface AbsPlaybackBaseActions { release: () => void; seekTo: (seconds: number) => void; setError: (error: null | string) => void; setPosition: (seconds: number) => void; } export interface AbsPlaybackCoreState { contentUrl: null | string; duration: number; error: null | string; isLoading: boolean; item: LongFormLibraryItem | null; position: number; server: null | null | ServerListItemWithCredential; sessionId: null | string; } export interface AbsPlaybackStoreBundle< TState extends AbsPlaybackCoreState & object, TActions extends AbsPlaybackBaseActions, > { selectors: { useActions: () => TActions; useContentUrl: () => null | string; useDuration: () => number; useError: () => null | string; useIsLoading: () => boolean; useItem: () => LongFormLibraryItem | null; usePosition: () => number; useServer: () => null | ServerListItemWithCredential; }; store: UseBoundStore>; sync: AbsPlaybackSyncHandle; } export interface AbsPlaySessionResult { contentUrl: string; duration: number; episode?: LongFormPodcastEpisode | null; item: LongFormLibraryItem; patch?: Partial; position: number; sessionId: null | string; } export interface CreateAbsPlaybackStoreConfig< TResume extends object, TExtra extends object, TActions extends AbsPlaybackBaseActions, TResumeField extends keyof TResume & string, > { clearTransientExtra: () => TExtra; extendActions: (api: { base: AbsPlaybackBaseActions; get: AbsStoreGet; play: (...playArgs: unknown[]) => Promise; set: AbsStoreSet; sync: AbsPlaybackSyncHandle; }) => TActions; failureToastLabel: string; getEpisodeForSync: (state: AbsPlaybackCoreState & TExtra) => LongFormPodcastEpisode | null; getLoadingSeed: (...playArgs: unknown[]) => Partial; getResumeKey: (state: AbsPlaybackCoreState & TExtra) => null | string; initialExtra: TExtra; logLabel: string; onBeforePlay?: ( current: AbsPlaybackCoreState & TExtra, set: AbsStoreSet, getResumeMap: () => TResume, ) => void; onLoseOwnershipExtra?: (state: AbsPlaybackCoreState & TExtra) => void; persistName: string; playArgsLabel: string; recordRecent: (item: LongFormLibraryItem, serverId: string) => void; rememberSession: (args: { episode?: LongFormPodcastEpisode | null; item: LongFormLibraryItem; position?: number; server: ServerListItemWithCredential; }) => void; requiresEpisode: boolean; resolvePlaySession: (...playArgs: unknown[]) => Promise; resumeField: TResumeField; resumeInitial: TResume; source: PlaybackSource; updateResumeOnSeek: ( state: AbsPlaybackCoreState & TExtra, position: number, ) => null | { key: string; position: number }; } type AbsStoreGet = () => TState; type AbsStoreSet = ( partial: ((state: TState) => Partial) | Partial, ) => void; const isPlayingForSync = () => usePlayerStoreBase.getState().player.status === PlayerStatus.PLAYING; export function createAbsPlaybackStore< TResume extends object, TExtra extends object, TActions extends AbsPlaybackBaseActions, TResumeField extends keyof TResume & string, >( config: CreateAbsPlaybackStoreConfig, ): AbsPlaybackStoreBundle { // forward-declared; assigned once below, after the store that its actions // close over has been created. // eslint-disable-next-line prefer-const let sync!: AbsPlaybackSyncHandle; type FullState = AbsPlaybackCoreState & TExtra & TResume & { actions: TActions }; const useStore = create()( subscribeWithSelector( persist( (set, get) => { const getResumeMap = () => get()[config.resumeField] as TResume; const flushCurrentResume = (current: AbsPlaybackCoreState & TExtra) => { const key = config.getResumeKey(current); if (!key) { return; } set( (state) => ({ [config.resumeField]: { ...(state[config.resumeField] as TResume), [key]: current.position, }, }) as Partial, ); }; const baseActions: AbsPlaybackBaseActions = { release: () => { sync.bumpPlayRequest(); const state = get(); const key = config.getResumeKey(state); if (key) { set( (s) => ({ [config.resumeField]: { ...(s[config.resumeField] as TResume), [key]: state.position, }, }) as Partial, ); if (state.server && state.item) { config.rememberSession({ episode: config.getEpisodeForSync(state), item: state.item, position: state.position, server: state.server, }); } } sync.syncProgress({ closeSession: true, countListeningTime: isPlayingForSync(), force: true, reason: 'close', }); set({ ...config.clearTransientExtra(), contentUrl: null, duration: 0, error: null, isLoading: false, item: null, position: 0, server: null, sessionId: null, } as Partial); sync.resetAfterClose(); usePlaybackOwnerStore.getState().release(config.source); }, seekTo: (seconds) => { const nextPosition = clampPosition(seconds, get().duration); set({ position: nextPosition } as Partial); sync.setLastFlushedPosition(nextPosition); const state = get(); const resumeUpdate = config.updateResumeOnSeek(state, nextPosition); if (resumeUpdate && state.server && state.item) { set( (s) => ({ [config.resumeField]: { ...(s[config.resumeField] as TResume), [resumeUpdate.key]: resumeUpdate.position, }, }) as Partial, ); config.rememberSession({ episode: config.getEpisodeForSync(state), item: state.item, position: nextPosition, server: state.server, }); } sync.syncProgress({ countListeningTime: isPlayingForSync(), force: true, reason: 'seek', }); }, setError: (error) => { set({ error } as Partial); }, setPosition: (seconds) => { const nextPosition = clampPosition(seconds, get().duration); set({ position: nextPosition } as Partial); const drift = Math.abs(nextPosition - sync.getLastFlushedPosition()); if (drift >= POSITION_PERSIST_DEBOUNCE_S) { const state = get(); const resumeUpdate = config.updateResumeOnSeek(state, nextPosition); if (resumeUpdate) { set( (s) => ({ [config.resumeField]: { ...(s[config.resumeField] as TResume), [resumeUpdate.key]: nextPosition, }, }) as Partial, ); sync.setLastFlushedPosition(nextPosition); const { server } = get(); if (server && state.item) { config.rememberSession({ episode: config.getEpisodeForSync(state), item: state.item, position: nextPosition, server, }); } } } sync.syncProgress({ countListeningTime: true, reason: 'progress', }); }, }; const play = async (...playArgs: unknown[]) => { const requestId = sync.bumpPlayRequest(); const current = get(); config.onBeforePlay?.(current, set as AbsStoreSet, getResumeMap); if (current.item && current.server) { flushCurrentResume(current); config.rememberSession({ episode: config.getEpisodeForSync(current), item: current.item, position: current.position, server: current.server, }); sync.syncProgress({ closeSession: true, countListeningTime: isPlayingForSync(), force: true, reason: 'close', }); } const server = playArgs[0] as ServerListItemWithCredential; const item = playArgs[1] as LongFormLibraryItem; const episode = config.requiresEpisode ? (playArgs[2] as LongFormPodcastEpisode) : null; const playbackMediaKey = episode ? `${item.id}:${episode.id}` : item.id; usePlaybackOwnerStore.getState().claim(config.source, { engine: 'web', mediaKey: playbackMediaKey, }); config.rememberSession({ episode, item, position: 0, server, }); config.recordRecent(item, server.id); set({ ...config.getLoadingSeed(...playArgs), contentUrl: null, error: null, isLoading: true, item, position: 0, server, sessionId: null, } as Partial); try { const result = await config.resolvePlaySession(...playArgs); if (!sync.isCurrentPlayRequest(requestId)) { return; } sync.setLastFlushedPosition(result.position); sync.resetProgressSync(result.position); sync.resetSyncWarnings(); set({ contentUrl: result.contentUrl, duration: result.duration, isLoading: false, item: result.item, position: result.position, sessionId: result.sessionId, ...(result.episode !== undefined ? { episode: result.episode } : {}), ...result.patch, } as Partial); config.rememberSession({ episode: result.episode ?? episode, item: result.item, position: result.position, server, }); usePlayerStoreBase.getState().mediaPlay(); } catch (err) { if (!sync.isCurrentPlayRequest(requestId)) { return; } const message = err instanceof Error ? err.message : String(err); logFn.error(`[${config.logLabel}] play() failed`, { category: LogCategory.PLAYER, meta: { err }, }); toast.error({ message: `${config.failureToastLabel}: ${message}`, }); set({ error: message, isLoading: false, } as Partial); usePlaybackOwnerStore.getState().release(config.source); } }; const actions = config.extendActions({ base: baseActions, get: get as AbsStoreGet, play, set: set as AbsStoreSet, sync, }); return { actions, contentUrl: null, duration: 0, error: null, isLoading: false, item: null, position: 0, server: null, sessionId: null, ...config.initialExtra, ...config.resumeInitial, } as FullState; }, { migrate: identityPersistMigrate, name: config.persistName, partialize: (state) => ({ [config.resumeField]: state[config.resumeField] }) as TResume, version: PERSIST_VERSION_INITIAL, }, ), ), ); sync = createAbsPlaybackSyncHandle(config.logLabel, () => { const state = useStore.getState(); return { duration: state.duration, episode: config.getEpisodeForSync(state), // A resolved contentUrl is what separates "this actually played" from // a restored/seeded session; release() and the owner handoff both // sync BEFORE clearing it, so the flush that matters still sees it. hasStream: state.contentUrl !== null, item: state.item, position: state.position, requiresEpisode: config.requiresEpisode, server: state.server, sessionId: state.sessionId, }; }); wireAbsPlaybackOwnerHandoff({ clearTransientState: () => { useStore.setState({ ...config.clearTransientExtra(), contentUrl: null, duration: 0, error: null, isLoading: false, item: null, position: 0, server: null, sessionId: null, } as Partial); }, onLoseOwnership: () => { const state = useStore.getState(); const key = config.getResumeKey(state); if (key) { useStore.setState( (s) => ({ [config.resumeField]: { ...(s[config.resumeField] as TResume), [key]: state.position, }, }) as Partial, ); } config.onLoseOwnershipExtra?.(state); sync.syncProgress({ closeSession: true, countListeningTime: isPlayingForSync(), force: true, reason: 'close', }); }, source: config.source, sync, }); wireAbsPauseProgressFlush({ source: config.source, sync }); return { selectors: { useActions: () => useStore((state) => state.actions), useContentUrl: () => useStore((state) => state.contentUrl), useDuration: () => useStore((state) => state.duration), useError: () => useStore((state) => state.error), useIsLoading: () => useStore((state) => state.isLoading), useItem: () => useStore((state) => state.item), usePosition: () => useStore((state) => state.position), useServer: () => useStore((state) => state.server), }, store: useStore, sync, }; }