|
|
|
|
@@ -1,261 +1,39 @@
|
|
|
|
|
import WaveSurfer from "wavesurfer.js";
|
|
|
|
|
import { usePlayerStore } from "../stores/playerStore";
|
|
|
|
|
|
|
|
|
|
// Log level enum (will be exported at end of file)
|
|
|
|
|
enum LogLevel {
|
|
|
|
|
DEBUG = 0,
|
|
|
|
|
INFO = 1,
|
|
|
|
|
WARN = 2,
|
|
|
|
|
ERROR = 3
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Initialization state enum
|
|
|
|
|
enum InitializationState {
|
|
|
|
|
NotStarted = 'not_started',
|
|
|
|
|
InProgress = 'in_progress',
|
|
|
|
|
Completed = 'completed',
|
|
|
|
|
Failed = 'failed'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Type extension for WaveSurfer backend access
|
|
|
|
|
interface WaveSurferWithBackend extends WaveSurfer {
|
|
|
|
|
backend?: {
|
|
|
|
|
getAudioContext?: () => AudioContext;
|
|
|
|
|
ac?: AudioContext;
|
|
|
|
|
audioContext?: AudioContext;
|
|
|
|
|
};
|
|
|
|
|
getAudioContext?: () => AudioContext;
|
|
|
|
|
getContainer?: () => HTMLElement;
|
|
|
|
|
setContainer?: (container: HTMLElement) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class AudioService {
|
|
|
|
|
private static instance: AudioService;
|
|
|
|
|
private wavesurfer: WaveSurfer | null = null;
|
|
|
|
|
private audioContext: AudioContext | null = null;
|
|
|
|
|
private currentUrl: string | null = null;
|
|
|
|
|
private lastPlayTime: number = 0;
|
|
|
|
|
private lastTimeUpdate: number = 0;
|
|
|
|
|
private readonly PLAY_DEBOUNCE_MS: number = 100;
|
|
|
|
|
private lastSeekTime: number = 0;
|
|
|
|
|
private readonly SEEK_DEBOUNCE_MS: number = 200;
|
|
|
|
|
private logLevel: LogLevel = LogLevel.ERROR;
|
|
|
|
|
private playbackAttempts: number = 0;
|
|
|
|
|
private readonly MAX_PLAYBACK_ATTEMPTS: number = 3;
|
|
|
|
|
private lastLogTime: number = 0;
|
|
|
|
|
private readonly LOG_THROTTLE_MS: number = 100;
|
|
|
|
|
private isReady = false;
|
|
|
|
|
private lastTimeUpdate = 0;
|
|
|
|
|
|
|
|
|
|
// Initialization tracking
|
|
|
|
|
private initializationState: InitializationState = InitializationState.NotStarted;
|
|
|
|
|
private initializationError: Error | null = null;
|
|
|
|
|
private initializationPromise: Promise<void> | null = null;
|
|
|
|
|
private initializationResolve: (() => void) | null = null;
|
|
|
|
|
private constructor() {}
|
|
|
|
|
|
|
|
|
|
private constructor() {
|
|
|
|
|
// Set appropriate log level based on environment
|
|
|
|
|
this.setLogLevel(this.detectLogLevel());
|
|
|
|
|
|
|
|
|
|
this.log(LogLevel.INFO, `AudioService initialized (log level: ${LogLevel[this.logLevel]})`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private detectLogLevel(): LogLevel {
|
|
|
|
|
try {
|
|
|
|
|
// Development environment: localhost or explicit debug mode
|
|
|
|
|
const isDevelopment = typeof window !== 'undefined' &&
|
|
|
|
|
window.location &&
|
|
|
|
|
(window.location.hostname === 'localhost' ||
|
|
|
|
|
window.location.hostname === '127.0.0.1');
|
|
|
|
|
|
|
|
|
|
// Check for debug query parameter (with safety checks)
|
|
|
|
|
const hasDebugParam = typeof window !== 'undefined' &&
|
|
|
|
|
window.location &&
|
|
|
|
|
window.location.search &&
|
|
|
|
|
window.location.search.includes('audioDebug=true');
|
|
|
|
|
|
|
|
|
|
if (isDevelopment || hasDebugParam) {
|
|
|
|
|
return LogLevel.DEBUG;
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
// If anything goes wrong, default to WARN level
|
|
|
|
|
console.warn('Error detecting log level, defaulting to WARN:', error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Production: warn level to reduce noise
|
|
|
|
|
return LogLevel.WARN;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private log(level: LogLevel, message: string, ...args: unknown[]) {
|
|
|
|
|
// Skip if below current log level
|
|
|
|
|
if (level < this.logLevel) return;
|
|
|
|
|
|
|
|
|
|
// Throttle rapid-fire logs to prevent console flooding
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
if (now - this.lastLogTime < this.LOG_THROTTLE_MS) {
|
|
|
|
|
return; // Skip this log to prevent spam
|
|
|
|
|
}
|
|
|
|
|
this.lastLogTime = now;
|
|
|
|
|
|
|
|
|
|
const prefix = `[AudioService:${LogLevel[level]}]`;
|
|
|
|
|
|
|
|
|
|
// Use appropriate console method based on log level
|
|
|
|
|
switch(level) {
|
|
|
|
|
case LogLevel.DEBUG:
|
|
|
|
|
if (console.debug) {
|
|
|
|
|
console.debug(prefix, message, ...args);
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
case LogLevel.INFO:
|
|
|
|
|
console.info(prefix, message, ...args);
|
|
|
|
|
break;
|
|
|
|
|
case LogLevel.WARN:
|
|
|
|
|
console.warn(prefix, message, ...args);
|
|
|
|
|
break;
|
|
|
|
|
case LogLevel.ERROR:
|
|
|
|
|
console.error(prefix, message, ...args);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add method to set log level from outside
|
|
|
|
|
public setLogLevel(level: LogLevel) {
|
|
|
|
|
this.log(LogLevel.INFO, `Log level set to: ${LogLevel[level]}`);
|
|
|
|
|
this.logLevel = level;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static getInstance() {
|
|
|
|
|
public static getInstance(): AudioService {
|
|
|
|
|
if (!this.instance) {
|
|
|
|
|
this.instance = new AudioService();
|
|
|
|
|
}
|
|
|
|
|
return this.instance;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Initialize audio context - now handles user gesture requirement
|
|
|
|
|
public async initializeAudioContext() {
|
|
|
|
|
try {
|
|
|
|
|
if (!this.audioContext) {
|
|
|
|
|
this.audioContext = new (window.AudioContext || (window as { webkitAudioContext?: new () => AudioContext }).webkitAudioContext)();
|
|
|
|
|
this.log(LogLevel.INFO, 'Audio context created', {
|
|
|
|
|
state: this.audioContext.state,
|
|
|
|
|
sampleRate: this.audioContext.sampleRate
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Set up state change monitoring
|
|
|
|
|
this.audioContext.onstatechange = () => {
|
|
|
|
|
this.log(LogLevel.DEBUG, 'Audio context state changed:', this.audioContext?.state);
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Resume suspended audio context
|
|
|
|
|
if (this.audioContext && this.audioContext.state === 'suspended') {
|
|
|
|
|
await this.audioContext.resume();
|
|
|
|
|
this.log(LogLevel.INFO, 'Audio context resumed successfully');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return this.audioContext;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.log(LogLevel.ERROR, 'Failed to initialize audio context:', error);
|
|
|
|
|
throw new Error(`Failed to initialize audio context: ${error instanceof Error ? error.message : String(error)}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// New method to handle audio context resume with user gesture
|
|
|
|
|
private async handleAudioContextResume(): Promise<void> {
|
|
|
|
|
if (!this.audioContext) {
|
|
|
|
|
await this.initializeAudioContext();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Handle suspended audio context (common in mobile browsers and autoplay policies)
|
|
|
|
|
if (this.audioContext && this.audioContext.state === 'suspended') {
|
|
|
|
|
try {
|
|
|
|
|
this.log(LogLevel.INFO, 'Attempting to resume suspended audio context...');
|
|
|
|
|
await this.audioContext.resume();
|
|
|
|
|
this.log(LogLevel.INFO, 'Audio context resumed successfully');
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.log(LogLevel.ERROR, 'Failed to resume audio context:', error);
|
|
|
|
|
// If resume fails, we might need to create a new context
|
|
|
|
|
// This can happen if the context was closed or terminated
|
|
|
|
|
if (this.audioContext && (this.audioContext.state as string) === 'closed') {
|
|
|
|
|
this.log(LogLevel.WARN, 'Audio context closed, creating new one');
|
|
|
|
|
this.audioContext = null;
|
|
|
|
|
await this.initializeAudioContext();
|
|
|
|
|
}
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Method for testing: reset the singleton instance
|
|
|
|
|
// For use in tests only
|
|
|
|
|
public static resetInstance(): void {
|
|
|
|
|
this.instance = undefined as any;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async initialize(container: HTMLElement, url: string): Promise<void> {
|
|
|
|
|
this.log(LogLevel.DEBUG, 'AudioService.initialize called', { url, containerExists: !!container });
|
|
|
|
|
if (!container) throw new Error('Container element is required');
|
|
|
|
|
if (!url) throw new Error('Valid audio URL is required');
|
|
|
|
|
|
|
|
|
|
// Reset initialization state
|
|
|
|
|
this.initializationState = InitializationState.InProgress;
|
|
|
|
|
this.initializationError = null;
|
|
|
|
|
this.initializationPromise = new Promise<void>((resolve) => {
|
|
|
|
|
this.initializationResolve = resolve;
|
|
|
|
|
});
|
|
|
|
|
// Reuse the existing instance when the URL hasn't changed
|
|
|
|
|
if (this.currentUrl === url && this.wavesurfer) return;
|
|
|
|
|
|
|
|
|
|
// Validate inputs
|
|
|
|
|
if (!container) {
|
|
|
|
|
this.log(LogLevel.ERROR, 'AudioService: container element is null');
|
|
|
|
|
this.initializationState = InitializationState.Failed;
|
|
|
|
|
this.initializationError = new Error('Container element is required');
|
|
|
|
|
this.initializationResolve?.();
|
|
|
|
|
this.initializationResolve = null;
|
|
|
|
|
throw new Error('Container element is required');
|
|
|
|
|
}
|
|
|
|
|
// Tear down the previous instance before creating a new one
|
|
|
|
|
if (this.wavesurfer) this.destroyWaveSurfer();
|
|
|
|
|
|
|
|
|
|
if (!url || url === 'null' || url === 'undefined') {
|
|
|
|
|
this.log(LogLevel.ERROR, 'AudioService: invalid URL', { url });
|
|
|
|
|
this.initializationState = InitializationState.Failed;
|
|
|
|
|
this.initializationError = new Error('Valid audio URL is required');
|
|
|
|
|
this.initializationResolve?.();
|
|
|
|
|
this.initializationResolve = null;
|
|
|
|
|
throw new Error('Valid audio URL is required');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If same URL and we already have an instance, just update container reference
|
|
|
|
|
if (this.currentUrl === url && this.wavesurfer) {
|
|
|
|
|
// Implementation detail, only log in debug mode
|
|
|
|
|
this.log(LogLevel.DEBUG, 'Reusing existing WaveSurfer instance for URL:', url);
|
|
|
|
|
try {
|
|
|
|
|
// Check if container is different and needs updating
|
|
|
|
|
const ws = this.wavesurfer as WaveSurferWithBackend;
|
|
|
|
|
const currentContainer = ws.getContainer?.();
|
|
|
|
|
if (currentContainer !== container) {
|
|
|
|
|
this.log(LogLevel.DEBUG, 'Updating container reference for existing instance');
|
|
|
|
|
// Update container reference without recreating instance
|
|
|
|
|
ws.setContainer?.(container);
|
|
|
|
|
} else {
|
|
|
|
|
this.log(LogLevel.DEBUG, 'Using existing instance - no changes needed');
|
|
|
|
|
}
|
|
|
|
|
// Signal initialization completion for existing instance
|
|
|
|
|
this.initializationState = InitializationState.Completed;
|
|
|
|
|
this.initializationResolve?.();
|
|
|
|
|
this.initializationResolve = null;
|
|
|
|
|
return;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.log(LogLevel.ERROR, 'Failed to reuse existing instance:', error);
|
|
|
|
|
this.cleanup();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Clean up existing instance if different URL
|
|
|
|
|
if (this.wavesurfer && this.currentUrl !== url) {
|
|
|
|
|
this.log(LogLevel.INFO, 'Cleaning up existing instance for new URL:', url);
|
|
|
|
|
this.cleanup();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create new WaveSurfer instance
|
|
|
|
|
this.log(LogLevel.DEBUG, 'Creating new WaveSurfer instance for URL:', url);
|
|
|
|
|
let ws;
|
|
|
|
|
try {
|
|
|
|
|
ws = WaveSurfer.create({
|
|
|
|
|
container: container,
|
|
|
|
|
const ws = WaveSurfer.create({
|
|
|
|
|
container,
|
|
|
|
|
waveColor: "rgba(255,255,255,0.09)",
|
|
|
|
|
progressColor: "#c8861a",
|
|
|
|
|
cursorColor: "#e8a22a",
|
|
|
|
|
@@ -263,588 +41,111 @@ private readonly PLAY_DEBOUNCE_MS: number = 100;
|
|
|
|
|
barRadius: 2,
|
|
|
|
|
height: 104,
|
|
|
|
|
normalize: true,
|
|
|
|
|
// Ensure we can control playback manually
|
|
|
|
|
autoplay: false,
|
|
|
|
|
// Development-specific settings for better debugging
|
|
|
|
|
...(typeof window !== 'undefined' && window.location && window.location.hostname === 'localhost' && this.audioContext && {
|
|
|
|
|
backend: 'WebAudio',
|
|
|
|
|
audioContext: this.audioContext,
|
|
|
|
|
audioRate: 1,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!ws) {
|
|
|
|
|
throw new Error('WaveSurfer.create returned null or undefined');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// @ts-expect-error - WaveSurfer typing doesn't expose backend
|
|
|
|
|
if (!ws.backend) {
|
|
|
|
|
this.log(LogLevel.DEBUG, 'WaveSurfer instance has no backend property yet - this might be normal in v7+');
|
|
|
|
|
// Don't throw error - we'll try to access backend later when needed
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to create WaveSurfer instance:', error);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Store references
|
|
|
|
|
this.wavesurfer = ws;
|
|
|
|
|
this.currentUrl = url;
|
|
|
|
|
this.setupEventHandlers(ws);
|
|
|
|
|
|
|
|
|
|
// Get audio context from wavesurfer
|
|
|
|
|
// Note: In WaveSurfer v7+, backend might not be available immediately
|
|
|
|
|
// We'll try to access it now, but also set up a handler to get it when ready
|
|
|
|
|
await this.setupAudioContext(ws);
|
|
|
|
|
|
|
|
|
|
// Set up event handlers before loading
|
|
|
|
|
this.setupEventHandlers();
|
|
|
|
|
|
|
|
|
|
// Load the audio with error handling
|
|
|
|
|
this.log(LogLevel.DEBUG, 'Loading audio URL:', url, {
|
|
|
|
|
currentWavesurfer: !!this.wavesurfer,
|
|
|
|
|
currentUrl: this.currentUrl,
|
|
|
|
|
initializationState: this.initializationState
|
|
|
|
|
});
|
|
|
|
|
try {
|
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
|
|
|
// Async work extracted outside the executor so rejections are always
|
|
|
|
|
// forwarded to reject() rather than becoming unhandled Promise rejections.
|
|
|
|
|
const onReady = async () => {
|
|
|
|
|
this.log(LogLevel.DEBUG, 'WaveSurfer ready event fired', {
|
|
|
|
|
currentTime: ws.getCurrentTime(),
|
|
|
|
|
duration: ws.getDuration(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await this.setupAudioContext(ws);
|
|
|
|
|
|
|
|
|
|
const duration = ws.getDuration();
|
|
|
|
|
this.log(LogLevel.DEBUG, 'WaveSurfer ready with duration:', duration);
|
|
|
|
|
|
|
|
|
|
if (duration > 0) {
|
|
|
|
|
usePlayerStore.getState().setDuration(duration);
|
|
|
|
|
this.initializationState = InitializationState.Completed;
|
|
|
|
|
this.initializationResolve?.();
|
|
|
|
|
this.initializationResolve = null;
|
|
|
|
|
this.isReady = true;
|
|
|
|
|
resolve();
|
|
|
|
|
} else {
|
|
|
|
|
const err = new Error('Audio loaded but duration is 0');
|
|
|
|
|
this.log(LogLevel.ERROR, err.message);
|
|
|
|
|
this.initializationState = InitializationState.Failed;
|
|
|
|
|
this.initializationError = err;
|
|
|
|
|
this.initializationResolve?.();
|
|
|
|
|
this.initializationResolve = null;
|
|
|
|
|
reject(err);
|
|
|
|
|
reject(new Error('Audio loaded but duration is 0'));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
ws.on('ready', () => { onReady().catch(reject); });
|
|
|
|
|
|
|
|
|
|
ws.on('error', (error) => {
|
|
|
|
|
this.log(LogLevel.ERROR, 'WaveSurfer error event:', error);
|
|
|
|
|
this.initializationState = InitializationState.Failed;
|
|
|
|
|
this.initializationError = error instanceof Error ? error : new Error(String(error));
|
|
|
|
|
reject(error);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ws.on('error', (err) => reject(err instanceof Error ? err : new Error(String(err))));
|
|
|
|
|
ws.load(url);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.log(LogLevel.INFO, 'Audio loaded successfully');
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.log(LogLevel.ERROR, 'Failed to load audio:', error);
|
|
|
|
|
this.initializationState = InitializationState.Failed;
|
|
|
|
|
this.initializationError = error instanceof Error ? error : new Error(String(error));
|
|
|
|
|
this.initializationResolve?.();
|
|
|
|
|
this.initializationResolve = null;
|
|
|
|
|
this.cleanup();
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private setupEventHandlers() {
|
|
|
|
|
if (!this.wavesurfer) return;
|
|
|
|
|
|
|
|
|
|
const ws = this.wavesurfer;
|
|
|
|
|
const playerStore = usePlayerStore.getState();
|
|
|
|
|
|
|
|
|
|
ws.on("play", () => {
|
|
|
|
|
playerStore.batchUpdate({ isPlaying: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ws.on("pause", () => {
|
|
|
|
|
playerStore.batchUpdate({ isPlaying: false });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ws.on("finish", () => {
|
|
|
|
|
playerStore.batchUpdate({ isPlaying: false });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ws.on("audioprocess", (time) => {
|
|
|
|
|
private setupEventHandlers(ws: WaveSurfer): void {
|
|
|
|
|
ws.on('play', () => usePlayerStore.getState().batchUpdate({ isPlaying: true }));
|
|
|
|
|
ws.on('pause', () => usePlayerStore.getState().batchUpdate({ isPlaying: false }));
|
|
|
|
|
ws.on('finish', () => usePlayerStore.getState().batchUpdate({ isPlaying: false }));
|
|
|
|
|
ws.on('audioprocess', (time) => {
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
// Throttle state updates to reduce React re-renders
|
|
|
|
|
if (now - this.lastTimeUpdate >= 250) {
|
|
|
|
|
playerStore.batchUpdate({ currentTime: time });
|
|
|
|
|
usePlayerStore.getState().batchUpdate({ currentTime: time });
|
|
|
|
|
this.lastTimeUpdate = now;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Note: Ready event is handled in the load promise, so we don't set it up here
|
|
|
|
|
// to avoid duplicate event handlers
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async play(songId: string | null = null, bandId: string | null = null): Promise<void> {
|
|
|
|
|
if (!this.wavesurfer) {
|
|
|
|
|
this.log(LogLevel.WARN, 'AudioService: no wavesurfer instance - cannot play');
|
|
|
|
|
// Provide more context about why there's no wavesurfer instance
|
|
|
|
|
if (!this.currentUrl) {
|
|
|
|
|
this.log(LogLevel.ERROR, 'No audio URL has been set - waveform not initialized');
|
|
|
|
|
} else if (this.initializationState === InitializationState.Failed) {
|
|
|
|
|
this.log(LogLevel.ERROR, 'Waveform initialization failed:', this.initializationError);
|
|
|
|
|
} else if (this.initializationState === InitializationState.InProgress) {
|
|
|
|
|
this.log(LogLevel.INFO, 'Waveform still initializing - waiting for completion');
|
|
|
|
|
// Wait for initialization to complete
|
|
|
|
|
try {
|
|
|
|
|
await this.waitForInitialization();
|
|
|
|
|
// After waiting, check if we have a wavesurfer instance
|
|
|
|
|
if (!this.wavesurfer) {
|
|
|
|
|
this.log(LogLevel.ERROR, 'Waveform initialization completed but no wavesurfer instance available');
|
|
|
|
|
if (!this.wavesurfer || !this.isReady) {
|
|
|
|
|
console.warn('[AudioService] play() called before ready');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.log(LogLevel.ERROR, 'Failed to wait for initialization:', error);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
this.log(LogLevel.ERROR, 'Waveform initialization failed or was cleaned up');
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if waveform is actually ready for playback
|
|
|
|
|
if (this.getDuration() <= 0) {
|
|
|
|
|
this.log(LogLevel.ERROR, 'Waveform not ready for playback - duration is 0', {
|
|
|
|
|
initializationState: this.initializationState,
|
|
|
|
|
error: this.initializationError,
|
|
|
|
|
currentUrl: this.currentUrl
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// If initialization failed, provide more context
|
|
|
|
|
if (this.initializationState === InitializationState.Failed) {
|
|
|
|
|
this.log(LogLevel.ERROR, 'Waveform initialization failed with error:', this.initializationError);
|
|
|
|
|
} else if (this.initializationState === InitializationState.InProgress) {
|
|
|
|
|
this.log(LogLevel.INFO, 'Waveform still initializing, waiting for completion');
|
|
|
|
|
// Wait for initialization to complete
|
|
|
|
|
try {
|
|
|
|
|
await this.waitForInitialization();
|
|
|
|
|
// After waiting, check duration again
|
|
|
|
|
if (this.getDuration() <= 0) {
|
|
|
|
|
this.log(LogLevel.ERROR, 'Waveform initialization completed but duration is still 0');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.log(LogLevel.ERROR, 'Failed to wait for initialization:', error);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If initialization is still in progress, wait for it to complete
|
|
|
|
|
if (this.initializationState === InitializationState.InProgress) {
|
|
|
|
|
this.log(LogLevel.INFO, 'Initialization in progress, waiting for completion before playback');
|
|
|
|
|
try {
|
|
|
|
|
await this.waitForInitialization();
|
|
|
|
|
this.log(LogLevel.DEBUG, 'Initialization completed, proceeding with playback');
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.log(LogLevel.ERROR, 'Initialization failed while waiting for playback:', error);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Debounce rapid play calls
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
if (now - this.lastPlayTime < this.PLAY_DEBOUNCE_MS) {
|
|
|
|
|
this.log(LogLevel.DEBUG, 'Playback debounced - too frequent calls');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
this.lastPlayTime = now;
|
|
|
|
|
|
|
|
|
|
// Only log play calls in debug mode to reduce noise
|
|
|
|
|
this.log(LogLevel.DEBUG, 'AudioService.play called', { songId, bandId });
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Always stop current playback first to ensure only one audio plays at a time
|
|
|
|
|
if (this.isPlaying()) {
|
|
|
|
|
this.log(LogLevel.INFO, 'Stopping current playback before starting new one');
|
|
|
|
|
this.pause();
|
|
|
|
|
// Small delay to ensure cleanup
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ensure we have a valid audio context and handle user gesture requirement
|
|
|
|
|
await this.handleAudioContextResume();
|
|
|
|
|
|
|
|
|
|
// Try to play - this might fail due to autoplay policy
|
|
|
|
|
try {
|
|
|
|
|
await this.wavesurfer.play();
|
|
|
|
|
} catch (playError) {
|
|
|
|
|
this.log(LogLevel.WARN, 'Initial play attempt failed, trying alternative approach:', playError);
|
|
|
|
|
|
|
|
|
|
// If play fails due to autoplay policy, try to resume audio context and retry
|
|
|
|
|
if (playError instanceof Error && (playError.name === 'NotAllowedError' || playError.name === 'InvalidStateError')) {
|
|
|
|
|
this.log(LogLevel.INFO, 'Playback blocked by browser autoplay policy, attempting recovery...');
|
|
|
|
|
|
|
|
|
|
// Ensure audio context is properly resumed
|
|
|
|
|
await this.handleAudioContextResume();
|
|
|
|
|
|
|
|
|
|
// Try playing again
|
|
|
|
|
await this.wavesurfer.play();
|
|
|
|
|
} else {
|
|
|
|
|
// For other errors, throw them to be handled by the retry logic
|
|
|
|
|
throw playError;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (songId && bandId) {
|
|
|
|
|
usePlayerStore.getState().setCurrentPlayingSong(songId, bandId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Success logs are redundant, only log in debug mode
|
|
|
|
|
this.log(LogLevel.DEBUG, 'Playback started successfully');
|
|
|
|
|
this.playbackAttempts = 0; // Reset on success
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.playbackAttempts++;
|
|
|
|
|
this.log(LogLevel.ERROR, `Playback failed (attempt ${this.playbackAttempts}):`, error);
|
|
|
|
|
|
|
|
|
|
// Handle specific audio context errors
|
|
|
|
|
if (error instanceof Error && (error.name === 'NotAllowedError' || error.name === 'InvalidStateError')) {
|
|
|
|
|
this.log(LogLevel.ERROR, 'Playback blocked by browser autoplay policy');
|
|
|
|
|
|
|
|
|
|
// Don't retry immediately - wait for user gesture
|
|
|
|
|
if (this.playbackAttempts >= this.MAX_PLAYBACK_ATTEMPTS) {
|
|
|
|
|
this.log(LogLevel.ERROR, 'Max playback attempts reached, resetting player');
|
|
|
|
|
// Don't cleanup wavesurfer - just reset state
|
|
|
|
|
this.playbackAttempts = 0;
|
|
|
|
|
}
|
|
|
|
|
return; // Don't retry autoplay errors
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.playbackAttempts >= this.MAX_PLAYBACK_ATTEMPTS) {
|
|
|
|
|
this.log(LogLevel.ERROR, 'Max playback attempts reached, resetting player');
|
|
|
|
|
this.cleanup();
|
|
|
|
|
// Could trigger re-initialization here if needed
|
|
|
|
|
} else {
|
|
|
|
|
// Exponential backoff for retry
|
|
|
|
|
const delay = 100 * this.playbackAttempts;
|
|
|
|
|
this.log(LogLevel.WARN, `Retrying playback in ${delay}ms...`);
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
|
|
|
return this.play(songId, bandId); // Retry
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
public pause(): void {
|
|
|
|
|
this.wavesurfer?.pause();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public pause() {
|
|
|
|
|
if (!this.wavesurfer) {
|
|
|
|
|
this.log(LogLevel.WARN, 'AudioService: no wavesurfer instance');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Only log pause calls in debug mode to reduce noise
|
|
|
|
|
this.log(LogLevel.DEBUG, 'AudioService.pause called');
|
|
|
|
|
this.wavesurfer.pause();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public seekTo(time: number) {
|
|
|
|
|
if (!this.wavesurfer) {
|
|
|
|
|
this.log(LogLevel.WARN, "AudioService: no wavesurfer instance");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Debounce seek operations to prevent jitter
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
if (now - this.lastSeekTime < this.SEEK_DEBOUNCE_MS) {
|
|
|
|
|
this.log(LogLevel.DEBUG, "Seek debounced - too frequent");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
this.lastSeekTime = now;
|
|
|
|
|
|
|
|
|
|
// Only log seek operations in debug mode to reduce noise
|
|
|
|
|
this.log(LogLevel.DEBUG, "AudioService.seekTo called", { time });
|
|
|
|
|
public seekTo(time: number): void {
|
|
|
|
|
if (this.wavesurfer && this.isReady && isFinite(time)) {
|
|
|
|
|
this.wavesurfer.setTime(time);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public getCurrentTime(): number {
|
|
|
|
|
if (!this.wavesurfer) return 0;
|
|
|
|
|
return this.wavesurfer.getCurrentTime();
|
|
|
|
|
return this.wavesurfer?.getCurrentTime() ?? 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public getDuration(): number {
|
|
|
|
|
if (!this.wavesurfer) return 0;
|
|
|
|
|
return this.wavesurfer.getDuration();
|
|
|
|
|
return this.wavesurfer?.getDuration() ?? 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public isPlaying(): boolean {
|
|
|
|
|
if (!this.wavesurfer) return false;
|
|
|
|
|
return this.wavesurfer.isPlaying();
|
|
|
|
|
return this.wavesurfer?.isPlaying() ?? false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public cleanup() {
|
|
|
|
|
this.log(LogLevel.INFO, 'AudioService.cleanup called');
|
|
|
|
|
public isWaveformReady(): boolean {
|
|
|
|
|
return this.isReady && !!this.wavesurfer;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.wavesurfer) {
|
|
|
|
|
// Aliases kept for callers until Phase 3 cleans them up
|
|
|
|
|
public isReadyForPlayback(): boolean {
|
|
|
|
|
return this.isWaveformReady();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public canAttemptPlayback(): boolean {
|
|
|
|
|
return this.isWaveformReady();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public cleanup(): void {
|
|
|
|
|
this.destroyWaveSurfer();
|
|
|
|
|
const store = usePlayerStore.getState();
|
|
|
|
|
store.setCurrentPlayingSong(null, null);
|
|
|
|
|
store.batchUpdate({ isPlaying: false, currentTime: 0 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private destroyWaveSurfer(): void {
|
|
|
|
|
if (!this.wavesurfer) return;
|
|
|
|
|
try {
|
|
|
|
|
// Always stop playback first
|
|
|
|
|
if (this.wavesurfer.isPlaying()) {
|
|
|
|
|
this.wavesurfer.pause();
|
|
|
|
|
}
|
|
|
|
|
// Disconnect audio nodes but keep audio context alive
|
|
|
|
|
if (this.wavesurfer.isPlaying()) this.wavesurfer.pause();
|
|
|
|
|
this.wavesurfer.unAll();
|
|
|
|
|
this.wavesurfer.destroy();
|
|
|
|
|
this.log(LogLevel.DEBUG, 'WaveSurfer instance cleaned up');
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.log(LogLevel.ERROR, 'Error cleaning up WaveSurfer:', error);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[AudioService] Error destroying WaveSurfer:', err);
|
|
|
|
|
}
|
|
|
|
|
this.wavesurfer = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.currentUrl = null;
|
|
|
|
|
|
|
|
|
|
const playerStore = usePlayerStore.getState();
|
|
|
|
|
playerStore.setCurrentPlayingSong(null, null);
|
|
|
|
|
playerStore.batchUpdate({ isPlaying: false, currentTime: 0 });
|
|
|
|
|
|
|
|
|
|
// Note: We intentionally don't nullify audioContext to keep it alive
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private async setupAudioContext(ws: WaveSurferWithBackend) {
|
|
|
|
|
// Simplified and more robust audio context setup
|
|
|
|
|
try {
|
|
|
|
|
// If we already have an audio context, ensure WaveSurfer uses it
|
|
|
|
|
if (this.audioContext) {
|
|
|
|
|
this.log(LogLevel.DEBUG, 'Using existing audio context');
|
|
|
|
|
|
|
|
|
|
// Centralized method to share audio context with WaveSurfer
|
|
|
|
|
this.shareAudioContextWithWaveSurfer(ws);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fallback: Try to get audio context from WaveSurfer (for compatibility)
|
|
|
|
|
if (ws.backend?.getAudioContext) {
|
|
|
|
|
this.audioContext = ws.backend.getAudioContext();
|
|
|
|
|
this.log(LogLevel.DEBUG, 'Audio context accessed via backend.getAudioContext()');
|
|
|
|
|
} else if (typeof ws.getAudioContext === 'function') {
|
|
|
|
|
this.audioContext = ws.getAudioContext();
|
|
|
|
|
this.log(LogLevel.DEBUG, 'Audio context accessed via ws.getAudioContext()');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.audioContext) {
|
|
|
|
|
this.log(LogLevel.INFO, 'Audio context initialized from WaveSurfer', {
|
|
|
|
|
state: this.audioContext.state,
|
|
|
|
|
sampleRate: this.audioContext.sampleRate
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Resume suspended audio context automatically
|
|
|
|
|
if (this.audioContext.state === 'suspended') {
|
|
|
|
|
try {
|
|
|
|
|
await this.audioContext.resume();
|
|
|
|
|
this.log(LogLevel.INFO, 'Audio context resumed successfully');
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.log(LogLevel.WARN, 'Failed to resume audio context:', error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Set up state change monitoring
|
|
|
|
|
this.audioContext.onstatechange = () => {
|
|
|
|
|
this.log(LogLevel.DEBUG, 'Audio context state changed:', this.audioContext?.state);
|
|
|
|
|
};
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Don't create new audio context if WaveSurfer doesn't provide methods
|
|
|
|
|
// This maintains backward compatibility and allows graceful degradation
|
|
|
|
|
this.log(LogLevel.DEBUG, 'No audio context available from WaveSurfer, continuing without it');
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.log(LogLevel.ERROR, 'Error setting up audio context:', error);
|
|
|
|
|
// Don't throw - we can continue with our existing audio context
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private shareAudioContextWithWaveSurfer(ws: WaveSurferWithBackend) {
|
|
|
|
|
if (!this.audioContext) {
|
|
|
|
|
this.log(LogLevel.WARN, 'No audio context available to share');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Method 1: Try to set via backend if available
|
|
|
|
|
if (ws.backend) {
|
|
|
|
|
ws.backend.audioContext = this.audioContext;
|
|
|
|
|
this.log(LogLevel.DEBUG, 'Shared audio context with WaveSurfer backend');
|
|
|
|
|
return; // Success, exit early
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Method 2: Try to access and replace the audio context via backend methods
|
|
|
|
|
if (ws.backend && typeof (ws.backend as any).getAudioContext === 'function') {
|
|
|
|
|
// Replace the method with proper typing
|
|
|
|
|
(ws.backend as any).getAudioContext = () => this.audioContext;
|
|
|
|
|
this.log(LogLevel.DEBUG, 'Overrode backend.getAudioContext with shared context');
|
|
|
|
|
return; // Success, exit early
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Method 3: Try top-level getAudioContext
|
|
|
|
|
if (typeof ws.getAudioContext === 'function') {
|
|
|
|
|
// Replace the method with proper typing
|
|
|
|
|
(ws as any).getAudioContext = () => this.audioContext;
|
|
|
|
|
this.log(LogLevel.DEBUG, 'Overrode ws.getAudioContext with shared context');
|
|
|
|
|
return; // Success, exit early
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.log(LogLevel.WARN, 'Could not share audio context with WaveSurfer - no compatible method found');
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.log(LogLevel.WARN, 'Could not share audio context with WaveSurfer:', error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
public getAudioContextState(): string | undefined {
|
|
|
|
|
return this.audioContext?.state;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Method to check if audio can be played (respects browser autoplay policies)
|
|
|
|
|
public canPlayAudio(): boolean {
|
|
|
|
|
// Must have a wavesurfer instance
|
|
|
|
|
if (!this.wavesurfer) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Must have a valid duration (waveform loaded)
|
|
|
|
|
if (this.getDuration() <= 0) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If we have an active audio context that's running, we can play
|
|
|
|
|
if (this.audioContext?.state === 'running') {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If audio context is suspended, we might be able to resume it with user gesture
|
|
|
|
|
if (this.audioContext?.state === 'suspended') {
|
|
|
|
|
return true; // User gesture can resume it
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If no audio context exists, we can create one with user gesture
|
|
|
|
|
return true; // User gesture can create it
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Method to check if waveform is ready for playback
|
|
|
|
|
public isWaveformReady(): boolean {
|
|
|
|
|
return !!this.wavesurfer && this.getDuration() > 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Method to check if audio service is properly initialized
|
|
|
|
|
public isInitialized(): boolean {
|
|
|
|
|
return !!this.wavesurfer && this.getDuration() > 0 && !!this.audioContext;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Method to check if ready for playback (unified readiness check)
|
|
|
|
|
public isReadyForPlayback(): boolean {
|
|
|
|
|
return (
|
|
|
|
|
this.isWaveformReady() &&
|
|
|
|
|
!!this.audioContext &&
|
|
|
|
|
(this.audioContext.state === 'running' || this.audioContext.state === 'suspended')
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Method to check if playback can be attempted (more lenient during initialization)
|
|
|
|
|
public canAttemptPlayback(): boolean {
|
|
|
|
|
return (
|
|
|
|
|
this.isWaveformReady() &&
|
|
|
|
|
(this.initializationState === InitializationState.Completed ||
|
|
|
|
|
this.initializationState === InitializationState.InProgress)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Initialization state management
|
|
|
|
|
public getInitializationState(): InitializationState {
|
|
|
|
|
return this.initializationState;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public getInitializationError(): Error | null {
|
|
|
|
|
return this.initializationError;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public isInitializationComplete(): boolean {
|
|
|
|
|
return this.initializationState === InitializationState.Completed;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async waitForInitialization(): Promise<void> {
|
|
|
|
|
return this.initializationPromise ?? Promise.resolve();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Method to ensure audio context is available (for backward compatibility)
|
|
|
|
|
// @ts-expect-error - This method is used in tests but may not be used in production code
|
|
|
|
|
private async ensureAudioContext(): Promise<AudioContext> {
|
|
|
|
|
// If we already have a valid audio context, return it
|
|
|
|
|
if (this.audioContext) {
|
|
|
|
|
// Resume if suspended (common in mobile browsers)
|
|
|
|
|
if (this.audioContext.state === 'suspended') {
|
|
|
|
|
try {
|
|
|
|
|
await this.audioContext.resume();
|
|
|
|
|
this.log(LogLevel.INFO, 'Audio context resumed successfully');
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.log(LogLevel.ERROR, 'Failed to resume audio context:', error);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return this.audioContext;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create new audio context
|
|
|
|
|
try {
|
|
|
|
|
this.audioContext = new (window.AudioContext || (window as { webkitAudioContext?: new () => AudioContext }).webkitAudioContext)();
|
|
|
|
|
this.log(LogLevel.INFO, 'New audio context created', {
|
|
|
|
|
state: this.audioContext.state,
|
|
|
|
|
sampleRate: this.audioContext.sampleRate
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Set up state change monitoring
|
|
|
|
|
this.audioContext.onstatechange = () => {
|
|
|
|
|
this.log(LogLevel.DEBUG, 'Audio context state changed:', this.audioContext?.state);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return this.audioContext;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.log(LogLevel.ERROR, 'Failed to create audio context:', error);
|
|
|
|
|
throw new Error(`Audio context creation failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Method to get WaveSurfer version for debugging
|
|
|
|
|
public getWaveSurferVersion(): string | null {
|
|
|
|
|
if (this.wavesurfer) {
|
|
|
|
|
// @ts-expect-error - WaveSurfer version might not be in types
|
|
|
|
|
return this.wavesurfer.version || 'unknown';
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Method to update multiple player state values at once
|
|
|
|
|
public updatePlayerState(updates: {
|
|
|
|
|
isPlaying?: boolean;
|
|
|
|
|
currentTime?: number;
|
|
|
|
|
duration?: number;
|
|
|
|
|
}) {
|
|
|
|
|
const playerStore = usePlayerStore.getState();
|
|
|
|
|
playerStore.batchUpdate(updates);
|
|
|
|
|
this.isReady = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const audioService = AudioService.getInstance();
|
|
|
|
|
export { AudioService, LogLevel, InitializationState }; // Export class and enums for testing
|
|
|
|
|
export { AudioService };
|
|
|
|
|
|