Compare commits
4 Commits
ef73e45da2
...
feature/st
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25dca3c788 | ||
|
|
7508d78a86 | ||
|
|
d4c0e9d776 | ||
|
|
1a0d926e1a |
@@ -94,7 +94,9 @@ export function MiniPlayer() {
|
|||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
audioService.pause();
|
audioService.pause();
|
||||||
} else {
|
} else {
|
||||||
audioService.play();
|
audioService.play(currentSongId, currentBandId).catch(err => {
|
||||||
|
console.warn('MiniPlayer playback failed:', err);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={
|
style={
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { audioService, InitializationState } from "../services/audioService";
|
import { audioService } from "../services/audioService";
|
||||||
import { usePlayerStore } from "../stores/playerStore";
|
import { usePlayerStore } from "../stores/playerStore";
|
||||||
|
|
||||||
export interface UseWaveformOptions {
|
export interface UseWaveformOptions {
|
||||||
@@ -22,211 +22,118 @@ export function useWaveform(
|
|||||||
containerRef: React.RefObject<HTMLDivElement>,
|
containerRef: React.RefObject<HTMLDivElement>,
|
||||||
options: UseWaveformOptions
|
options: UseWaveformOptions
|
||||||
) {
|
) {
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
|
||||||
const [isReady, setIsReady] = useState(false);
|
const [isReady, setIsReady] = useState(false);
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
|
||||||
const [duration, setDuration] = useState(0);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [initializationState, setInitializationState] = useState<InitializationState>(InitializationState.NotStarted);
|
|
||||||
const markersRef = useRef<CommentMarker[]>([]);
|
const markersRef = useRef<CommentMarker[]>([]);
|
||||||
|
|
||||||
// Global player state - use shallow comparison to reduce re-renders
|
// Playback state comes directly from the store — no intermediate local state
|
||||||
const {
|
// or RAF polling loop needed. The store is updated by WaveSurfer event handlers
|
||||||
isPlaying: globalIsPlaying,
|
// in AudioService, so these values are always in sync.
|
||||||
currentTime: globalCurrentTime,
|
const isPlaying = usePlayerStore(state => state.isPlaying);
|
||||||
currentSongId,
|
const currentTime = usePlayerStore(state => state.currentTime);
|
||||||
currentBandId: globalBandId,
|
const duration = usePlayerStore(state => state.duration);
|
||||||
currentPlayingSongId,
|
|
||||||
currentPlayingBandId,
|
|
||||||
setCurrentSong
|
|
||||||
} = usePlayerStore(state => ({
|
|
||||||
isPlaying: state.isPlaying,
|
|
||||||
currentTime: state.currentTime,
|
|
||||||
currentSongId: state.currentSongId,
|
|
||||||
currentBandId: state.currentBandId,
|
|
||||||
currentPlayingSongId: state.currentPlayingSongId,
|
|
||||||
currentPlayingBandId: state.currentPlayingBandId,
|
|
||||||
setCurrentSong: state.setCurrentSong
|
|
||||||
}));
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) {
|
if (!containerRef.current) return;
|
||||||
return;
|
if (!options.url || options.url === 'null' || options.url === 'undefined') return;
|
||||||
}
|
|
||||||
|
|
||||||
if (!options.url || options.url === 'null' || options.url === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initializeAudio = async () => {
|
const initializeAudio = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
|
|
||||||
await audioService.initialize(containerRef.current!, options.url!);
|
await audioService.initialize(containerRef.current!, options.url!);
|
||||||
|
|
||||||
// Set up local state synchronization with requestAnimationFrame for smoother updates
|
// Restore playback if this song was already playing when the page loaded.
|
||||||
let animationFrameId: number | null = null;
|
// Read as a one-time snapshot — these values must NOT be reactive deps or
|
||||||
let lastUpdateTime = 0;
|
// the effect would re-run on every time update (re-initializing WaveSurfer).
|
||||||
const updateInterval = 1000 / 15; // ~15fps for state updates
|
const {
|
||||||
|
currentSongId,
|
||||||
|
currentBandId,
|
||||||
|
isPlaying: wasPlaying,
|
||||||
|
currentTime: savedTime,
|
||||||
|
} = usePlayerStore.getState();
|
||||||
|
|
||||||
const handleStateUpdate = () => {
|
if (
|
||||||
const now = Date.now();
|
options.songId &&
|
||||||
if (now - lastUpdateTime >= updateInterval) {
|
options.bandId &&
|
||||||
const state = usePlayerStore.getState();
|
currentSongId === options.songId &&
|
||||||
setIsPlaying(state.isPlaying);
|
currentBandId === options.bandId &&
|
||||||
setCurrentTime(state.currentTime);
|
wasPlaying &&
|
||||||
setDuration(state.duration);
|
audioService.isWaveformReady()
|
||||||
lastUpdateTime = now;
|
) {
|
||||||
}
|
|
||||||
animationFrameId = requestAnimationFrame(handleStateUpdate);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Start the animation frame loop
|
|
||||||
animationFrameId = requestAnimationFrame(handleStateUpdate);
|
|
||||||
|
|
||||||
const unsubscribe = () => {
|
|
||||||
if (animationFrameId) {
|
|
||||||
cancelAnimationFrame(animationFrameId);
|
|
||||||
animationFrameId = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update global song context
|
|
||||||
if (options.songId && options.bandId) {
|
|
||||||
setCurrentSong(options.songId, options.bandId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If this is the currently playing song, restore play state
|
|
||||||
if (options.songId && options.bandId &&
|
|
||||||
currentPlayingSongId === options.songId &&
|
|
||||||
currentPlayingBandId === options.bandId &&
|
|
||||||
globalIsPlaying) {
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Wait for initialization to complete using the new promise-based approach
|
|
||||||
try {
|
try {
|
||||||
await audioService.waitForInitialization();
|
await audioService.play(options.songId, options.bandId);
|
||||||
|
if (savedTime > 0) audioService.seekTo(savedTime);
|
||||||
// Use the unified readiness check
|
} catch (err) {
|
||||||
if (audioService.isReadyForPlayback()) {
|
console.warn('Auto-play prevented during initialization:', err);
|
||||||
audioService.play(options.songId, options.bandId);
|
|
||||||
if (globalCurrentTime > 0) {
|
|
||||||
audioService.seekTo(globalCurrentTime);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn('Not ready for playback after initialization', {
|
|
||||||
state: audioService.getInitializationState(),
|
|
||||||
error: audioService.getInitializationError()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Auto-play prevented during initialization:', error);
|
|
||||||
// Don't retry - wait for user to click play
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsReady(true);
|
setIsReady(true);
|
||||||
setInitializationState(audioService.getInitializationState());
|
|
||||||
options.onReady?.(audioService.getDuration());
|
options.onReady?.(audioService.getDuration());
|
||||||
|
} catch (err) {
|
||||||
return () => {
|
console.error('useWaveform: initialization failed', err);
|
||||||
|
|
||||||
unsubscribe();
|
|
||||||
// Note: We don't cleanup the audio service here to maintain persistence
|
|
||||||
// audioService.cleanup();
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('useWaveform: initialization failed', error);
|
|
||||||
setIsReady(false);
|
setIsReady(false);
|
||||||
setError(error instanceof Error ? error.message : 'Failed to initialize audio');
|
setError(err instanceof Error ? err.message : 'Failed to initialize audio');
|
||||||
return () => {};
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
initializeAudio();
|
initializeAudio();
|
||||||
|
}, [options.url, options.songId, options.bandId]);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [options.url, options.songId, options.bandId, containerRef, currentSongId, globalBandId, globalCurrentTime, globalIsPlaying, setCurrentSong]);
|
|
||||||
|
|
||||||
const play = () => {
|
const play = () => {
|
||||||
// Use the unified readiness check
|
if (!audioService.isWaveformReady()) {
|
||||||
if (audioService.isReadyForPlayback()) {
|
console.warn('[useWaveform] play() called but not ready', { url: options.url });
|
||||||
try {
|
return;
|
||||||
audioService.play(options.songId || null, options.bandId || null);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('useWaveform.play failed:', error);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn('Cannot play: not ready for playback', {
|
|
||||||
initializationState: audioService.getInitializationState(),
|
|
||||||
error: audioService.getInitializationError(),
|
|
||||||
duration: audioService.getDuration(),
|
|
||||||
url: options.url
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
audioService.play(options.songId ?? null, options.bandId ?? null)
|
||||||
|
.catch(err => console.error('useWaveform.play failed:', err));
|
||||||
};
|
};
|
||||||
|
|
||||||
const pause = () => {
|
const pause = () => {
|
||||||
|
audioService.pause();
|
||||||
try {
|
|
||||||
audioService.pause();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('useWaveform.pause failed:', error);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const seekTo = (time: number) => {
|
const seekTo = (time: number) => {
|
||||||
|
audioService.seekTo(time);
|
||||||
try {
|
|
||||||
if (isReady && isFinite(time)) {
|
|
||||||
audioService.seekTo(time);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('useWaveform.seekTo failed:', error);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const addMarker = (marker: CommentMarker) => {
|
const addMarker = (marker: CommentMarker) => {
|
||||||
if (isReady) {
|
if (!isReady) return;
|
||||||
try {
|
try {
|
||||||
// This would need proper implementation with the actual wavesurfer instance
|
const markerElement = document.createElement("div");
|
||||||
const markerElement = document.createElement("div");
|
markerElement.style.position = "absolute";
|
||||||
markerElement.style.position = "absolute";
|
markerElement.style.width = "24px";
|
||||||
markerElement.style.width = "24px";
|
markerElement.style.height = "24px";
|
||||||
markerElement.style.height = "24px";
|
markerElement.style.borderRadius = "50%";
|
||||||
markerElement.style.borderRadius = "50%";
|
markerElement.style.backgroundColor = "var(--accent)";
|
||||||
markerElement.style.backgroundColor = "var(--accent)";
|
markerElement.style.cursor = "pointer";
|
||||||
markerElement.style.cursor = "pointer";
|
markerElement.style.zIndex = "9999";
|
||||||
markerElement.style.zIndex = "9999";
|
markerElement.style.left = `${(marker.time / audioService.getDuration()) * 100}%`;
|
||||||
markerElement.style.left = `${(marker.time / audioService.getDuration()) * 100}%`;
|
markerElement.style.transform = "translateX(-50%) translateY(-50%)";
|
||||||
markerElement.style.transform = "translateX(-50%) translateY(-50%)";
|
markerElement.style.top = "50%";
|
||||||
markerElement.style.top = "50%";
|
markerElement.style.border = "2px solid white";
|
||||||
markerElement.style.border = "2px solid white";
|
markerElement.style.boxShadow = "0 0 4px rgba(0, 0, 0, 0.3)";
|
||||||
markerElement.style.boxShadow = "0 0 4px rgba(0, 0, 0, 0.3)";
|
markerElement.title = `Comment at ${formatTime(marker.time)}`;
|
||||||
markerElement.title = `Comment at ${formatTime(marker.time)}`;
|
markerElement.onclick = marker.onClick;
|
||||||
markerElement.onclick = marker.onClick;
|
|
||||||
|
|
||||||
if (marker.icon) {
|
if (marker.icon) {
|
||||||
const iconElement = document.createElement("img");
|
const iconElement = document.createElement("img");
|
||||||
iconElement.src = marker.icon;
|
iconElement.src = marker.icon;
|
||||||
iconElement.style.width = "100%";
|
iconElement.style.width = "100%";
|
||||||
iconElement.style.height = "100%";
|
iconElement.style.height = "100%";
|
||||||
iconElement.style.borderRadius = "50%";
|
iconElement.style.borderRadius = "50%";
|
||||||
iconElement.style.objectFit = "cover";
|
iconElement.style.objectFit = "cover";
|
||||||
markerElement.appendChild(iconElement);
|
markerElement.appendChild(iconElement);
|
||||||
}
|
|
||||||
|
|
||||||
const waveformContainer = containerRef.current;
|
|
||||||
if (waveformContainer) {
|
|
||||||
waveformContainer.style.position = "relative";
|
|
||||||
waveformContainer.appendChild(markerElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
markersRef.current.push(marker);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('useWaveform.addMarker failed:', error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const waveformContainer = containerRef.current;
|
||||||
|
if (waveformContainer) {
|
||||||
|
waveformContainer.style.position = "relative";
|
||||||
|
waveformContainer.appendChild(markerElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
markersRef.current.push(marker);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('useWaveform.addMarker failed:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -241,19 +148,7 @@ export function useWaveform(
|
|||||||
markersRef.current = [];
|
markersRef.current = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return { isPlaying, isReady, currentTime, duration, play, pause, seekTo, addMarker, clearMarkers, error };
|
||||||
isPlaying,
|
|
||||||
isReady,
|
|
||||||
currentTime,
|
|
||||||
duration,
|
|
||||||
play,
|
|
||||||
pause,
|
|
||||||
seekTo,
|
|
||||||
addMarker,
|
|
||||||
clearMarkers,
|
|
||||||
error,
|
|
||||||
initializationState
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(seconds: number): string {
|
function formatTime(seconds: number): string {
|
||||||
|
|||||||
@@ -381,7 +381,13 @@ export function SongPage() {
|
|||||||
if (target.tagName === "TEXTAREA" || target.tagName === "INPUT") return;
|
if (target.tagName === "TEXTAREA" || target.tagName === "INPUT") return;
|
||||||
if (e.code === "Space") {
|
if (e.code === "Space") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (isPlaying) { pause(); } else { play(); }
|
if (isPlaying) {
|
||||||
|
pause();
|
||||||
|
} else {
|
||||||
|
if (isReady) {
|
||||||
|
play();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
|||||||
@@ -1,794 +1,142 @@
|
|||||||
import WaveSurfer from "wavesurfer.js";
|
import WaveSurfer from "wavesurfer.js";
|
||||||
import { usePlayerStore } from "../stores/playerStore";
|
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 {
|
class AudioService {
|
||||||
private static instance: AudioService;
|
private static instance: AudioService;
|
||||||
private wavesurfer: WaveSurfer | null = null;
|
private wavesurfer: WaveSurfer | null = null;
|
||||||
private audioContext: AudioContext | null = null;
|
|
||||||
private currentUrl: string | null = null;
|
private currentUrl: string | null = null;
|
||||||
private currentPlayingSongId: string | null = null;
|
private isReady = false;
|
||||||
private currentPlayingBandId: string | null = null;
|
private lastTimeUpdate = 0;
|
||||||
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;
|
|
||||||
|
|
||||||
// Initialization tracking
|
private constructor() {}
|
||||||
private initializationState: InitializationState = InitializationState.NotStarted;
|
|
||||||
private initializationError: Error | null = null;
|
|
||||||
private initializationPromise: Promise<void> | null = null;
|
|
||||||
private initializationResolve: (() => void) | null = null;
|
|
||||||
|
|
||||||
private constructor() {
|
public static getInstance(): AudioService {
|
||||||
// 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() {
|
|
||||||
if (!this.instance) {
|
if (!this.instance) {
|
||||||
this.instance = new AudioService();
|
this.instance = new AudioService();
|
||||||
}
|
}
|
||||||
return this.instance;
|
return this.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize audio context - now handles user gesture requirement
|
// For use in tests only
|
||||||
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
|
|
||||||
public static resetInstance(): void {
|
public static resetInstance(): void {
|
||||||
this.instance = undefined as any;
|
this.instance = undefined as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async initialize(container: HTMLElement, url: string): Promise<void> {
|
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
|
// Reuse the existing instance when the URL hasn't changed
|
||||||
this.initializationState = InitializationState.InProgress;
|
if (this.currentUrl === url && this.wavesurfer) return;
|
||||||
this.initializationError = null;
|
|
||||||
this.initializationPromise = new Promise<void>((resolve) => {
|
// Tear down the previous instance before creating a new one
|
||||||
this.initializationResolve = resolve;
|
if (this.wavesurfer) this.destroyWaveSurfer();
|
||||||
|
|
||||||
|
const ws = WaveSurfer.create({
|
||||||
|
container,
|
||||||
|
waveColor: "rgba(255,255,255,0.09)",
|
||||||
|
progressColor: "#c8861a",
|
||||||
|
cursorColor: "#e8a22a",
|
||||||
|
barWidth: 2,
|
||||||
|
barRadius: 2,
|
||||||
|
height: 104,
|
||||||
|
normalize: true,
|
||||||
|
autoplay: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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');
|
|
||||||
}
|
|
||||||
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
return this.wavesurfer;
|
|
||||||
} 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,
|
|
||||||
waveColor: "rgba(255,255,255,0.09)",
|
|
||||||
progressColor: "#c8861a",
|
|
||||||
cursorColor: "#e8a22a",
|
|
||||||
barWidth: 2,
|
|
||||||
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) {
|
|
||||||
console.warn('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.wavesurfer = ws;
|
||||||
this.currentUrl = url;
|
this.currentUrl = url;
|
||||||
|
this.setupEventHandlers(ws);
|
||||||
|
|
||||||
// Get audio context from wavesurfer
|
await new Promise<void>((resolve, reject) => {
|
||||||
// Note: In WaveSurfer v7+, backend might not be available immediately
|
const onReady = async () => {
|
||||||
// We'll try to access it now, but also set up a handler to get it when ready
|
const duration = ws.getDuration();
|
||||||
await this.setupAudioContext(ws);
|
if (duration > 0) {
|
||||||
|
usePlayerStore.getState().setDuration(duration);
|
||||||
|
this.isReady = true;
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error('Audio loaded but duration is 0'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Set up event handlers before loading
|
ws.on('ready', () => { onReady().catch(reject); });
|
||||||
this.setupEventHandlers();
|
ws.on('error', (err) => reject(err instanceof Error ? err : new Error(String(err))));
|
||||||
|
ws.load(url);
|
||||||
// Load the audio with error handling
|
});
|
||||||
this.log(LogLevel.DEBUG, 'Loading audio URL:', url);
|
|
||||||
try {
|
|
||||||
const loadPromise = new Promise<void>(async (resolve, reject) => {
|
|
||||||
ws.on('ready', async () => {
|
|
||||||
this.log(LogLevel.DEBUG, 'WaveSurfer ready event fired');
|
|
||||||
// Now that WaveSurfer is ready, set up audio context and finalize initialization
|
|
||||||
try {
|
|
||||||
await this.setupAudioContext(ws);
|
|
||||||
|
|
||||||
// Update player store with duration
|
|
||||||
const playerStore = usePlayerStore.getState();
|
|
||||||
playerStore.setDuration(ws.getDuration());
|
|
||||||
|
|
||||||
// Signal initialization completion
|
|
||||||
this.initializationState = InitializationState.Completed;
|
|
||||||
this.initializationResolve?.();
|
|
||||||
this.initializationResolve = null;
|
|
||||||
|
|
||||||
resolve();
|
|
||||||
} catch (error) {
|
|
||||||
this.log(LogLevel.ERROR, 'Initialization failed in ready handler:', error);
|
|
||||||
this.initializationState = InitializationState.Failed;
|
|
||||||
this.initializationError = error instanceof Error ? error : new Error(String(error));
|
|
||||||
this.initializationResolve?.();
|
|
||||||
this.initializationResolve = null;
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on('error', (error) => {
|
|
||||||
this.log(LogLevel.ERROR, 'WaveSurfer error event:', error);
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start loading
|
|
||||||
ws.load(url);
|
|
||||||
});
|
|
||||||
|
|
||||||
await loadPromise;
|
|
||||||
this.log(LogLevel.INFO, 'Audio loaded successfully');
|
|
||||||
|
|
||||||
return this.initializationPromise;
|
|
||||||
} 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() {
|
private setupEventHandlers(ws: WaveSurfer): void {
|
||||||
if (!this.wavesurfer) return;
|
ws.on('play', () => usePlayerStore.getState().batchUpdate({ isPlaying: true }));
|
||||||
|
ws.on('pause', () => usePlayerStore.getState().batchUpdate({ isPlaying: false }));
|
||||||
const ws = this.wavesurfer;
|
ws.on('finish', () => usePlayerStore.getState().batchUpdate({ isPlaying: false }));
|
||||||
const playerStore = usePlayerStore.getState();
|
ws.on('audioprocess', (time) => {
|
||||||
|
|
||||||
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) => {
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
// Throttle state updates to reduce React re-renders
|
|
||||||
if (now - this.lastTimeUpdate >= 250) {
|
if (now - this.lastTimeUpdate >= 250) {
|
||||||
playerStore.batchUpdate({ currentTime: time });
|
usePlayerStore.getState().batchUpdate({ currentTime: time });
|
||||||
this.lastTimeUpdate = now;
|
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> {
|
public async play(songId: string | null = null, bandId: string | null = null): Promise<void> {
|
||||||
if (!this.wavesurfer) {
|
if (!this.wavesurfer || !this.isReady) {
|
||||||
this.log(LogLevel.WARN, 'AudioService: no wavesurfer instance - cannot play');
|
console.warn('[AudioService] play() called before ready');
|
||||||
// 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 {
|
|
||||||
this.log(LogLevel.ERROR, 'Waveform initialization failed or was cleaned up');
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await this.wavesurfer.play();
|
||||||
// Check if waveform is actually ready for playback
|
if (songId && bandId) {
|
||||||
if (this.getDuration() <= 0) {
|
usePlayerStore.getState().setCurrentSong(songId, bandId);
|
||||||
this.log(LogLevel.ERROR, 'Waveform not ready for playback - duration is 0');
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we need to switch songs
|
|
||||||
const isDifferentSong = songId && bandId &&
|
|
||||||
(this.currentPlayingSongId !== songId || this.currentPlayingBandId !== bandId);
|
|
||||||
|
|
||||||
// If switching to a different song, perform cleanup
|
|
||||||
if (isDifferentSong) {
|
|
||||||
this.log(LogLevel.INFO, 'Switching to different song - performing cleanup');
|
|
||||||
this.cleanup();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update currently playing song tracking
|
|
||||||
if (songId && bandId) {
|
|
||||||
this.currentPlayingSongId = songId;
|
|
||||||
this.currentPlayingBandId = bandId;
|
|
||||||
const playerStore = usePlayerStore.getState();
|
|
||||||
playerStore.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() {
|
public pause(): void {
|
||||||
if (!this.wavesurfer) {
|
this.wavesurfer?.pause();
|
||||||
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) {
|
public seekTo(time: number): void {
|
||||||
if (!this.wavesurfer) {
|
if (this.wavesurfer && this.isReady && isFinite(time)) {
|
||||||
this.log(LogLevel.WARN, "AudioService: no wavesurfer instance");
|
this.wavesurfer.setTime(time);
|
||||||
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 });
|
|
||||||
this.wavesurfer.setTime(time);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public getCurrentTime(): number {
|
public getCurrentTime(): number {
|
||||||
if (!this.wavesurfer) return 0;
|
return this.wavesurfer?.getCurrentTime() ?? 0;
|
||||||
return this.wavesurfer.getCurrentTime();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public getDuration(): number {
|
public getDuration(): number {
|
||||||
if (!this.wavesurfer) return 0;
|
return this.wavesurfer?.getDuration() ?? 0;
|
||||||
return this.wavesurfer.getDuration();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public isPlaying(): boolean {
|
public isPlaying(): boolean {
|
||||||
if (!this.wavesurfer) return false;
|
return this.wavesurfer?.isPlaying() ?? false;
|
||||||
return this.wavesurfer.isPlaying();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public cleanup() {
|
|
||||||
this.log(LogLevel.INFO, 'AudioService.cleanup called');
|
|
||||||
|
|
||||||
if (this.wavesurfer) {
|
|
||||||
try {
|
|
||||||
// Always stop playback first
|
|
||||||
if (this.wavesurfer.isPlaying()) {
|
|
||||||
this.wavesurfer.pause();
|
|
||||||
}
|
|
||||||
// Disconnect audio nodes but keep audio context alive
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
this.wavesurfer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.currentUrl = null;
|
|
||||||
this.currentPlayingSongId = null;
|
|
||||||
this.currentPlayingBandId = null;
|
|
||||||
|
|
||||||
// Reset player store completely
|
|
||||||
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?.getAudioContext) {
|
|
||||||
// @ts-expect-error - Replace the method
|
|
||||||
ws.backend.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') {
|
|
||||||
// @ts-expect-error - Replace the method
|
|
||||||
ws.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 {
|
public isWaveformReady(): boolean {
|
||||||
return !!this.wavesurfer && this.getDuration() > 0;
|
return this.isReady && !!this.wavesurfer;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Method to check if audio service is properly initialized
|
public cleanup(): void {
|
||||||
public isInitialized(): boolean {
|
this.destroyWaveSurfer();
|
||||||
return !!this.wavesurfer && this.getDuration() > 0 && !!this.audioContext;
|
const store = usePlayerStore.getState();
|
||||||
|
store.setCurrentSong(null, null);
|
||||||
|
store.batchUpdate({ isPlaying: false, currentTime: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Method to check if ready for playback (unified readiness check)
|
private destroyWaveSurfer(): void {
|
||||||
public isReadyForPlayback(): boolean {
|
if (!this.wavesurfer) return;
|
||||||
return (
|
|
||||||
this.initializationState === InitializationState.Completed &&
|
|
||||||
this.isWaveformReady() &&
|
|
||||||
!!this.audioContext &&
|
|
||||||
(this.audioContext.state === 'running' || this.audioContext.state === 'suspended')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
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 {
|
try {
|
||||||
this.audioContext = new (window.AudioContext || (window as { webkitAudioContext?: new () => AudioContext }).webkitAudioContext)();
|
if (this.wavesurfer.isPlaying()) this.wavesurfer.pause();
|
||||||
this.log(LogLevel.INFO, 'New audio context created', {
|
this.wavesurfer.unAll();
|
||||||
state: this.audioContext.state,
|
this.wavesurfer.destroy();
|
||||||
sampleRate: this.audioContext.sampleRate
|
} catch (err) {
|
||||||
});
|
console.error('[AudioService] Error destroying WaveSurfer:', err);
|
||||||
|
|
||||||
// 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)}`);
|
|
||||||
}
|
}
|
||||||
}
|
this.wavesurfer = null;
|
||||||
|
this.currentUrl = null;
|
||||||
// Method to get WaveSurfer version for debugging
|
this.isReady = false;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const audioService = AudioService.getInstance();
|
export const audioService = AudioService.getInstance();
|
||||||
export { AudioService, LogLevel, InitializationState }; // Export class and enums for testing
|
export { AudioService };
|
||||||
|
|||||||
@@ -4,17 +4,16 @@ interface PlayerState {
|
|||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
|
// Set when audio starts playing; cleared on cleanup.
|
||||||
|
// Drives MiniPlayer visibility and sidebar "go to now playing" links.
|
||||||
currentSongId: string | null;
|
currentSongId: string | null;
|
||||||
currentBandId: string | null;
|
currentBandId: string | null;
|
||||||
currentPlayingSongId: string | null; // Track which song is actively playing
|
|
||||||
currentPlayingBandId: string | null; // Track which band's song is actively playing
|
|
||||||
setPlaying: (isPlaying: boolean) => void;
|
setPlaying: (isPlaying: boolean) => void;
|
||||||
setCurrentTime: (currentTime: number) => void;
|
setCurrentTime: (currentTime: number) => void;
|
||||||
setDuration: (duration: number) => void;
|
setDuration: (duration: number) => void;
|
||||||
setCurrentSong: (songId: string | null, bandId: string | null) => void;
|
setCurrentSong: (songId: string | null, bandId: string | null) => void;
|
||||||
setCurrentPlayingSong: (songId: string | null, bandId: string | null) => void;
|
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
batchUpdate: (updates: Partial<Omit<PlayerState, 'setPlaying' | 'setCurrentTime' | 'setDuration' | 'setCurrentSong' | 'setCurrentPlayingSong' | 'reset' | 'batchUpdate'>>) => void;
|
batchUpdate: (updates: Partial<Omit<PlayerState, 'setPlaying' | 'setCurrentTime' | 'setDuration' | 'setCurrentSong' | 'reset' | 'batchUpdate'>>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const usePlayerStore = create<PlayerState>()((set) => ({
|
export const usePlayerStore = create<PlayerState>()((set) => ({
|
||||||
@@ -23,13 +22,10 @@ export const usePlayerStore = create<PlayerState>()((set) => ({
|
|||||||
duration: 0,
|
duration: 0,
|
||||||
currentSongId: null,
|
currentSongId: null,
|
||||||
currentBandId: null,
|
currentBandId: null,
|
||||||
currentPlayingSongId: null,
|
|
||||||
currentPlayingBandId: null,
|
|
||||||
setPlaying: (isPlaying) => set({ isPlaying }),
|
setPlaying: (isPlaying) => set({ isPlaying }),
|
||||||
setCurrentTime: (currentTime) => set({ currentTime }),
|
setCurrentTime: (currentTime) => set({ currentTime }),
|
||||||
setDuration: (duration) => set({ duration }),
|
setDuration: (duration) => set({ duration }),
|
||||||
setCurrentSong: (songId, bandId) => set({ currentSongId: songId, currentBandId: bandId }),
|
setCurrentSong: (songId, bandId) => set({ currentSongId: songId, currentBandId: bandId }),
|
||||||
setCurrentPlayingSong: (songId, bandId) => set({ currentPlayingSongId: songId, currentPlayingBandId: bandId }),
|
|
||||||
batchUpdate: (updates) => set(updates),
|
batchUpdate: (updates) => set(updates),
|
||||||
reset: () => set({
|
reset: () => set({
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
@@ -37,7 +33,5 @@ export const usePlayerStore = create<PlayerState>()((set) => ({
|
|||||||
duration: 0,
|
duration: 0,
|
||||||
currentSongId: null,
|
currentSongId: null,
|
||||||
currentBandId: null,
|
currentBandId: null,
|
||||||
currentPlayingSongId: null,
|
|
||||||
currentPlayingBandId: null
|
|
||||||
})
|
})
|
||||||
}));
|
}));
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import { AudioService } from '../src/services/audioService';
|
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
||||||
|
|
||||||
describe('AudioService Audio Context Initialization', () => {
|
|
||||||
let audioService: AudioService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
// Reset the singleton instance before each test
|
|
||||||
AudioService.resetInstance();
|
|
||||||
audioService = AudioService.getInstance();
|
|
||||||
|
|
||||||
// Mock AudioContext for testing
|
|
||||||
global.window.AudioContext = vi.fn().mockImplementation(() => ({
|
|
||||||
state: 'suspended',
|
|
||||||
sampleRate: 44100,
|
|
||||||
resume: vi.fn().mockResolvedValue(undefined),
|
|
||||||
close: vi.fn().mockResolvedValue(undefined),
|
|
||||||
onstatechange: null,
|
|
||||||
suspend: vi.fn().mockResolvedValue(undefined)
|
|
||||||
})) as any;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
// Clean up any audio contexts
|
|
||||||
if (audioService['audioContext']) {
|
|
||||||
audioService['audioContext'].close?.().catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up mock
|
|
||||||
delete global.window.AudioContext;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should initialize audio context successfully', async () => {
|
|
||||||
const context = await audioService.initializeAudioContext();
|
|
||||||
expect(context).toBeDefined();
|
|
||||||
expect(context.state).toBe('suspended'); // Should start suspended
|
|
||||||
expect(audioService['audioContext']).toBe(context);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle audio context resume', async () => {
|
|
||||||
// Initialize context first
|
|
||||||
await audioService.initializeAudioContext();
|
|
||||||
|
|
||||||
// Mock user gesture by resuming
|
|
||||||
const resumeSpy = vi.spyOn(audioService['audioContext']!, 'resume');
|
|
||||||
|
|
||||||
// This should attempt to resume the context
|
|
||||||
await audioService['handleAudioContextResume']();
|
|
||||||
|
|
||||||
expect(resumeSpy).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should share audio context with WaveSurfer', async () => {
|
|
||||||
// Initialize audio context
|
|
||||||
await audioService.initializeAudioContext();
|
|
||||||
|
|
||||||
// Create mock WaveSurfer instance
|
|
||||||
const mockWaveSurfer = {
|
|
||||||
backend: {
|
|
||||||
audioContext: null
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Call the sharing method
|
|
||||||
audioService['shareAudioContextWithWaveSurfer'](mockWaveSurfer);
|
|
||||||
|
|
||||||
// Verify context was shared
|
|
||||||
expect(mockWaveSurfer.backend.audioContext).toBe(audioService['audioContext']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle WaveSurfer without backend gracefully', async () => {
|
|
||||||
// Initialize audio context
|
|
||||||
await audioService.initializeAudioContext();
|
|
||||||
|
|
||||||
// Create mock WaveSurfer instance without backend
|
|
||||||
const mockWaveSurfer = {
|
|
||||||
getAudioContext: vi.fn()
|
|
||||||
};
|
|
||||||
|
|
||||||
// This should not throw
|
|
||||||
expect(() => {
|
|
||||||
audioService['shareAudioContextWithWaveSurfer'](mockWaveSurfer);
|
|
||||||
}).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should check initialization state correctly', async () => {
|
|
||||||
// Initially should not be initialized
|
|
||||||
expect(audioService.isInitialized()).toBe(false);
|
|
||||||
|
|
||||||
// After audio context initialization, still not fully initialized
|
|
||||||
await audioService.initializeAudioContext();
|
|
||||||
expect(audioService.isInitialized()).toBe(false);
|
|
||||||
|
|
||||||
// Note: Full initialization would require WaveSurfer instance
|
|
||||||
// which we can't easily mock here without DOM
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,259 +1,288 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { AudioService } from '../src/services/audioService';
|
import { AudioService } from '../src/services/audioService';
|
||||||
|
import { usePlayerStore } from '../src/stores/playerStore';
|
||||||
|
|
||||||
// Mock WaveSurfer
|
// ── WaveSurfer mock ───────────────────────────────────────────────────────────
|
||||||
function createMockWaveSurfer() {
|
|
||||||
return {
|
type EventName = 'ready' | 'error' | 'play' | 'pause' | 'finish' | 'audioprocess';
|
||||||
backend: {
|
type EventHandler = (...args: any[]) => void;
|
||||||
getAudioContext: vi.fn(() => ({
|
|
||||||
state: 'running',
|
function createMockWaveSurfer(duration = 120) {
|
||||||
sampleRate: 44100,
|
const handlers: Partial<Record<EventName, EventHandler>> = {};
|
||||||
destination: { channelCount: 2 },
|
|
||||||
resume: vi.fn().mockResolvedValue(undefined),
|
const ws = {
|
||||||
onstatechange: null
|
on: vi.fn((event: EventName, handler: EventHandler) => {
|
||||||
})),
|
handlers[event] = handler;
|
||||||
ac: null,
|
}),
|
||||||
audioContext: null
|
|
||||||
},
|
|
||||||
getAudioContext: vi.fn(),
|
|
||||||
on: vi.fn(),
|
|
||||||
load: vi.fn(),
|
load: vi.fn(),
|
||||||
play: vi.fn(),
|
play: vi.fn().mockResolvedValue(undefined),
|
||||||
pause: vi.fn(),
|
pause: vi.fn(),
|
||||||
|
setTime: vi.fn(),
|
||||||
getCurrentTime: vi.fn(() => 0),
|
getCurrentTime: vi.fn(() => 0),
|
||||||
getDuration: vi.fn(() => 120),
|
getDuration: vi.fn(() => duration),
|
||||||
isPlaying: vi.fn(() => false),
|
isPlaying: vi.fn(() => false),
|
||||||
unAll: vi.fn(),
|
unAll: vi.fn(),
|
||||||
destroy: vi.fn(),
|
destroy: vi.fn(),
|
||||||
setTime: vi.fn()
|
// Helper to fire events from tests
|
||||||
|
_emit: (event: EventName, ...args: any[]) => handlers[event]?.(...args),
|
||||||
};
|
};
|
||||||
|
return ws;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMockAudioContext(state: 'suspended' | 'running' | 'closed' = 'running') {
|
vi.mock('wavesurfer.js', () => ({
|
||||||
return {
|
default: { create: vi.fn() },
|
||||||
state,
|
}));
|
||||||
sampleRate: 44100,
|
|
||||||
destination: { channelCount: 2 },
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
resume: vi.fn().mockResolvedValue(undefined),
|
|
||||||
onstatechange: null
|
function makeContainer(): HTMLElement {
|
||||||
};
|
return document.createElement('div');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function initService(
|
||||||
|
service: AudioService,
|
||||||
|
opts: { url?: string; duration?: number } = {}
|
||||||
|
): Promise<ReturnType<typeof createMockWaveSurfer>> {
|
||||||
|
const { url = 'http://example.com/audio.mp3', duration = 120 } = opts;
|
||||||
|
const WaveSurfer = (await import('wavesurfer.js')).default;
|
||||||
|
const mockWs = createMockWaveSurfer(duration);
|
||||||
|
vi.mocked(WaveSurfer.create).mockReturnValue(mockWs as any);
|
||||||
|
|
||||||
|
const initPromise = service.initialize(makeContainer(), url);
|
||||||
|
// Fire 'ready' to complete initialization
|
||||||
|
mockWs._emit('ready');
|
||||||
|
await initPromise;
|
||||||
|
return mockWs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('AudioService', () => {
|
describe('AudioService', () => {
|
||||||
let audioService: AudioService;
|
let service: AudioService;
|
||||||
let mockWaveSurfer: any;
|
|
||||||
let mockAudioContext: any;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Reset the singleton instance
|
|
||||||
AudioService.resetInstance();
|
AudioService.resetInstance();
|
||||||
audioService = AudioService.getInstance();
|
service = AudioService.getInstance();
|
||||||
|
usePlayerStore.getState().reset();
|
||||||
mockWaveSurfer = createMockWaveSurfer();
|
|
||||||
mockAudioContext = createMockAudioContext();
|
|
||||||
|
|
||||||
// Mock window.AudioContext
|
|
||||||
(globalThis as any).window = {
|
|
||||||
AudioContext: vi.fn(() => mockAudioContext) as any
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
// ── initialize() ────────────────────────────────────────────────────────────
|
||||||
vi.restoreAllMocks();
|
|
||||||
|
describe('initialize()', () => {
|
||||||
|
it('throws if container is null', async () => {
|
||||||
|
await expect(
|
||||||
|
service.initialize(null as any, 'http://example.com/a.mp3')
|
||||||
|
).rejects.toThrow('Container element is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws if url is empty', async () => {
|
||||||
|
await expect(
|
||||||
|
service.initialize(makeContainer(), '')
|
||||||
|
).rejects.toThrow('Valid audio URL is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves and marks ready when WaveSurfer fires ready with duration > 0', async () => {
|
||||||
|
await initService(service);
|
||||||
|
expect(service.isWaveformReady()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets duration in the player store on ready', async () => {
|
||||||
|
await initService(service, { duration: 180 });
|
||||||
|
expect(usePlayerStore.getState().duration).toBe(180);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects and stays not-ready when duration is 0', async () => {
|
||||||
|
const WaveSurfer = (await import('wavesurfer.js')).default;
|
||||||
|
const mockWs = createMockWaveSurfer(0);
|
||||||
|
vi.mocked(WaveSurfer.create).mockReturnValue(mockWs as any);
|
||||||
|
|
||||||
|
const initPromise = service.initialize(makeContainer(), 'http://example.com/a.mp3');
|
||||||
|
mockWs._emit('ready');
|
||||||
|
|
||||||
|
await expect(initPromise).rejects.toThrow('duration is 0');
|
||||||
|
expect(service.isWaveformReady()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when WaveSurfer fires an error', async () => {
|
||||||
|
const WaveSurfer = (await import('wavesurfer.js')).default;
|
||||||
|
const mockWs = createMockWaveSurfer();
|
||||||
|
vi.mocked(WaveSurfer.create).mockReturnValue(mockWs as any);
|
||||||
|
|
||||||
|
const initPromise = service.initialize(makeContainer(), 'http://example.com/a.mp3');
|
||||||
|
mockWs._emit('error', new Error('network error'));
|
||||||
|
|
||||||
|
await expect(initPromise).rejects.toThrow('network error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses the existing instance for the same URL', async () => {
|
||||||
|
const WaveSurfer = (await import('wavesurfer.js')).default;
|
||||||
|
const mockWs = createMockWaveSurfer();
|
||||||
|
vi.mocked(WaveSurfer.create).mockReturnValue(mockWs as any);
|
||||||
|
|
||||||
|
const url = 'http://example.com/same.mp3';
|
||||||
|
const p1 = service.initialize(makeContainer(), url);
|
||||||
|
mockWs._emit('ready');
|
||||||
|
await p1;
|
||||||
|
|
||||||
|
const createCallCount = vi.mocked(WaveSurfer.create).mock.calls.length;
|
||||||
|
await service.initialize(makeContainer(), url); // same URL
|
||||||
|
expect(vi.mocked(WaveSurfer.create).mock.calls.length).toBe(createCallCount); // no new instance
|
||||||
|
});
|
||||||
|
|
||||||
|
it('destroys old instance when URL changes', async () => {
|
||||||
|
const WaveSurfer = (await import('wavesurfer.js')).default;
|
||||||
|
|
||||||
|
const mockWs1 = createMockWaveSurfer();
|
||||||
|
vi.mocked(WaveSurfer.create).mockReturnValue(mockWs1 as any);
|
||||||
|
const p1 = service.initialize(makeContainer(), 'http://example.com/a.mp3');
|
||||||
|
mockWs1._emit('ready');
|
||||||
|
await p1;
|
||||||
|
|
||||||
|
const mockWs2 = createMockWaveSurfer();
|
||||||
|
vi.mocked(WaveSurfer.create).mockReturnValue(mockWs2 as any);
|
||||||
|
const p2 = service.initialize(makeContainer(), 'http://example.com/b.mp3');
|
||||||
|
mockWs2._emit('ready');
|
||||||
|
await p2;
|
||||||
|
|
||||||
|
expect(mockWs1.destroy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('setupAudioContext', () => {
|
// ── play() ───────────────────────────────────────────────────────────────────
|
||||||
it('should successfully access audio context via backend.getAudioContext()', () => {
|
|
||||||
audioService['setupAudioContext'](mockWaveSurfer);
|
|
||||||
|
|
||||||
expect(mockWaveSurfer.backend.getAudioContext).toHaveBeenCalled();
|
describe('play()', () => {
|
||||||
expect(audioService['audioContext']).toBeDefined();
|
it('does nothing if not ready', async () => {
|
||||||
expect(audioService['audioContext'].state).toBe('running');
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
await service.play('song-1', 'band-1');
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('before ready'));
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls wavesurfer.play()', async () => {
|
||||||
|
const mockWs = await initService(service);
|
||||||
|
await service.play();
|
||||||
|
expect(mockWs.play).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates currentSongId/BandId in the store', async () => {
|
||||||
|
await initService(service);
|
||||||
|
await service.play('song-1', 'band-1');
|
||||||
|
const state = usePlayerStore.getState();
|
||||||
|
expect(state.currentSongId).toBe('song-1');
|
||||||
|
expect(state.currentBandId).toBe('band-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not update store ids when called without ids', async () => {
|
||||||
|
await initService(service);
|
||||||
|
await service.play();
|
||||||
|
const state = usePlayerStore.getState();
|
||||||
|
expect(state.currentSongId).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fall back to ws.getAudioContext() if backend method fails', () => {
|
// ── pause() ──────────────────────────────────────────────────────────────────
|
||||||
const mockWaveSurferNoBackend = {
|
|
||||||
...mockWaveSurfer,
|
|
||||||
backend: null,
|
|
||||||
getAudioContext: vi.fn(() => mockAudioContext)
|
|
||||||
};
|
|
||||||
|
|
||||||
audioService['setupAudioContext'](mockWaveSurferNoBackend);
|
describe('pause()', () => {
|
||||||
|
it('calls wavesurfer.pause() when ready', async () => {
|
||||||
|
const mockWs = await initService(service);
|
||||||
|
service.pause();
|
||||||
|
expect(mockWs.pause).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
expect(mockWaveSurferNoBackend.getAudioContext).toHaveBeenCalled();
|
it('does nothing if not initialized', () => {
|
||||||
expect(audioService['audioContext']).toBeDefined();
|
expect(() => service.pause()).not.toThrow();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle case when no audio context methods work but not throw error', () => {
|
// ── seekTo() ─────────────────────────────────────────────────────────────────
|
||||||
const mockWaveSurferNoMethods = {
|
|
||||||
...mockWaveSurfer,
|
|
||||||
backend: {
|
|
||||||
getAudioContext: null,
|
|
||||||
ac: null,
|
|
||||||
audioContext: null
|
|
||||||
},
|
|
||||||
getAudioContext: null
|
|
||||||
};
|
|
||||||
|
|
||||||
// Should not throw error - just continue without audio context
|
describe('seekTo()', () => {
|
||||||
audioService['setupAudioContext'](mockWaveSurferNoMethods);
|
it('calls wavesurfer.setTime() with the given time', async () => {
|
||||||
|
const mockWs = await initService(service);
|
||||||
|
service.seekTo(42);
|
||||||
|
expect(mockWs.setTime).toHaveBeenCalledWith(42);
|
||||||
|
});
|
||||||
|
|
||||||
// Audio context should remain null in this case
|
it('does nothing for non-finite values', async () => {
|
||||||
expect(audioService['audioContext']).toBeNull();
|
const mockWs = await initService(service);
|
||||||
|
service.seekTo(Infinity);
|
||||||
|
service.seekTo(NaN);
|
||||||
|
expect(mockWs.setTime).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing if not ready', () => {
|
||||||
|
expect(() => service.seekTo(10)).not.toThrow();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle suspended audio context by resuming it', () => {
|
// ── cleanup() ────────────────────────────────────────────────────────────────
|
||||||
const suspendedContext = createMockAudioContext('suspended');
|
|
||||||
mockWaveSurfer.backend.getAudioContext.mockReturnValue(suspendedContext);
|
|
||||||
|
|
||||||
audioService['setupAudioContext'](mockWaveSurfer);
|
describe('cleanup()', () => {
|
||||||
|
it('destroys WaveSurfer and marks service not-ready', async () => {
|
||||||
|
const mockWs = await initService(service);
|
||||||
|
service.cleanup();
|
||||||
|
expect(mockWs.destroy).toHaveBeenCalled();
|
||||||
|
expect(service.isWaveformReady()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
expect(suspendedContext.resume).toHaveBeenCalled();
|
it('resets isPlaying and currentTime in the store', async () => {
|
||||||
|
await initService(service);
|
||||||
|
usePlayerStore.getState().batchUpdate({ isPlaying: true, currentTime: 30 });
|
||||||
|
service.cleanup();
|
||||||
|
const state = usePlayerStore.getState();
|
||||||
|
expect(state.isPlaying).toBe(false);
|
||||||
|
expect(state.currentTime).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pauses before destroying if playing', async () => {
|
||||||
|
const callOrder: string[] = [];
|
||||||
|
const mockWs = await initService(service);
|
||||||
|
mockWs.isPlaying.mockReturnValue(true);
|
||||||
|
mockWs.pause.mockImplementation(() => callOrder.push('pause'));
|
||||||
|
mockWs.destroy.mockImplementation(() => callOrder.push('destroy'));
|
||||||
|
service.cleanup();
|
||||||
|
expect(callOrder).toEqual(['pause', 'destroy']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is safe to call when not initialized', () => {
|
||||||
|
expect(() => service.cleanup()).not.toThrow();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not throw error if audio context cannot be created - just continue', () => {
|
// ── WaveSurfer event → store sync ────────────────────────────────────────────
|
||||||
global.window.AudioContext = vi.fn(() => {
|
|
||||||
throw new Error('AudioContext creation failed');
|
|
||||||
}) as any;
|
|
||||||
|
|
||||||
const mockWaveSurferNoMethods = {
|
describe('WaveSurfer event handlers', () => {
|
||||||
...mockWaveSurfer,
|
it('play event sets store isPlaying=true', async () => {
|
||||||
backend: {
|
const mockWs = await initService(service);
|
||||||
getAudioContext: null,
|
mockWs._emit('play');
|
||||||
ac: null,
|
expect(usePlayerStore.getState().isPlaying).toBe(true);
|
||||||
audioContext: null
|
});
|
||||||
},
|
|
||||||
getAudioContext: null
|
|
||||||
};
|
|
||||||
|
|
||||||
// Should not throw error - just continue without audio context
|
it('pause event sets store isPlaying=false', async () => {
|
||||||
expect(() => audioService['setupAudioContext'](mockWaveSurferNoMethods))
|
const mockWs = await initService(service);
|
||||||
.not.toThrow();
|
usePlayerStore.getState().batchUpdate({ isPlaying: true });
|
||||||
expect(audioService['audioContext']).toBeNull();
|
mockWs._emit('pause');
|
||||||
|
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finish event sets store isPlaying=false', async () => {
|
||||||
|
const mockWs = await initService(service);
|
||||||
|
usePlayerStore.getState().batchUpdate({ isPlaying: true });
|
||||||
|
mockWs._emit('finish');
|
||||||
|
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('audioprocess event updates store currentTime (throttled at 250ms)', async () => {
|
||||||
|
const mockWs = await initService(service);
|
||||||
|
// First emission at t=300 passes throttle (300 - lastUpdateTime:0 >= 250)
|
||||||
|
vi.spyOn(Date, 'now').mockReturnValue(300);
|
||||||
|
mockWs._emit('audioprocess', 15.5);
|
||||||
|
expect(usePlayerStore.getState().currentTime).toBe(15.5);
|
||||||
|
|
||||||
|
// Second emission at t=400 is throttled (400 - 300 = 100 < 250)
|
||||||
|
vi.spyOn(Date, 'now').mockReturnValue(400);
|
||||||
|
mockWs._emit('audioprocess', 16.0);
|
||||||
|
expect(usePlayerStore.getState().currentTime).toBe(15.5); // unchanged
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('ensureAudioContext', () => {
|
|
||||||
it('should return existing audio context if available', async () => {
|
|
||||||
audioService['audioContext'] = mockAudioContext;
|
|
||||||
|
|
||||||
const result = await audioService['ensureAudioContext']();
|
|
||||||
|
|
||||||
expect(result).toBe(mockAudioContext);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should resume suspended audio context', async () => {
|
|
||||||
const suspendedContext = createMockAudioContext('suspended');
|
|
||||||
audioService['audioContext'] = suspendedContext;
|
|
||||||
|
|
||||||
const result = await audioService['ensureAudioContext']();
|
|
||||||
|
|
||||||
expect(suspendedContext.resume).toHaveBeenCalled();
|
|
||||||
expect(result).toBe(suspendedContext);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create new audio context if none exists', async () => {
|
|
||||||
const result = await audioService['ensureAudioContext']();
|
|
||||||
|
|
||||||
expect(global.window.AudioContext).toHaveBeenCalled();
|
|
||||||
expect(result).toBeDefined();
|
|
||||||
expect(result.state).toBe('running');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw error if audio context creation fails', async () => {
|
|
||||||
global.window.AudioContext = vi.fn(() => {
|
|
||||||
throw new Error('Creation failed');
|
|
||||||
}) as any;
|
|
||||||
|
|
||||||
await expect(audioService['ensureAudioContext']())
|
|
||||||
.rejects
|
|
||||||
.toThrow('Audio context creation failed: Creation failed');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getWaveSurferVersion', () => {
|
|
||||||
it('should return WaveSurfer version if available', () => {
|
|
||||||
audioService['wavesurfer'] = {
|
|
||||||
version: '7.12.5'
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
expect(audioService.getWaveSurferVersion()).toBe('7.12.5');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return unknown if version not available', () => {
|
|
||||||
audioService['wavesurfer'] = {} as any;
|
|
||||||
|
|
||||||
expect(audioService.getWaveSurferVersion()).toBe('unknown');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null if no wavesurfer instance', () => {
|
|
||||||
audioService['wavesurfer'] = null;
|
|
||||||
|
|
||||||
expect(audioService.getWaveSurferVersion()).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('initializeAudioContext', () => {
|
|
||||||
it('should initialize audio context successfully', async () => {
|
|
||||||
const result = await audioService.initializeAudioContext();
|
|
||||||
|
|
||||||
expect(result).toBeDefined();
|
|
||||||
expect(result.state).toBe('running');
|
|
||||||
expect(audioService['audioContext']).toBe(result);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should resume suspended audio context', async () => {
|
|
||||||
const suspendedContext = createMockAudioContext('suspended');
|
|
||||||
global.window.AudioContext = vi.fn(() => suspendedContext) as any;
|
|
||||||
|
|
||||||
const result = await audioService.initializeAudioContext();
|
|
||||||
|
|
||||||
expect(suspendedContext.resume).toHaveBeenCalled();
|
|
||||||
expect(result).toBe(suspendedContext);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle audio context creation errors', async () => {
|
|
||||||
global.window.AudioContext = vi.fn(() => {
|
|
||||||
throw new Error('AudioContext creation failed');
|
|
||||||
}) as any;
|
|
||||||
|
|
||||||
await expect(audioService.initializeAudioContext())
|
|
||||||
.rejects
|
|
||||||
.toThrow('Failed to initialize audio context: AudioContext creation failed');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('cleanup', () => {
|
|
||||||
it('should stop playback and clean up properly', () => {
|
|
||||||
// Mock a playing wavesurfer instance
|
|
||||||
const mockWavesurfer = {
|
|
||||||
isPlaying: vi.fn(() => true),
|
|
||||||
pause: vi.fn(),
|
|
||||||
unAll: vi.fn(),
|
|
||||||
destroy: vi.fn()
|
|
||||||
};
|
|
||||||
audioService['wavesurfer'] = mockWavesurfer;
|
|
||||||
|
|
||||||
audioService['currentPlayingSongId'] = 'song-123';
|
|
||||||
audioService['currentPlayingBandId'] = 'band-456';
|
|
||||||
|
|
||||||
audioService.cleanup();
|
|
||||||
|
|
||||||
expect(mockWavesurfer.pause).toHaveBeenCalled();
|
|
||||||
expect(mockWavesurfer.unAll).toHaveBeenCalled();
|
|
||||||
expect(mockWavesurfer.destroy).toHaveBeenCalled();
|
|
||||||
expect(audioService['wavesurfer']).toBeNull();
|
|
||||||
expect(audioService['currentPlayingSongId']).toBeNull();
|
|
||||||
expect(audioService['currentPlayingBandId']).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle cleanup when no wavesurfer instance exists', () => {
|
|
||||||
audioService['wavesurfer'] = null;
|
|
||||||
audioService['currentPlayingSongId'] = 'song-123';
|
|
||||||
|
|
||||||
expect(() => audioService.cleanup()).not.toThrow();
|
|
||||||
expect(audioService['currentPlayingSongId']).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
||||||
import { AudioService, LogLevel } from '../src/services/audioService';
|
|
||||||
|
|
||||||
describe('AudioService Logging Optimization', () => {
|
|
||||||
let audioService: AudioService;
|
|
||||||
let consoleSpy: any;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
AudioService.resetInstance();
|
|
||||||
|
|
||||||
// Spy on console methods
|
|
||||||
consoleSpy = {
|
|
||||||
debug: vi.spyOn(console, 'debug').mockImplementation(() => {}),
|
|
||||||
info: vi.spyOn(console, 'info').mockImplementation(() => {}),
|
|
||||||
warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
|
|
||||||
error: vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Environment-based Log Level Detection', () => {
|
|
||||||
it('should use DEBUG level in development environment (localhost)', () => {
|
|
||||||
// Mock localhost environment
|
|
||||||
const originalLocation = window.location;
|
|
||||||
delete (window as any).location;
|
|
||||||
window.location = { hostname: 'localhost' } as any;
|
|
||||||
|
|
||||||
audioService = AudioService.getInstance();
|
|
||||||
|
|
||||||
expect(consoleSpy.info).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('DEBUG')
|
|
||||||
);
|
|
||||||
|
|
||||||
window.location = originalLocation;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use WARN level in production environment', () => {
|
|
||||||
// Mock production environment
|
|
||||||
const originalLocation = window.location;
|
|
||||||
delete (window as any).location;
|
|
||||||
window.location = { hostname: 'example.com' } as any;
|
|
||||||
|
|
||||||
audioService = AudioService.getInstance();
|
|
||||||
|
|
||||||
expect(consoleSpy.info).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('WARN')
|
|
||||||
);
|
|
||||||
|
|
||||||
window.location = originalLocation;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use DEBUG level with audioDebug query parameter', () => {
|
|
||||||
// Mock production environment with debug parameter
|
|
||||||
const originalLocation = window.location;
|
|
||||||
delete (window as any).location;
|
|
||||||
window.location = {
|
|
||||||
hostname: 'example.com',
|
|
||||||
search: '?audioDebug=true'
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
audioService = AudioService.getInstance();
|
|
||||||
|
|
||||||
expect(consoleSpy.info).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('log level: DEBUG')
|
|
||||||
);
|
|
||||||
|
|
||||||
window.location = originalLocation;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Log Throttling', () => {
|
|
||||||
it('should throttle rapid-fire log calls', () => {
|
|
||||||
// Mock development environment
|
|
||||||
const originalLocation = window.location;
|
|
||||||
delete (window as any).location;
|
|
||||||
window.location = { hostname: 'localhost' } as any;
|
|
||||||
|
|
||||||
audioService = AudioService.getInstance();
|
|
||||||
|
|
||||||
// Call log multiple times rapidly
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
audioService['log'](LogLevel.DEBUG, `Test log ${i}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should only log a few times due to throttling
|
|
||||||
expect(consoleSpy.debug).toHaveBeenCalled();
|
|
||||||
// Should be called fewer times due to throttling
|
|
||||||
const callCount = consoleSpy.debug.mock.calls.length;
|
|
||||||
expect(callCount).toBeLessThanOrEqual(3);
|
|
||||||
|
|
||||||
window.location = originalLocation;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Log Level Filtering', () => {
|
|
||||||
it('should filter out logs below current log level', () => {
|
|
||||||
// Mock production environment (WARN level)
|
|
||||||
const originalLocation = window.location;
|
|
||||||
delete (window as any).location;
|
|
||||||
window.location = { hostname: 'example.com' } as any;
|
|
||||||
|
|
||||||
audioService = AudioService.getInstance();
|
|
||||||
|
|
||||||
// Try to log INFO level message in WARN environment
|
|
||||||
audioService['log'](LogLevel.INFO, 'This should not appear');
|
|
||||||
|
|
||||||
// Should not call console.info
|
|
||||||
expect(consoleSpy.info).not.toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('This should not appear')
|
|
||||||
);
|
|
||||||
|
|
||||||
// WARN level should appear
|
|
||||||
audioService['log'](LogLevel.WARN, 'This should appear');
|
|
||||||
expect(consoleSpy.warn).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('This should appear')
|
|
||||||
);
|
|
||||||
|
|
||||||
window.location = originalLocation;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should allow DEBUG logs in development environment', () => {
|
|
||||||
// Mock development environment
|
|
||||||
const originalLocation = window.location;
|
|
||||||
delete (window as any).location;
|
|
||||||
window.location = { hostname: 'localhost' } as any;
|
|
||||||
|
|
||||||
audioService = AudioService.getInstance();
|
|
||||||
|
|
||||||
// DEBUG level should appear in development
|
|
||||||
audioService['log'](LogLevel.DEBUG, 'Debug message');
|
|
||||||
expect(consoleSpy.debug).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('Debug message')
|
|
||||||
);
|
|
||||||
|
|
||||||
window.location = originalLocation;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Verbose Log Reduction', () => {
|
|
||||||
it('should not log play/pause/seek calls in production', () => {
|
|
||||||
// Mock production environment
|
|
||||||
const originalLocation = window.location;
|
|
||||||
delete (window as any).location;
|
|
||||||
window.location = { hostname: 'example.com' } as any;
|
|
||||||
|
|
||||||
audioService = AudioService.getInstance();
|
|
||||||
|
|
||||||
// These should not appear in production (INFO level, but production uses WARN)
|
|
||||||
audioService['log'](LogLevel.INFO, 'AudioService.play called');
|
|
||||||
audioService['log'](LogLevel.INFO, 'AudioService.pause called');
|
|
||||||
audioService['log'](LogLevel.INFO, 'AudioService.seekTo called');
|
|
||||||
|
|
||||||
// Should not call console.info for these
|
|
||||||
expect(consoleSpy.info).not.toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('AudioService.play called')
|
|
||||||
);
|
|
||||||
expect(consoleSpy.info).not.toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('AudioService.pause called')
|
|
||||||
);
|
|
||||||
expect(consoleSpy.info).not.toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('AudioService.seekTo called')
|
|
||||||
);
|
|
||||||
|
|
||||||
window.location = originalLocation;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should log errors in both environments', () => {
|
|
||||||
// Test in production environment
|
|
||||||
const originalLocation = window.location;
|
|
||||||
delete (window as any).location;
|
|
||||||
window.location = { hostname: 'example.com' } as any;
|
|
||||||
|
|
||||||
audioService = AudioService.getInstance();
|
|
||||||
|
|
||||||
// Error logs should always appear
|
|
||||||
audioService['log'](LogLevel.ERROR, 'Critical error');
|
|
||||||
expect(consoleSpy.error).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('Critical error')
|
|
||||||
);
|
|
||||||
|
|
||||||
window.location = originalLocation;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user