PageSourceSearch

https://norelcomori.com/audio-manager.js

js norelcomori.com collected 2026-09-24 20:15:48 UTC 11,514 bytes, 397 lines download raw bytes

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.