From 2bb980c71c6c9f56dde1d38783e88aa7c0a83a73 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Sun, 10 May 2026 00:07:56 +0100 Subject: [PATCH] update: play audio + srt sync --- src/components/ui/Subtitles.tsx | 14 +++--- src/managers/AudioManager.ts | 8 +++- src/managers/stores/useSubtitleStore.ts | 24 ++++++++++ src/utils/dialogues/playDialogue.ts | 60 +++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 src/managers/stores/useSubtitleStore.ts create mode 100644 src/utils/dialogues/playDialogue.ts diff --git a/src/components/ui/Subtitles.tsx b/src/components/ui/Subtitles.tsx index db67d37..8e338e3 100644 --- a/src/components/ui/Subtitles.tsx +++ b/src/components/ui/Subtitles.tsx @@ -1,6 +1,8 @@ import { useSettingsStore } from "@/managers/stores/useSettingsStore"; +import { useSubtitleStore } from "@/managers/stores/useSubtitleStore"; +import type { DialogueSpeaker } from "@/types/dialogues/dialogues"; -export type SubtitleSpeaker = "Narrateur" | "Fermier" | "Leonie"; +export type SubtitleSpeaker = DialogueSpeaker; interface SubtitlesProps { speaker?: SubtitleSpeaker | null; @@ -12,18 +14,20 @@ export function Subtitles({ text = null, }: SubtitlesProps): React.JSX.Element | null { const subtitlesEnabled = useSettingsStore((state) => state.subtitlesEnabled); - const content = text?.trim(); + const activeSubtitle = useSubtitleStore((state) => state.activeSubtitle); + const subtitleSpeaker = speaker ?? activeSubtitle?.speaker ?? null; + const content = (text ?? activeSubtitle?.text)?.trim(); if (!subtitlesEnabled || !content) return null; return (

- {speaker ? ( + {subtitleSpeaker ? ( - {speaker}: + {subtitleSpeaker}: ) : null} {content} diff --git a/src/managers/AudioManager.ts b/src/managers/AudioManager.ts index 4e5d4bb..5ac796b 100644 --- a/src/managers/AudioManager.ts +++ b/src/managers/AudioManager.ts @@ -54,7 +54,11 @@ export class AudioManager { return this._categoryVolumes[category]; } - playSound(path: string, volume = 1, options: PlaySoundOptions = {}): void { + playSound( + path: string, + volume = 1, + options: PlaySoundOptions = {}, + ): HTMLAudioElement { const audio = this._acquireAudio(path); const category = options.category ?? AudioManager.DEFAULT_SOUND_CATEGORY; audio.volume = this._getEffectiveVolume(category, volume); @@ -75,6 +79,8 @@ export class AudioManager { error: AudioManager._toLogValue(error), }); }); + + return audio; } playMusic(path: string, volume = 1): void { diff --git a/src/managers/stores/useSubtitleStore.ts b/src/managers/stores/useSubtitleStore.ts new file mode 100644 index 0000000..e7766ec --- /dev/null +++ b/src/managers/stores/useSubtitleStore.ts @@ -0,0 +1,24 @@ +import { create } from "zustand"; +import type { DialogueSpeaker } from "@/types/dialogues/dialogues"; + +interface ActiveSubtitle { + speaker: DialogueSpeaker; + text: string; +} + +interface SubtitleState { + activeSubtitle: ActiveSubtitle | null; +} + +interface SubtitleActions { + setActiveSubtitle: (subtitle: ActiveSubtitle | null) => void; + clearActiveSubtitle: () => void; +} + +type SubtitleStore = SubtitleState & SubtitleActions; + +export const useSubtitleStore = create()((set) => ({ + activeSubtitle: null, + setActiveSubtitle: (activeSubtitle) => set({ activeSubtitle }), + clearActiveSubtitle: () => set({ activeSubtitle: null }), +})); diff --git a/src/utils/dialogues/playDialogue.ts b/src/utils/dialogues/playDialogue.ts new file mode 100644 index 0000000..5c4d7da --- /dev/null +++ b/src/utils/dialogues/playDialogue.ts @@ -0,0 +1,60 @@ +import { AudioManager } from "@/managers/AudioManager"; +import { useSettingsStore } from "@/managers/stores/useSettingsStore"; +import { useSubtitleStore } from "@/managers/stores/useSubtitleStore"; +import type { DialogueManifest } from "@/types/dialogues/dialogues"; +import { loadDialogueSubtitleCue } from "@/utils/dialogues/loadDialogueManifest"; + +export async function playDialogueById( + manifest: DialogueManifest, + dialogueId: string, +): Promise { + const dialogue = manifest.dialogues.find((item) => item.id === dialogueId); + if (!dialogue) return null; + + const subtitleLanguage = useSettingsStore.getState().subtitleLanguage; + const subtitle = await loadDialogueSubtitleCue( + manifest, + dialogue, + subtitleLanguage, + ); + const audio = AudioManager.getInstance().playSound(dialogue.audio, 1, { + category: "dialogue", + }); + + if (!subtitle) return audio; + + const clearSubtitle = (): void => { + useSubtitleStore.getState().clearActiveSubtitle(); + }; + + const cleanup = (): void => { + audio.removeEventListener("timeupdate", syncSubtitle); + audio.removeEventListener("ended", cleanup); + audio.removeEventListener("pause", cleanup); + clearSubtitle(); + }; + + const syncSubtitle = (): void => { + const currentTime = audio.currentTime; + const shouldShowSubtitle = + currentTime >= subtitle.cue.startTime && + currentTime <= subtitle.cue.endTime; + + if (shouldShowSubtitle) { + useSubtitleStore.getState().setActiveSubtitle({ + speaker: subtitle.voice.speaker, + text: subtitle.cue.text, + }); + return; + } + + clearSubtitle(); + }; + + audio.addEventListener("timeupdate", syncSubtitle); + audio.addEventListener("ended", cleanup); + audio.addEventListener("pause", cleanup); + syncSubtitle(); + + return audio; +}