/** * Audio Player - Audio player interface * * Handles audio playback, pause, stop, and other operations * Loads pre-generated TTS audio files from IndexedDB * */ import { db } from '@/lib/utils/database'; import { createLogger } from '@/lib/logger'; const log = createLogger('AudioPlayer'); /** * Audio player implementation */ export class AudioPlayer { private audio: HTMLAudioElement | null = null; private onEndedCallback: (() => void) | null = null; private muted: boolean = false; private volume: number = 1; private playbackRate: number = 1; /** * Play audio (from URL or IndexedDB pre-generated cache) * @param audioId Audio ID * @param audioUrl Optional server-generated audio URL (takes priority over IndexedDB) * @returns true if audio started playing, false if no audio (TTS disabled or not generated) */ public async play(audioId: string, audioUrl?: string): Promise { try { // 1. Try audioUrl first (server-generated TTS) if (audioUrl) { this.stop(); this.audio = new Audio(); this.audio.src = audioUrl; if (this.muted) this.audio.volume = 0; else this.audio.volume = this.volume; this.audio.defaultPlaybackRate = this.playbackRate; this.audio.playbackRate = this.playbackRate; this.audio.addEventListener('ended', () => { this.onEndedCallback?.(); }); await this.audio.play(); this.audio.playbackRate = this.playbackRate; return true; } // 2. Fall back to IndexedDB (client-generated TTS) const audioRecord = await db.audioFiles.get(audioId); if (!audioRecord) { // Pre-generated audio does not exist (generation failed), skip silently return false; } // Stop current playback this.stop(); // Create audio element this.audio = new Audio(); // Set audio source const blobUrl = URL.createObjectURL(audioRecord.blob); this.audio.src = blobUrl; if (this.muted) this.audio.volume = 0; else this.audio.volume = this.volume; // Apply playback rate this.audio.defaultPlaybackRate = this.playbackRate; this.audio.playbackRate = this.playbackRate; // Set ended callback this.audio.addEventListener('ended', () => { URL.revokeObjectURL(blobUrl); this.onEndedCallback?.(); }); // Play await this.audio.play(); // Re-apply after play() — some browsers reset during load this.audio.playbackRate = this.playbackRate; return true; } catch (error) { log.error('Failed to play audio:', error); throw error; } } /** * Pause playback */ public pause(): void { if (this.audio && !this.audio.paused) { this.audio.pause(); } } /** * Stop playback */ public stop(): void { if (this.audio) { this.audio.pause(); this.audio.currentTime = 0; this.audio = null; } // Note: onEndedCallback intentionally NOT cleared here because play() // calls stop() internally — clearing would break the callback chain. // Stale callbacks are harmless: engine mode check prevents processNext(). } /** * Resume playback */ public resume(): void { if (this.audio?.paused) { this.audio.playbackRate = this.playbackRate; this.audio.play().catch((error) => { log.error('Failed to resume audio:', error); }); } } /** * Get current playback status (actively playing, not paused) */ public isPlaying(): boolean { return this.audio !== null && !this.audio.paused; } /** * Whether there is active audio (playing or paused, but not ended) * Used to decide whether to resume playback or skip to the next line */ public hasActiveAudio(): boolean { return this.audio !== null; } /** * Get current playback time (milliseconds) */ public getCurrentTime(): number { return this.audio ? this.audio.currentTime * 1000 : 0; } /** * Get audio duration (milliseconds) */ public getDuration(): number { return this.audio && !isNaN(this.audio.duration) ? this.audio.duration * 1000 : 0; } /** * Set playback ended callback */ public onEnded(callback: () => void): void { this.onEndedCallback = callback; } /** * Set mute state (takes effect immediately on currently playing audio) */ public setMuted(muted: boolean): void { this.muted = muted; if (this.audio) { this.audio.volume = muted ? 0 : this.volume; } } /** * Set volume (0-1) */ public setVolume(volume: number): void { this.volume = Math.max(0, Math.min(1, volume)); if (this.audio && !this.muted) { this.audio.volume = this.volume; } } /** * Set playback speed (takes effect immediately on currently playing audio) */ public setPlaybackRate(rate: number): void { this.playbackRate = Math.max(0.5, Math.min(2, rate)); if (this.audio) { this.audio.playbackRate = this.playbackRate; } } /** * Destroy the player */ public destroy(): void { this.stop(); this.onEndedCallback = null; } } /** * Create an audio player instance */ export function createAudioPlayer(): AudioPlayer { return new AudioPlayer(); }