1/** 2 * Global Audio Manager 3 * Coordinates multiple audio players, ensures only one plays at a time 4 * Provides state for the transport bar 5 */ 6 7class AudioManager { 8 constructor() { 9 this.players = {}; // Registered players by name 10 this.activePlayerName = null; 11 this.audio = new Audio(); 12 this.currentPlaylist = []; 13 this.currentIndex = 0; 14 this.isPlaying = false; 15 this.waveformData = null; 16 17 // Callbacks for UI updates (arrays to support multiple listeners) 18 this._stateChangeListeners = []; 19 this._timeUpdateListeners = []; 20 this._playlistChangeListeners = []; 21 22 // Legacy callbacks for transport bar 23 this.onStateChange = () => {}; 24 this.onTimeUpdate = () => {}; 25 this.onPlaylistChange = () => {}; 26 27 this.bindAudioEvents(); 28 } 29 30 bindAudioEvents() { 31 this.audio.addEventListener('timeupdate', () => { 32 const time = { 33 currentTime: this.audio.currentTime, 34 duration: this.audio.duration || 0 35 }; 36 this.onTimeUpdate(time); 37 this._notifyTimeListeners(time); 38 }); 39 40 this.audio.addEventListener('ended', () => { 41 this.next(); 42 }); 43 44 this.audio.addEventListener('play', () => { 45 this.isPlaying = true; 46 this.notifyStateChange(); 47 }); 48 49 this.audio.addEventListener('pause', () => { 50 this.isPlaying = false; 51 this.notifyStateChange(); 52 }); 53 54 this.audio.addEventListener('error', (e) => { 55 console.error('Audio error:', this.audio.error); 56 this.isPlaying = false; 57 this.notifyStateChange(); 58 }); 59 60 this.audio.addEventListener('loadedmetadata', () => { 61 this.notifyStateChange(); 62 this.onTimeUpdate({ 63 currentTime: this.audio.currentTime, 64 duration: this.audio.duration || 0 65 }); 66 }); 67 } 68 69 /** 70 * Register a player with its playlist 71 * @param {string} name - Unique identifier for this player 72 * @param {Array} playlist - Array of {title, src} objects 73 * @param {Object} options - Optional settings including artist, year 74 */ 75 register(name, playlist, options = {}) { 76 // Preserve existing waveforms if player already registered 77 const existingWaveforms = this.players[name]?.waveforms || {}; 78 this.players[name] = { 79 name, 80 playlist, 81 options, 82 artist: options.artist || null, 83 year: options.year || null, 84 waveforms: existingWaveforms 85 }; 86 this.onPlaylistChange(this.getPlaylists()); 87 } 88 89 /** 90 * Unregister a player (e.g., when navigating away) 91 */ 92 unregister(name) { 93 // If this was the active player, stop playback 94 if (this.activePlayerName === name) { 95 this.pause(); 96 this.activePlayerName = null; 97 this.currentPlaylist = []; 98 } 99 delete this.players[name]; 100 this.onPlaylistChange(this.getPlaylists()); 101 } 102 103 /** 104 * Clear all players (called on navigation) 105 */ 106 clearPlayers() { 107 // Don't stop playback, just clear registrations 108 // This allows audio to continue if same playlist exists on new page 109 const wasActive = this.activePlayerName; 110 const wasPlaying = this.isPlaying; 111 const currentSrc = this.audio.src; 112 const currentTime = this.audio.currentTime; 113 114 // Destroy player instances to clean up listeners 115 Object.values(this.players).forEach(player => { 116 if (player.options?.player?.destroy) { 117 player.options.player.destroy(); 118 } 119 }); 120 121 this.players = {}; 122 123 // Store state for potential reconnection 124 this._pendingReconnect = { 125 playerName: wasActive, 126 src: currentSrc, 127 time: currentTime, 128 playing: wasPlaying 129 }; 130 131 // Notify transport bar that playlists changed 132 this.onPlaylistChange(this.getPlaylists()); 133 } 134 135 /** 136 * Try to reconnect to a player after navigation 137 */ 138 tryReconnect(name) {
139 if (!this._pendingReconnect) return false; 140 if (this._pendingReconnect.playerName !== name) return false; 141 142 const player = this.players[name]; 143 if (!player) return false; 144 145 // Check if any track in the playlist matches 146 const matchIndex = player.playlist.findIndex( 147 track => this._pendingReconnect.src.includes(track.src) 148 ); 149 150 if (matchIndex >= 0) { 151 this.activePlayerName = name; 152 this.currentPlaylist = player.playlist; 153 this.currentIndex = matchIndex; 154 this._pendingReconnect = null; 155 this.notifyStateChange(); 156 return true; 157 } 158 159 this._pendingReconnect = null; 160 return false; 161 } 162 163 /** 164 * Get list of all registered playlists 165 */ 166 getPlaylists() { 167 return Object.keys(this.players).map(name => ({ 168 name, 169 trackCount: this.players[name].playlist.length, 170 isActive: name === this.activePlayerName 171 })); 172 } 173 174 /** 175 * Switch to a different playlist 176 */ 177 setActivePlaylist(name) { 178 const player = this.players[name]; 179 if (!player) return; 180 181 this.activePlayerName = name; 182 this.currentPlaylist = player.playlist; 183 this.loadTrack(0); 184 this.notifyStateChange(); 185 } 186 187 /** 188 * Start playing a specific player's playlist 189 */ 190 playPlaylist(name, startIndex = 0) { 191 const player = this.players[name]; 192 if (!player) return; 193 194 this.activePlayerName = name; 195 this.currentPlaylist = player.playlist; 196 this.loadTrack(startIndex); 197 this.play(); 198 } 199 200 loadTrack(index) { 201 if (index < 0 || index >= this.currentPlaylist.length) return; 202 203 this.currentIndex = index; 204 const track = this.currentPlaylist[index]; 205 this.audio.src = track.src; 206 this.notifyStateChange(); 207 } 208 209 play() { 210 if (this.currentPlaylist.length === 0) return; 211 212 // Pause any playing videos 213 if (window.videoManager) { 214 window.videoManager.pauseAll(); 215 } 216 217 const playPromise = this.audio.play(); 218 if (playPromise !== undefined) { 219 playPromise.catch(error => { 220 console.error('Play error:', error); 221 this.isPlaying = false; 222 this.notifyStateChange(); 223 }); 224 } 225 } 226 227 pause() { 228 this.audio.pause(); 229 } 230 231 togglePlay() { 232 if (this.isPlaying) { 233 this.pause(); 234 } else { 235 this.play(); 236 } 237 } 238 239 next() { 240 if (this.currentIndex < this.currentPlaylist.length - 1) { 241 this.loadTrack(this.currentIndex + 1); 242 this.play(); 243 } else { 244 // Loop to beginning but don't auto-play 245 this.loadTrack(0); 246 this.pause(); 247 } 248 } 249 250 previous() { 251 // If more than 3 seconds in, restart current track 252 if (this.audio.currentTime > 3) { 253 this.audio.currentTime = 0; 254 } else if (this.currentIndex > 0) { 255 this.loadTrack(this.currentIndex - 1); 256 if (this.isPlaying) this.play(); 257 } 258 } 259 260 seek(percent) { 261 if (this.audio.duration) { 262 this.audio.currentTime = percent * this.audio.duration; 263 } 264 } 265 266 getCurrentTrack() { 267 if (this.currentPlaylist.length === 0) return null; 268 return this.currentPlaylist[this.currentIndex]; 269 } 270 271 getState() { 272 const player = this.activePlayerName ? this.players[this.activePlayerName] : null; 273 return { 274 isPlaying: this.isPlaying, 275 currentTrack: this.getCurrentTrack(), 276 currentIndex: this.currentIndex, 277 playlistName: this.activePlayerName, 278 playlistLength: this.currentPlaylist.length, 279 currentTime: this.audio.currentTime, 280 duration: this.audio.duration || 0, 281 artist: player?.artist || null, 282 year: player?.year || null 283 }; 284 } 285 286 notifyStateChange() {
287 const state = this.getState(); 288 this.onStateChange(state); 289 this._notifyStateListeners(state); 290 } 291 292 /** 293 * Store waveform data for a track 294 */ 295 setWaveform(playerName, trackIndex, data) { 296 if (this.players[playerName]) { 297 this.players[playerName].waveforms[trackIndex] = data; 298 } 299 } 300 301 getWaveform(playerName, trackIndex) { 302 return this.players[playerName]?.waveforms[trackIndex] || null; 303 } 304 305 getCurrentWaveform() { 306 if (!this.activePlayerName) return null; 307 return this.getWaveform(this.activePlayerName, this.currentIndex); 308 } 309 310 /** 311 * Get active playlist with full track data for the transport dropdown 312 */ 313 getActivePlaylistData() { 314 if (!this.activePlayerName) return null; 315 316 // Try to get from registered players first 317 const playerData = this.getPlaylistData(this.activePlayerName); 318 if (playerData) return playerData; 319 320 // Fall back to cached data if player was cleared but audio is still playing 321 if (this._cachedActivePlaylist && this._cachedActivePlaylist.name === this.activePlayerName) { 322 return { 323 ...this._cachedActivePlaylist, 324 currentIndex: this.currentIndex 325 }; 326 } 327 328 return null; 329 } 330 331 /** 332 * Get playlist data by name 333 */ 334 getPlaylistData(name) { 335 const player = this.players[name]; 336 if (!player) return null; 337 338 const data = { 339 name: player.name, 340 artist: player.artist, 341 year: player.year, 342 tracks: player.playlist, 343 currentIndex: name === this.activePlayerName ? this.currentIndex : -1 344 }; 345 346 // Cache active playlist data for when player is cleared 347 if (name === this.activePlayerName) { 348 this._cachedActivePlaylist = { 349 name: player.name, 350 artist: player.artist, 351 year: player.year, 352 tracks: player.playlist 353 }; 354 } 355 356 return data; 357 } 358 359 /** 360 * Add a state change listener (returns unsubscribe function) 361 */ 362 addStateListener(callback) { 363 this._stateChangeListeners.push(callback); 364 return () => { 365 const idx = this._stateChangeListeners.indexOf(callback); 366 if (idx > -1) this._stateChangeListeners.splice(idx, 1); 367 }; 368 } 369 370 /** 371 * Add a time update listener (returns unsubscribe function) 372 */ 373 addTimeListener(callback) { 374 this._timeUpdateListeners.push(callback); 375 return () => { 376 const idx = this._timeUpdateListeners.indexOf(callback); 377 if (idx > -1) this._timeUpdateListeners.splice(idx, 1); 378 }; 379 } 380 381 /** 382 * Notify all state listeners 383 */ 384 _notifyStateListeners(state) { 385 this._stateChangeListeners.forEach(cb => cb(state)); 386 } 387 388 /** 389 * Notify all time listeners 390 */ 391 _notifyTimeListeners(time) { 392 this._timeUpdateListeners.forEach(cb => cb(time)); 393 } 394} 395 396// Global singleton 397window.audioManager = new AudioManager();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.