@@ -13,10 +13,133 @@ export async function handleCreateMedia(data) {
1313 return Math . round ( ( ( maxDistance - currentDistance ) / maxDistance ) * 100 ) ;
1414 }
1515
16+ // Helper function to select a random index excluding a specific index (unless impossible)
17+ function getRandomIndex ( arrayLength , excludeIndex = - 1 ) {
18+ if ( arrayLength === 1 ) return 0 ;
19+ if ( excludeIndex < 0 || excludeIndex >= arrayLength ) {
20+ return Math . floor ( Math . random ( ) * arrayLength ) ;
21+ }
22+ // Get random index that's not the excluded one
23+ const availableIndices = [ ] ;
24+ for ( let i = 0 ; i < arrayLength ; i ++ ) {
25+ if ( i !== excludeIndex ) availableIndices . push ( i ) ;
26+ }
27+ return availableIndices [ Math . floor ( Math . random ( ) * availableIndices . length ) ] ;
28+ }
29+
30+ // Helper function to play next track from playlist (for looping playlists)
31+ async function playNextFromPlaylist ( channel , engine , id , fadeTime , muteRegions , muteSpeakers , startInstant , startAtMillis , speed ) {
32+ // eslint-disable-next-line no-console
33+ console . log ( `[Playlist ${ id } ] playNextFromPlaylist called` ) ;
34+ const { playlistData } = channel ;
35+ if ( ! playlistData || ! playlistData . loop ) {
36+ // eslint-disable-next-line no-console
37+ console . log ( `[Playlist ${ id } ] No playlist data or not looping, exiting` ) ;
38+ return ;
39+ }
40+
41+ // eslint-disable-next-line no-console
42+ console . log ( `[Playlist ${ id } ] Acquiring mutex...` ) ;
43+ await MEDIA_MUTEX . lock ( ) ;
44+ // eslint-disable-next-line no-console
45+ console . log ( `[Playlist ${ id } ] Mutex acquired` ) ;
46+ try {
47+ // Select next random track (avoiding the last one if possible)
48+ const nextIndex = getRandomIndex ( playlistData . sources . length , playlistData . lastIndex ) ;
49+ playlistData . lastIndex = nextIndex ;
50+ const nextSource = playlistData . sources [ nextIndex ] ;
51+
52+ // eslint-disable-next-line no-console
53+ console . log ( `Playlist transition: playing track ${ nextIndex } from playlist` ) ;
54+
55+ // Preload next track
56+ let preloaded ;
57+ try {
58+ preloaded = await AudioPreloader . getResource ( nextSource , false , true ) ;
59+ } catch ( e ) {
60+ // eslint-disable-next-line no-console
61+ console . error ( `Failed to load next playlist track from ${ nextSource } ` , e ) ;
62+ MEDIA_MUTEX . unlock ( ) ;
63+ return ;
64+ }
65+
66+ // Create new track for the next source
67+ // IMPORTANT: Don't use the original startInstant/startAtMillis for transitions!
68+ // Those are for syncing the FIRST track. Subsequent tracks should start from 0.
69+ const track = new MediaTrack ( {
70+ id : `${ id } ::${ nextIndex } ` ,
71+ source : nextSource ,
72+ audio : preloaded ,
73+ loop : false , // Individual tracks don't loop; playlist manages transitions
74+ startAtMillis : 0 , // Start from beginning, not the original offset
75+ startInstant : null , // No sync timestamp for subsequent tracks
76+ } ) ;
77+
78+ if ( speed != null && speed !== 1 && speed !== 0 ) track . setPlaybackSpeed ( speed ) ;
79+
80+ // Set up end handler for continuous looping
81+ // eslint-disable-next-line no-console
82+ console . log ( `[Playlist ${ id } ] Setting up onEnded handler for track ${ nextIndex } ` ) ;
83+ track . onEnded ( ( ) => {
84+ // eslint-disable-next-line no-console
85+ console . log ( `[Playlist ${ id } ] Track ${ nextIndex } ended, calling playNextFromPlaylist` ) ;
86+ playNextFromPlaylist ( channel , engine , id , fadeTime , muteRegions , muteSpeakers , startInstant , startAtMillis , speed ) ;
87+ } ) ;
88+
89+ // Remove old track and add new one
90+ const oldTracks = Array . from ( channel . tracks . values ( ) ) ;
91+ // eslint-disable-next-line no-console
92+ console . log ( `[Playlist ${ id } ] Found ${ oldTracks . length } old tracks to clean up` ) ;
93+
94+ // CRITICAL: Clear callbacks from old tracks FIRST, before any channel operations
95+ // because removing from channel or adding new track can trigger stop() which fires callbacks
96+ // eslint-disable-next-line no-console
97+ console . log ( `[Playlist ${ id } ] Clearing callbacks from ${ oldTracks . length } old tracks BEFORE removal` ) ;
98+ oldTracks . forEach ( ( t ) => {
99+ // eslint-disable-next-line no-console
100+ console . log ( `[Playlist ${ id } ] Clearing ${ t . onFinish . size } callbacks from track ${ t . id } ` ) ;
101+ t . onFinish . clear ( ) ;
102+ } ) ;
103+
104+ // eslint-disable-next-line no-console
105+ console . log ( `[Playlist ${ id } ] Removing ${ oldTracks . length } old tracks from channel` ) ;
106+ oldTracks . forEach ( ( t ) => {
107+ // eslint-disable-next-line no-console
108+ console . log ( `[Playlist ${ id } ] Removing track ${ t . id } from channel` ) ;
109+ channel . tracks . delete ( t . id ) ;
110+ } ) ;
111+ // eslint-disable-next-line no-console
112+ console . log ( `[Playlist ${ id } ] Adding new track ${ track . id } to channel` ) ;
113+ channel . addTrack ( track ) ;
114+
115+ // Start playback
116+ // eslint-disable-next-line no-console
117+ console . log ( `[Playlist ${ id } ] Starting playback of track ${ nextIndex } ` ) ;
118+ await track . play ( ) ;
119+
120+ // Clean up old tracks after new one starts
121+ // Callbacks already cleared above before channel operations
122+ // eslint-disable-next-line no-console
123+ console . log ( `[Playlist ${ id } ] Destroying ${ oldTracks . length } old tracks` ) ;
124+ oldTracks . forEach ( ( t ) => {
125+ try {
126+ // eslint-disable-next-line no-console
127+ console . log ( `[Playlist ${ id } ] Destroying track ${ t . id } ` ) ;
128+ t . destroy ( ) ;
129+ } catch ( e ) {
130+ // eslint-disable-next-line no-console
131+ console . error ( `[Playlist ${ id } ] Error destroying track ${ t . id } :` , e ) ;
132+ }
133+ } ) ;
134+ } finally {
135+ MEDIA_MUTEX . unlock ( ) ;
136+ }
137+ }
138+
16139 const looping = data . media . loop ;
17140 const { startInstant } = data . media ;
18141 const id = data . media . mediaId ;
19- let { source } = data . media ;
142+ const { source } = data . media ;
20143 const { doPickup } = data . media ;
21144 const { fadeTime } = data . media ;
22145 const { distance } = data ;
@@ -28,22 +151,54 @@ export async function handleCreateMedia(data) {
28151 let volume = 100 ;
29152
30153 await MEDIA_MUTEX . lock ( ) ;
31- const initialSource = source ;
32- const isPlaylist = source . startsWith ( '[' ) && source . endsWith ( ']' ) ;
33154
34- source = await sourceRewriter . translate ( source ) ;
155+ // Detect and handle playlists BEFORE translation
156+ let isPlaylist = false ;
157+ let playlistSources = null ;
158+ let selectedSource = source ;
35159
36- let preloaded ;
37- if ( ! isPlaylist ) {
160+ if ( typeof source === 'string' && source . startsWith ( '[' ) && source . endsWith ( ']' ) ) {
38161 try {
39- preloaded = await AudioPreloader . getResource ( source , false , true ) ;
162+ const rawSources = JSON . parse ( source ) ;
163+ if ( Array . isArray ( rawSources ) && rawSources . length > 0 ) {
164+ isPlaylist = true ;
165+ // eslint-disable-next-line no-console
166+ console . log ( `Detected playlist with ${ rawSources . length } sources` ) ;
167+
168+ // Translate each source in the playlist
169+ playlistSources = await Promise . all ( rawSources . map ( ( rawSrc ) => sourceRewriter . translate ( rawSrc ) ) ) ;
170+
171+ // Select random initial track
172+ const initialIndex = getRandomIndex ( playlistSources . length ) ;
173+ selectedSource = playlistSources [ initialIndex ] ;
174+ // eslint-disable-next-line no-console
175+ console . log ( `Selected initial playlist track ${ initialIndex } : ${ selectedSource } ` ) ;
176+ }
40177 } catch ( e ) {
41- console . error ( `Failed to load audio from ${ source } ` , e ) ;
42- MEDIA_MUTEX . unlock ( ) ;
43- return ;
178+ // eslint-disable-next-line no-console
179+ console . error ( 'Failed to parse playlist, treating as single source' , e ) ;
180+ isPlaylist = false ;
44181 }
45182 }
46183
184+ // Translate single source if not a playlist
185+ if ( ! isPlaylist ) {
186+ selectedSource = await sourceRewriter . translate ( source ) ;
187+ }
188+
189+ // eslint-disable-next-line no-console
190+ console . log ( `Translated source to ${ selectedSource } ` ) ;
191+
192+ let preloaded ;
193+ try {
194+ preloaded = await AudioPreloader . getResource ( selectedSource , false , true ) ;
195+ } catch ( e ) {
196+ // eslint-disable-next-line no-console
197+ console . error ( `Failed to load audio from ${ selectedSource } ` , e ) ;
198+ MEDIA_MUTEX . unlock ( ) ;
199+ return ;
200+ }
201+
47202 // only if its a new version and provided, then use that volume
48203 if ( data . media . volume != null ) {
49204 volume = data . media . volume ;
@@ -57,6 +212,16 @@ export async function handleCreateMedia(data) {
57212 const newChannel = engine . ensureChannel ( id , volume ) ;
58213 newChannel . setTag ( id ) ;
59214
215+ // Store playlist data on channel if this is a playlist
216+ if ( isPlaylist && playlistSources ) {
217+ const initialIndex = playlistSources . indexOf ( selectedSource ) ;
218+ newChannel . setPlaylistData ( {
219+ sources : playlistSources ,
220+ loop : looping ,
221+ lastIndex : initialIndex ,
222+ } ) ;
223+ }
224+
60225 // Use the same fadeTime as the media to crossfade regions/speakers
61226 if ( muteRegions ) { debugLog ( 'Incrementing region inhibit' ) ; MediaManager . engine . incrementInhibitor ( 'REGION' , fadeTime ) ; }
62227 if ( muteSpeakers ) { debugLog ( 'Incrementing speaker inhibit' ) ; MediaManager . engine . incrementInhibitor ( 'SPEAKER' , fadeTime ) ; }
@@ -66,7 +231,7 @@ export async function handleCreateMedia(data) {
66231 // eslint-disable-next-line no-console
67232 console . log ( `Channel ${ id } finished, removing inhibitors` ) ;
68233 try {
69- await MEDIA_MUTEX . unlock ( ) ;
234+ await MEDIA_MUTEX . lock ( ) ;
70235 if ( muteRegions ) MediaManager . engine . decrementInhibitor ( 'REGION' , fadeTime ) ;
71236 if ( muteSpeakers ) MediaManager . engine . decrementInhibitor ( 'SPEAKER' , fadeTime ) ;
72237 } finally {
@@ -77,20 +242,31 @@ export async function handleCreateMedia(data) {
77242 newChannel . setTag ( flag ) ;
78243 // Preload audio element and create track
79244 const track = new MediaTrack ( {
80- id : `${ id } ::0` , source, audio : preloaded , loop : looping , startAtMillis, startInstant,
245+ id : `${ id } ::0` ,
246+ source : selectedSource ,
247+ audio : preloaded ,
248+ loop : isPlaylist ? false : looping , // Playlists don't loop individual tracks
249+ startAtMillis,
250+ startInstant,
81251 } ) ;
82252
83- if ( isPlaylist ) {
84- track . setPlaylist ( JSON . parse ( initialSource ) ) ;
85- }
86-
87253 if ( speed != null && speed !== 1 && speed !== 0 ) track . setPlaybackSpeed ( speed ) ;
88254 newChannel . addTrack ( track ) ;
89- if ( ! looping ) {
255+
256+ // Handle track end based on playlist mode
257+ if ( isPlaylist && looping ) {
258+ // Looping playlist: play next track on end
259+ track . onEnded ( ( ) => {
260+ playNextFromPlaylist ( newChannel , engine , id , fadeTime , muteRegions , muteSpeakers , startInstant , startAtMillis , speed ) ;
261+ } ) ;
262+ } else if ( ! isPlaylist && ! looping ) {
263+ // Non-looping single track: remove channel on end
90264 track . onEnded ( ( ) => {
91265 if ( MediaManager . engine ) MediaManager . engine . removeChannel ( id ) ;
92266 } ) ;
93267 }
268+ // If isPlaylist && !looping, track ends naturally and channel cleans up
269+ // If !isPlaylist && looping, track.loop is true so it loops internally
94270
95271 newChannel . setChannelVolumePct ( 0 ) ;
96272 // convert distance
0 commit comments