Skip to content

Commit 65ac413

Browse files
committed
fixes, version bump
1 parent fd5d91e commit 65ac413

File tree

6 files changed

+222
-83
lines changed

6 files changed

+222
-83
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
package com.craftmend.openaudiomc.generic.utils;
2+
3+
public class NamedExecutors {
4+
}

client/src/client/medialib/MediaChannel.js

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ export class MediaChannel {
1616
this._pendingRemoveFinalizer = null;
1717
this._isDestroying = false;
1818
this._engine = null; // set by MediaEngine.ensureChannel
19+
this.playlistData = null; // { sources: [...], loop: bool, lastIndex: number }
20+
}
21+
22+
setPlaylistData(data) {
23+
this.playlistData = data;
1924
}
2025

2126
setTag(tag) {
@@ -32,14 +37,17 @@ export class MediaChannel {
3237
addTrack(track) {
3338
this.tracks.set(track.id, track);
3439
// If a non-looping track ends, auto-remove the channel if this was the last track
35-
try {
36-
track.onEnded(() => {
37-
this.tracks.delete(track.id);
38-
if (this.tracks.size === 0 && this._engine) {
39-
this._engine.removeChannel(this.id);
40-
}
41-
});
42-
} catch (e) { /* ignore */ }
40+
// UNLESS this is a playlist, which manages its own track transitions
41+
if (!this.playlistData) {
42+
try {
43+
track.onEnded(() => {
44+
this.tracks.delete(track.id);
45+
if (this.tracks.size === 0 && this._engine) {
46+
this._engine.removeChannel(this.id);
47+
}
48+
});
49+
} catch (e) { /* ignore */ }
50+
}
4351
this.updateVolumeFromMaster();
4452
}
4553

client/src/client/medialib/MediaTrack.js

Lines changed: 8 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { TimeService } from '../services/time/TimeService';
2-
import { AudioSourceProcessor } from '../util/AudioSourceProcessor';
32

43
export class MediaTrack {
54
constructor({
@@ -19,7 +18,6 @@ export class MediaTrack {
1918
this.startInstant = startInstant || null;
2019
this.speedPct = speedPct || 100;
2120
this.muted = !!muted;
22-
this.sourceRewriter = new AudioSourceProcessor();
2321

2422
this.audio = providedAudio || new Audio();
2523
// Prefer eager metadata loading so we can compute duration/start offsets early
@@ -34,8 +32,6 @@ export class MediaTrack {
3432
console.warn('Replacing audio src', this.audio.src, 'with', source);
3533
this.audio.src = source;
3634
}
37-
38-
// this is not supported enough
3935
this.audio.loop = !!loop;
4036

4137
this.epoch = 0;
@@ -46,33 +42,6 @@ export class MediaTrack {
4642
this._handlers = { ended: null, error: null };
4743
}
4844

49-
setPlaylist(playlistArray) {
50-
if (this.audio.loop) {
51-
this.audio.loop = false;
52-
this.currentSongIndex = playlistArray.length - 1;
53-
this.audio.addEventListener('ended', () => {
54-
let nextIndex = this.currentSongIndex + 1;
55-
if (nextIndex >= playlistArray.length) {
56-
if (this.loop) {
57-
nextIndex = 0;
58-
this.currentSongIndex = 0;
59-
} else {
60-
return;
61-
}
62-
}
63-
this.currentSongIndex = nextIndex;
64-
65-
const nextSource = playlistArray[nextIndex];
66-
this.sourceRewriter.translate(nextSource).then((translatedSrc) => {
67-
// have we not been stopped/destroyed in the meantime?
68-
if (this.state === 'destroyed' || this.state === 'stopped') return;
69-
this.audio.src = translatedSrc;
70-
this.audio.play();
71-
});
72-
});
73-
}
74-
}
75-
7645
setTimerInterval(fn, ms) {
7746
const id = setInterval(fn, ms);
7847
this.timers.add(id);
@@ -118,6 +87,8 @@ export class MediaTrack {
11887
}
11988

12089
onEnded(cb) {
90+
// eslint-disable-next-line no-console
91+
console.log(`[MediaTrack ${this.id}] Adding onEnded callback, total: ${this.onFinish.size + 1}`);
12192
this.onFinish.add(cb);
12293
return () => this.onFinish.delete(cb);
12394
}
@@ -132,12 +103,14 @@ export class MediaTrack {
132103
const onErr = endGuard(() => {
133104
});
134105
const onEnd = endGuard(() => {
135-
if (this.loop) return;
106+
// eslint-disable-next-line no-console
107+
console.log(`[MediaTrack ${this.id}] Audio ended event fired, calling ${this.onFinish.size} callbacks`);
136108
this.onFinish.forEach((cb) => {
137109
try {
138110
cb();
139111
} catch (e) {
140-
/* ignore */
112+
// eslint-disable-next-line no-console
113+
console.error(`[MediaTrack ${this.id}] Error in onFinish callback:`, e);
141114
}
142115
});
143116
});
@@ -240,6 +213,8 @@ export class MediaTrack {
240213
}
241214
// Do not clear src on stop to avoid MEDIA_ELEMENT_ERROR: Empty src attribute
242215
// Fire finish callbacks so channels can clean up non-looping tracks deterministically
216+
// eslint-disable-next-line no-console
217+
console.log(`[MediaTrack ${this.id}] stop() called, firing ${this.onFinish.size} callbacks`);
243218
try {
244219
this.onFinish.forEach((cb) => {
245220
try {

client/src/client/services/socket/handlers/HandleCreateMedia.jsx

Lines changed: 193 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)