src/renderer/store/abs-playback.store.ts
Rama main · réplica de lectura del repositorio original.
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<StoreApi<TState & { actions: TActions }>>;
sync: AbsPlaybackSyncHandle;
}
export interface AbsPlaySessionResult {
contentUrl: string;
duration: number;
episode?: LongFormPodcastEpisode | null;
item: LongFormLibraryItem;
patch?: Partial<AbsPlaybackCoreState>;
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<AbsPlaybackCoreState & TExtra & TResume & { actions: TActions }>;
play: (...playArgs: unknown[]) => Promise<void>;
set: AbsStoreSet<AbsPlaybackCoreState & TExtra & TResume & { actions: TActions }>;
sync: AbsPlaybackSyncHandle;
}) => TActions;
failureToastLabel: string;
getEpisodeForSync: (state: AbsPlaybackCoreState & TExtra) => LongFormPodcastEpisode | null;
getLoadingSeed: (...playArgs: unknown[]) => Partial<AbsPlaybackCoreState & TExtra>;
getResumeKey: (state: AbsPlaybackCoreState & TExtra) => null | string;
initialExtra: TExtra;
logLabel: string;
onBeforePlay?: (
current: AbsPlaybackCoreState & TExtra,
set: AbsStoreSet<AbsPlaybackCoreState & TExtra & TResume & { actions: TActions }>,
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<AbsPlaySessionResult>;
resumeField: TResumeField;
resumeInitial: TResume;
source: PlaybackSource;
updateResumeOnSeek: (
state: AbsPlaybackCoreState & TExtra,
position: number,
) => null | { key: string; position: number };
}
type AbsStoreGet<TState> = () => TState;
type AbsStoreSet<TState> = (
partial: ((state: TState) => Partial<TState>) | Partial<TState>,
) => 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<TResume, TExtra, TActions, TResumeField>,
): AbsPlaybackStoreBundle<AbsPlaybackCoreState & TExtra & TResume, TActions> {
// 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<FullState>()(
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<FullState>,
);
};
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<FullState>,
);
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<FullState>);
sync.resetAfterClose();
usePlaybackOwnerStore.getState().release(config.source);
},
seekTo: (seconds) => {
const nextPosition = clampPosition(seconds, get().duration);
set({ position: nextPosition } as Partial<FullState>);
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<FullState>,
);
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<FullState>);
},
setPosition: (seconds) => {
const nextPosition = clampPosition(seconds, get().duration);
set({ position: nextPosition } as Partial<FullState>);
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<FullState>,
);
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<FullState>, 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<FullState>);
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<FullState>);
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<FullState>);
usePlaybackOwnerStore.getState().release(config.source);
}
};
const actions = config.extendActions({
base: baseActions,
get: get as AbsStoreGet<FullState>,
play,
set: set as AbsStoreSet<FullState>,
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<TResume>,
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<FullState>);
},
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<FullState>,
);
}
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,
};
}