Ctrl K
ComunidadGuías…

src/renderer/store/audiobook.store.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { type SamoAudiobookFileSegment } from '/@/renderer/api/samo/samo-audiobook-stream';
import { resolveSamoAudiobookPlaySession } from '/@/renderer/api/samo/samo-long-form';
import {
    type AbsPlaybackBaseActions,
    type AbsPlaybackCoreState,
    createAbsPlaybackStore,
} from '/@/renderer/store/abs-playback.store';
import { getCurrentChapterIndex } from '/@/renderer/store/audiobook-chapters';
import { useLastPlaybackSessionStore } from '/@/renderer/store/last-playback-session.store';
import { recordRecentAudiobook } from '/@/renderer/store/play-history.store';
import { usePlayerStoreBase } from '/@/renderer/store/player.store';
import { LongFormChapter, LongFormLibraryItem } from '/@/shared/api/long-form-types';
import { ServerListItemWithCredential } from '/@/shared/types/domain-types';

export type { AudiobookChapterListItem } from '/@/renderer/store/audiobook-chapters';
export {
    getCurrentChapterIndex,
    getOrderedAudiobookChapters,
} from '/@/renderer/store/audiobook-chapters';

export interface AudiobookActions extends AbsPlaybackBaseActions {
    /**
     * `startSeconds` starts the book at an explicit point instead of the saved
     * progress — how "play this chapter" works from the detail page.
     */
    play: (
        server: ServerListItemWithCredential,
        item: LongFormLibraryItem,
        startSeconds?: number,
    ) => Promise<void>;
    seekToNextChapter: () => void;
    seekToPreviousChapter: () => void;
    setDuration: (seconds: number) => void;
}

export type AudiobookState = AbsPlaybackCoreState &
    AudiobookExtra &
    AudiobookResume & {
        actions: AudiobookActions;
    };

type AudiobookExtra = {
    /** Per-file manifest for whole-file streaming + cross-file seek switching. */
    audiobookFiles: SamoAudiobookFileSegment[];
    chapters: LongFormChapter[];
    /** Book-global start offset of the file currently loaded in the player. */
    streamOffsetSeconds: number;
};

type AudiobookResume = { resumeByItemId: Record<string, number> };

const rememberAudiobookPlaybackSession = (
    server: ServerListItemWithCredential,
    item: LongFormLibraryItem,
    position?: number,
) => {
    useLastPlaybackSessionStore.getState().actions.setSession({
        itemId: item.id,
        position,
        serverId: server.id,
        source: 'audiobook',
    });
};

const { selectors, store: useAudiobookStore } = createAbsPlaybackStore<
    AudiobookResume,
    AudiobookExtra,
    AudiobookActions,
    'resumeByItemId'
>({
    clearTransientExtra: () => ({ audiobookFiles: [], chapters: [], streamOffsetSeconds: 0 }),
    extendActions: ({ base, get, play, set }) => ({
        ...base,
        play: play as AudiobookActions['play'],
        seekToNextChapter: () => {
            const { chapters, duration, position } = get();
            const currentIndex = getCurrentChapterIndex(chapters, position, duration);
            if (currentIndex === -1) return;
            const nextIndex = currentIndex + 1;
            if (nextIndex >= chapters.length) return;
            const target = Math.max(0, chapters[nextIndex].start);
            usePlayerStoreBase.getState().mediaSeekToTimestamp(target);
        },
        seekToPreviousChapter: () => {
            const { chapters, duration, position } = get();
            const currentIndex = getCurrentChapterIndex(chapters, position, duration);
            if (currentIndex === -1) return;
            const currentStart = chapters[currentIndex].start;
            let target: number;
            if (position - currentStart > 5) {
                target = currentStart;
            } else if (currentIndex > 0) {
                target = chapters[currentIndex - 1].start;
            } else {
                target = 0;
            }
            usePlayerStoreBase.getState().mediaSeekToTimestamp(Math.max(0, target));
        },
        setDuration: (seconds) => {
            set({ duration: seconds });
        },
    }),
    failureToastLabel: 'Audiobook playback failed',
    getEpisodeForSync: () => null,
    getLoadingSeed: (_server, item) => ({
        audiobookFiles: [],
        chapters: (item as LongFormLibraryItem).media?.chapters ?? [],
        duration: (item as LongFormLibraryItem).media?.duration ?? 0,
        streamOffsetSeconds: 0,
    }),
    getResumeKey: (state) => (state.item ? state.item.id : null),
    initialExtra: { audiobookFiles: [], chapters: [], streamOffsetSeconds: 0 },
    logLabel: 'audiobook.store',
    persistName: 'audiobook-store',
    playArgsLabel: 'audiobook',
    recordRecent: recordRecentAudiobook,
    rememberSession: ({ item, position, server }) =>
        rememberAudiobookPlaybackSession(server, item, position),
    requiresEpisode: false,
    resolvePlaySession: async (_server, item, startSeconds) => {
        const server = _server as ServerListItemWithCredential;
        const libraryItem = item as LongFormLibraryItem;

        return resolveSamoAudiobookPlaySession(
            server,
            libraryItem,
            startSeconds as number | undefined,
        ) as any;
    },
    resumeField: 'resumeByItemId',
    resumeInitial: { resumeByItemId: {} },
    source: 'audiobook',
    updateResumeOnSeek: (state, position) => (state.item ? { key: state.item.id, position } : null),
});

export { useAudiobookStore };

export const useAudiobookContentUrl = selectors.useContentUrl;
export const useAudiobookItem = selectors.useItem;
export const useAudiobookIsLoading = selectors.useIsLoading;
export const useAudiobookPosition = selectors.usePosition;
export const useAudiobookDuration = selectors.useDuration;
export const useAudiobookChapters = () => useAudiobookStore((state) => state.chapters);
export const useAudiobookFiles = () => useAudiobookStore((state) => state.audiobookFiles);
export const useAudiobookStreamOffset = () =>
    useAudiobookStore((state) => state.streamOffsetSeconds);
export const useAudiobookError = selectors.useError;
export const useAudiobookServer = selectors.useServer;
export const useAudiobookActions = selectors.useActions;

/**
 * Every book this machine has a saved playhead for, keyed by item id.
 *
 * The server's audiobooks LISTING carries a duration but no per-user position
 * (only the per-book detail route overlays that), so "what am I part-way
 * through" has to come from here. It is this machine's own record, which is
 * exactly what it claims to be.
 */
export const useAudiobookResumePositions = () => useAudiobookStore((state) => state.resumeByItemId);