Files
lidify/frontend/lib/howler-engine.ts
Your Name cc8d0f6969 Release v1.3.0: Multi-source downloads, audio analyzer resilience, mobile improvements
Major Features:
- Multi-source download system (Soulseek/Lidarr with fallback)
- Configurable enrichment speed control (1-5x)
- Mobile touch drag support for seek sliders
- iOS PWA media controls (Control Center, Lock Screen)
- Artist name alias resolution via Last.fm
- Circuit breaker pattern for audio analysis

Critical Fixes:
- Audio analyzer stability (non-ASCII, BrokenProcessPool, OOM)
- Discovery system race conditions and import failures
- Radio decade categorization using originalYear
- LastFM API response normalization
- Mood bucket infinite loop prevention

Security:
- Bull Board admin authentication
- Lidarr webhook signature verification
- JWT token expiration and refresh
- Encryption key validation on startup

Closes #2, #6, #9, #13, #21, #26, #31, #34, #35, #37, #40, #43
2026-01-06 20:07:33 -06:00

583 lines
18 KiB
TypeScript

/**
* Howler.js Audio Engine
*
* Singleton manager for audio playback using Howler.js
* Handles: play, pause, seek, volume, track changes, events
*/
import { Howl } from "howler";
export type HowlerEventType =
| "play"
| "pause"
| "stop"
| "end"
| "seek"
| "volume"
| "load"
| "loaderror"
| "playerror"
| "timeupdate";
export type HowlerEventCallback = (data?: any) => void;
interface HowlerEngineState {
currentSrc: string | null;
isPlaying: boolean;
currentTime: number;
duration: number;
volume: number;
isMuted: boolean;
}
class HowlerEngine {
private howl: Howl | null = null;
private timeUpdateInterval: NodeJS.Timeout | null = null;
private eventListeners: Map<HowlerEventType, Set<HowlerEventCallback>> =
new Map();
private state: HowlerEngineState = {
currentSrc: null,
isPlaying: false,
currentTime: 0,
duration: 0,
volume: 1,
isMuted: false,
};
private isLoading: boolean = false; // Guard against duplicate loads
private userInitiatedPlay: boolean = false; // Track if play was user-initiated
private retryCount: number = 0; // Track retry attempts
private maxRetries: number = 3; // Max retry attempts for load errors
private pendingAutoplay: boolean = false; // Track pending autoplay for retries
private lastFormat: string | undefined; // Store format for retries
private readonly popFadeMs: number = 10; // ms - micro-fade to reduce click/pop on track changes
private shouldRetryLoads: boolean = false; // Only retry transient load errors where it helps (Android WebView)
private cleanupTimeoutId: NodeJS.Timeout | null = null; // Track cleanup timeout to prevent race conditions
// Seek state management - prevents stale timeupdate events during seeks
private isSeeking: boolean = false;
private seekTargetTime: number | null = null;
private seekTimeoutId: NodeJS.Timeout | null = null;
constructor() {
// Initialize event listener maps
const events: HowlerEventType[] = [
"play",
"pause",
"stop",
"end",
"seek",
"volume",
"load",
"loaderror",
"playerror",
"timeupdate",
];
events.forEach((event) => this.eventListeners.set(event, new Set()));
}
/**
* Load and optionally play a new audio source
* @param src - Audio URL
* @param autoplay - Whether to auto-play after loading
* @param format - Audio format hint (mp3, flac, etc.) - required for URLs without extensions
*/
load(
src: string,
autoplay: boolean = false,
format?: string,
isRetry: boolean = false
): void {
// Don't reload if same source and already loaded
if (this.state.currentSrc === src && this.howl) {
if (autoplay && !this.state.isPlaying) {
this.play();
}
return;
}
// Prevent duplicate loads - if already loading this URL, skip
if (this.isLoading && this.state.currentSrc === src) {
return;
}
// Set loading guard immediately
this.isLoading = true;
// Simple instant switch - no crossfade (crossfade caused duplicate playback bugs)
// Just stop current track and load new one
this.cleanup();
this.state.currentSrc = src;
// Detect if running in Android WebView (for graceful degradation)
const isAndroidWebView =
typeof navigator !== "undefined" &&
/wv/.test(navigator.userAgent.toLowerCase()) &&
/android/.test(navigator.userAgent.toLowerCase());
this.shouldRetryLoads = isAndroidWebView;
// Check if this is a podcast/audiobook stream (they need HTML5 Audio for Range request support)
const isPodcastOrAudiobook =
src.includes("/api/podcasts/") || src.includes("/api/audiobooks/");
// Build Howl config
// Note: On Android WebView, HTML5 Audio causes crackling/popping on track changes
// Use Web Audio API on Android for smoother playback (trades streaming for quality)
// EXCEPTION: Podcasts always use HTML5 Audio because they need Range request support
// for seeking in large files. Web Audio would try to download the entire ~100MB file.
const howlConfig: any = {
src: [src],
html5: isPodcastOrAudiobook || !isAndroidWebView, // HTML5 for podcasts/audiobooks OR non-Android
autoplay: false, // We'll handle autoplay with fade
preload: true,
volume: this.state.isMuted ? 0 : this.state.volume,
// On Android WebView, increase the xhr timeout
...(isAndroidWebView && { xhr: { timeout: 30000 } }),
};
// Store for potential retry
this.pendingAutoplay = autoplay;
this.lastFormat = format;
// Reset retry count only when this is NOT a retry attempt.
// If we reset on retries, we can end up in an infinite retry loop.
if (!isRetry) {
this.retryCount = 0;
}
// Add format hints (required for URLs without file extensions)
// Include multiple formats as fallbacks - browser will try them in order
if (format) {
// Put the expected format first, then common fallbacks
const formats = [format];
if (!formats.includes("mp3")) formats.push("mp3");
if (!formats.includes("flac")) formats.push("flac");
if (!formats.includes("mp4")) formats.push("mp4");
if (!formats.includes("webm")) formats.push("webm");
howlConfig.format = formats;
} else {
// Default format order if none specified
howlConfig.format = ["mp3", "flac", "mp4", "webm", "wav"];
}
this.howl = new Howl({
...howlConfig,
onload: () => {
this.isLoading = false;
this.state.duration = this.howl?.duration() || 0;
this.emit("load", { duration: this.state.duration });
if (autoplay) {
this.play();
}
},
onloaderror: (id, error) => {
console.error(
"[HowlerEngine] Load error:",
error,
"Attempt:",
this.retryCount + 1
);
this.isLoading = false;
// Retry logic for transient errors (common on Android WebView)
if (
this.shouldRetryLoads &&
this.retryCount < this.maxRetries &&
this.state.currentSrc
) {
this.retryCount++;
// Save src before cleanup
const srcToRetry = this.state.currentSrc;
const autoplayToRetry = this.pendingAutoplay;
const formatToRetry = this.lastFormat;
// CRITICAL: Clean up the failed Howl instance BEFORE retrying
// This prevents "HTML5 Audio pool exhausted" errors
this.cleanup();
// Wait a bit before retrying
setTimeout(() => {
this.load(
srcToRetry,
autoplayToRetry,
formatToRetry,
true
);
}, 500 * this.retryCount); // Exponential backoff
return;
}
// All retries failed - clean up and emit error
this.retryCount = 0;
this.cleanup(); // Clean up failed instance
this.emit("loaderror", { error });
},
onplayerror: (id, error) => {
console.error("[HowlerEngine] Play error:", error);
// Clear playing state so UI shows play button
this.state.isPlaying = false;
this.userInitiatedPlay = false;
this.stopTimeUpdates();
this.emit("playerror", { error });
// Don't try to auto-recover - let the user click play again
// The 'unlock' mechanism requires a NEW user interaction which won't happen automatically
},
onplay: () => {
this.state.isPlaying = true;
this.userInitiatedPlay = false; // Clear flag after successful play
this.startTimeUpdates();
this.emit("play");
},
onpause: () => {
this.state.isPlaying = false;
this.userInitiatedPlay = false;
this.stopTimeUpdates();
this.emit("pause");
},
onstop: () => {
this.state.isPlaying = false;
this.state.currentTime = 0;
this.stopTimeUpdates();
this.emit("stop");
},
onend: () => {
this.state.isPlaying = false;
this.stopTimeUpdates();
this.emit("end");
},
onseek: () => {
if (this.howl) {
this.state.currentTime = this.howl.seek() as number;
this.emit("seek", { time: this.state.currentTime });
}
},
});
}
/**
* Play audio (user-initiated)
*/
play(): void {
if (!this.howl) {
console.warn("[HowlerEngine] No audio loaded");
return;
}
// Don't reset volume if already playing
if (this.state.isPlaying) {
return;
}
// Mark as user-initiated for autoplay recovery
this.userInitiatedPlay = true;
// Ensure volume is set correctly before playing
const targetVolume = this.state.isMuted ? 0 : this.state.volume;
this.howl.volume(targetVolume);
this.howl.play();
}
/**
* Pause audio
*/
pause(): void {
if (!this.howl || !this.state.isPlaying) return;
this.howl.pause();
}
/**
* Stop playback completely
*/
stop(): void {
if (!this.howl) return;
this.howl.stop();
}
/**
* Seek to a specific time
* Includes seek locking to prevent stale timeupdate events from causing UI flicker
*/
seek(time: number): void {
if (!this.howl) return;
// Set seek lock - this prevents timeupdate from emitting stale values
this.isSeeking = true;
this.seekTargetTime = time;
// Clear any existing seek timeout
if (this.seekTimeoutId) {
clearTimeout(this.seekTimeoutId);
}
this.state.currentTime = time;
this.howl.seek(time);
this.emit("seek", { time });
// Release seek lock after audio has time to sync
// This timeout ensures timeupdate doesn't emit stale values during the seek operation
this.seekTimeoutId = setTimeout(() => {
this.isSeeking = false;
this.seekTargetTime = null;
this.seekTimeoutId = null;
}, 300);
}
/**
* Check if currently in a seek operation
*/
isCurrentlySeeking(): boolean {
return this.isSeeking;
}
/**
* Get the target seek position (if seeking)
*/
getSeekTarget(): number | null {
return this.seekTargetTime;
}
/**
* Force reload the audio from current source
* Used after cache is ready to enable seeking
*/
reload(): void {
if (!this.state.currentSrc) return;
const src = this.state.currentSrc;
const format = this.howl ? (this.howl as any)._format : undefined;
this.cleanup();
this.load(src, false, format?.[0]);
}
/**
* Set volume (0-1)
*/
setVolume(volume: number): void {
this.state.volume = Math.max(0, Math.min(1, volume));
if (this.howl && !this.state.isMuted) {
this.howl.volume(this.state.volume);
}
this.emit("volume", { volume: this.state.volume });
}
/**
* Mute/unmute
*/
setMuted(muted: boolean): void {
this.state.isMuted = muted;
if (this.howl) {
this.howl.volume(muted ? 0 : this.state.volume);
}
}
/**
* Get current playback state
*/
getState(): Readonly<HowlerEngineState> {
return { ...this.state };
}
/**
* Get current time (from Howler's state)
*/
getCurrentTime(): number {
if (this.howl) {
const seek = this.howl.seek();
return typeof seek === "number" ? seek : 0;
}
return 0;
}
/**
* Get the ACTUAL current time from the HTML5 audio element
* This is more accurate than Howler's reported position after failed seeks
*/
getActualCurrentTime(): number {
if (!this.howl) return 0;
try {
// Access the underlying HTML5 audio element
const sounds = (this.howl as any)._sounds;
if (sounds && sounds.length > 0 && sounds[0]._node) {
return sounds[0]._node.currentTime || 0;
}
} catch (e) {
// Fallback to Howler's reported time
}
return this.getCurrentTime();
}
/**
* Get duration
*/
getDuration(): number {
return this.howl?.duration() || 0;
}
/**
* Check if currently playing
*/
isPlaying(): boolean {
return this.howl?.playing() || false;
}
/**
* Subscribe to events
*/
on(event: HowlerEventType, callback: HowlerEventCallback): void {
this.eventListeners.get(event)?.add(callback);
}
/**
* Unsubscribe from events
*/
off(event: HowlerEventType, callback: HowlerEventCallback): void {
this.eventListeners.get(event)?.delete(callback);
}
/**
* Emit event to all listeners
*/
private emit(event: HowlerEventType, data?: any): void {
this.eventListeners.get(event)?.forEach((callback) => {
try {
callback(data);
} catch (err) {
console.error(
`[HowlerEngine] Event listener error (${event}):`,
err
);
}
});
}
/**
* Start time update interval
*/
private startTimeUpdates(): void {
this.stopTimeUpdates();
this.timeUpdateInterval = setInterval(() => {
if (this.howl && this.state.isPlaying) {
const seek = this.howl.seek();
if (typeof seek === "number") {
// During a seek operation, ignore timeupdate events that report stale positions
// This prevents the UI flicker where old position briefly shows during seek
if (this.isSeeking && this.seekTargetTime !== null) {
const isNearTarget = Math.abs(seek - this.seekTargetTime) < 2;
if (!isNearTarget) {
// Stale position - don't emit, use target instead
return;
}
// Position is near target, seek completed - clear seek state
this.isSeeking = false;
this.seekTargetTime = null;
if (this.seekTimeoutId) {
clearTimeout(this.seekTimeoutId);
this.seekTimeoutId = null;
}
}
this.state.currentTime = seek;
this.emit("timeupdate", { time: seek });
}
}
}, 250); // Update 4 times per second
}
/**
* Stop time update interval
*/
private stopTimeUpdates(): void {
if (this.timeUpdateInterval) {
clearInterval(this.timeUpdateInterval);
this.timeUpdateInterval = null;
}
}
/**
* Cleanup current Howl instance
*/
private cleanup(): void {
this.stopTimeUpdates();
// Cancel any pending cleanup timeout to prevent race conditions
if (this.cleanupTimeoutId) {
clearTimeout(this.cleanupTimeoutId);
this.cleanupTimeoutId = null;
}
if (this.howl) {
const oldHowl = this.howl;
const wasPlaying = this.state.isPlaying;
const targetVolume = this.state.isMuted ? 0 : this.state.volume;
// Detach immediately so new loads don't race with cleanup.
this.howl = null;
try {
if (wasPlaying) {
// Micro-fade before stop/unload to reduce click/pop artifacts.
oldHowl.fade(targetVolume, 0, this.popFadeMs);
this.cleanupTimeoutId = setTimeout(() => {
this.cleanupTimeoutId = null;
try {
oldHowl.stop();
oldHowl.unload();
} catch {
// ignore
}
}, this.popFadeMs + 2);
} else {
// Synchronous cleanup when not playing - no race condition risk
oldHowl.stop();
oldHowl.unload();
}
} catch {
// Ignore errors during cleanup
}
}
// Note: Removed Howler.unload() - it was unloading ALL audio globally
// which caused issues. Individual howl.unload() calls are sufficient.
this.state.currentSrc = null;
this.state.isPlaying = false;
this.state.currentTime = 0;
this.state.duration = 0;
}
/**
* Destroy the engine completely
*/
destroy(): void {
this.cleanup();
this.isLoading = false;
this.eventListeners.clear();
// Ensure cleanup timeout is cleared
if (this.cleanupTimeoutId) {
clearTimeout(this.cleanupTimeoutId);
this.cleanupTimeoutId = null;
}
// Clear seek state
if (this.seekTimeoutId) {
clearTimeout(this.seekTimeoutId);
this.seekTimeoutId = null;
}
this.isSeeking = false;
this.seekTargetTime = null;
}
}
// Export singleton instance
export const howlerEngine = new HowlerEngine();
// Also export class for testing
export { HowlerEngine };