ziplayer 0.3.6 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/plugins/index.d.ts +1 -8
  2. package/dist/plugins/index.d.ts.map +1 -1
  3. package/dist/plugins/index.js +59 -107
  4. package/dist/plugins/index.js.map +1 -1
  5. package/dist/structures/FilterManager.d.ts +9 -24
  6. package/dist/structures/FilterManager.d.ts.map +1 -1
  7. package/dist/structures/FilterManager.js +182 -93
  8. package/dist/structures/FilterManager.js.map +1 -1
  9. package/dist/structures/Player.d.ts +8 -1
  10. package/dist/structures/Player.d.ts.map +1 -1
  11. package/dist/structures/Player.js +233 -133
  12. package/dist/structures/Player.js.map +1 -1
  13. package/dist/structures/PreloadManager.d.ts +1 -0
  14. package/dist/structures/PreloadManager.d.ts.map +1 -1
  15. package/dist/structures/PreloadManager.js +26 -6
  16. package/dist/structures/PreloadManager.js.map +1 -1
  17. package/dist/structures/Queue.d.ts.map +1 -1
  18. package/dist/structures/Queue.js +4 -0
  19. package/dist/structures/Queue.js.map +1 -1
  20. package/dist/structures/StreamManager.d.ts +8 -0
  21. package/dist/structures/StreamManager.d.ts.map +1 -1
  22. package/dist/structures/StreamManager.js +23 -0
  23. package/dist/structures/StreamManager.js.map +1 -1
  24. package/dist/types/index.d.ts +1 -0
  25. package/dist/types/index.d.ts.map +1 -1
  26. package/dist/types/index.js.map +1 -1
  27. package/package.json +1 -1
  28. package/src/plugins/index.ts +70 -120
  29. package/src/structures/FilterManager.ts +439 -303
  30. package/src/structures/Player.ts +268 -140
  31. package/src/structures/PreloadManager.ts +293 -274
  32. package/src/structures/Queue.ts +5 -0
  33. package/src/structures/StreamManager.ts +585 -563
  34. package/src/types/index.ts +1 -0
@@ -1,274 +1,293 @@
1
- import { createAudioResource, AudioResource } from "@discordjs/voice";
2
- import type { Track, StreamInfo, StreamSlot } from "../types";
3
- import type { StreamManager } from "./StreamManager";
4
-
5
- interface PreloadManagerDeps {
6
- streamManager: StreamManager;
7
- debug: (message?: any, ...optionalParams: any[]) => void;
8
- getNextTrack: () => Track | null;
9
- getStream: (track: Track) => Promise<StreamInfo | null>;
10
- isDestroyed: () => boolean;
11
- isEnabled: () => boolean;
12
- }
13
-
14
- export class PreloadManager {
15
- private readonly streamManager: StreamManager;
16
- private readonly debugLog: (message?: any, ...optionalParams: any[]) => void;
17
- private readonly getNextTrack: () => Track | null;
18
- private readonly getStream: (track: Track) => Promise<StreamInfo | null>;
19
- private readonly isDestroyed: () => boolean;
20
- private readonly isEnabled: () => boolean;
21
-
22
- private preloadLock = false;
23
- private readonly preloadSlot: StreamSlot = {
24
- resource: null,
25
- track: null,
26
- streamId: null,
27
- abortController: null,
28
- isValid: false,
29
- isLoading: false,
30
- loadPromise: null,
31
- };
32
-
33
- constructor(deps: PreloadManagerDeps) {
34
- this.streamManager = deps.streamManager;
35
- this.debugLog = deps.debug;
36
- this.getNextTrack = deps.getNextTrack;
37
- this.getStream = deps.getStream;
38
- this.isDestroyed = deps.isDestroyed;
39
- this.isEnabled = deps.isEnabled;
40
- }
41
-
42
- public hasValidPreload(track: Track): boolean {
43
- return !!(
44
- this.preloadSlot.isValid &&
45
- this.preloadSlot.track?.id === track.id &&
46
- this.preloadSlot.resource &&
47
- this.preloadSlot.resource.playStream?.readable !== false
48
- );
49
- }
50
-
51
- public promoteToCurrent(track: Track, currentSlot: StreamSlot): AudioResource | null {
52
- const promotedResource = this.preloadSlot.resource;
53
- const promotedStreamId = this.preloadSlot.streamId;
54
- if (!promotedResource) return null;
55
-
56
- currentSlot.resource = promotedResource;
57
- currentSlot.track = track;
58
- currentSlot.streamId = promotedStreamId;
59
- currentSlot.abortController = null;
60
- currentSlot.isValid = true;
61
- currentSlot.isLoading = false;
62
- currentSlot.loadPromise = null;
63
-
64
- this.preloadSlot.resource = null;
65
- this.preloadSlot.track = null;
66
- this.preloadSlot.streamId = null;
67
- this.preloadSlot.abortController = null;
68
- this.preloadSlot.isValid = false;
69
- this.preloadSlot.isLoading = false;
70
- this.preloadSlot.loadPromise = null;
71
-
72
- return promotedResource;
73
- }
74
-
75
- public async preloadNextTrack(): Promise<void> {
76
- if (this.isDestroyed()) return;
77
- if (!this.isEnabled()) {
78
- this.debugLog(`[Preload] Disabled by options/runtime profile`);
79
- return;
80
- }
81
-
82
- if (this.preloadLock) {
83
- this.debugLog(`[Preload] Already preloading, skipping`);
84
- return;
85
- }
86
-
87
- const nextTrack = this.getNextTrack();
88
- if (!nextTrack) {
89
- this.debugLog(`[Preload] No next track to preload`);
90
- return;
91
- }
92
-
93
- if (this.preloadSlot.isValid && this.preloadSlot.track?.id === nextTrack.id && this.preloadSlot.resource) {
94
- this.debugLog(`[Preload] Already have valid preload for: ${nextTrack.title}`);
95
- return;
96
- }
97
-
98
- if (this.preloadSlot.isLoading && this.preloadSlot.track?.id === nextTrack.id) {
99
- this.debugLog(`[Preload] Currently loading same track, waiting...`);
100
- if (this.preloadSlot.loadPromise) {
101
- await this.preloadSlot.loadPromise;
102
- }
103
- return;
104
- }
105
-
106
- if (this.preloadSlot.isValid && this.preloadSlot.track?.id !== nextTrack.id) {
107
- this.debugLog(`[Preload] Cancelling old preload for different track: ${this.preloadSlot.track?.title}`);
108
- await this.safeCancelPreload();
109
- }
110
-
111
- this.preloadLock = true;
112
- const abortController = new AbortController();
113
- this.preloadSlot.track = nextTrack;
114
- this.preloadSlot.abortController = abortController;
115
- this.preloadSlot.isLoading = true;
116
-
117
- const loadPromise = this.executePreload(nextTrack, abortController);
118
- this.preloadSlot.loadPromise = loadPromise;
119
-
120
- try {
121
- await loadPromise;
122
- } catch (err) {
123
- if (err instanceof Error && err.message === "PRELOAD_CANCELLED") {
124
- this.debugLog(`[Preload] Cancelled for ${nextTrack.title}`);
125
- } else {
126
- this.debugLog(`[Preload] Failed for ${nextTrack.title}:`, err);
127
- }
128
- this.clearPreloadSlot();
129
- } finally {
130
- this.preloadLock = false;
131
- this.preloadSlot.isLoading = false;
132
- this.preloadSlot.loadPromise = null;
133
- }
134
- }
135
-
136
- public async safeCancelPreload(): Promise<void> {
137
- if (!this.preloadSlot.abortController && !this.preloadSlot.resource) {
138
- return;
139
- }
140
-
141
- this.debugLog(`[Preload] Safely cancelling preload for: ${this.preloadSlot.track?.title || "unknown"}`);
142
-
143
- if (this.preloadSlot.abortController) {
144
- this.preloadSlot.abortController.abort();
145
- this.preloadSlot.abortController = null;
146
- }
147
-
148
- if (this.preloadSlot.streamId) {
149
- this.streamManager.unregisterStream(this.preloadSlot.streamId, true);
150
- }
151
-
152
- if (this.preloadSlot.resource) {
153
- try {
154
- const stream = this.preloadSlot.resource.playStream;
155
- if (stream && typeof stream.destroy === "function" && !stream.destroyed) {
156
- stream.destroy();
157
- }
158
- } catch {
159
- // ignore
160
- }
161
- }
162
-
163
- this.clearPreloadSlot();
164
- }
165
-
166
- public cancelPreload(): void {
167
- if (this.preloadSlot.abortController) {
168
- this.debugLog(`[Preload] Cancelling preload for: ${this.preloadSlot.track?.title}`);
169
- this.preloadSlot.abortController.abort();
170
- }
171
- if (this.preloadSlot.streamId) {
172
- this.streamManager.unregisterStream(this.preloadSlot.streamId, true);
173
- }
174
- this.clearPreloadSlot();
175
- }
176
-
177
- public clearPreloadSlot(): void {
178
- if (this.preloadSlot.resource) {
179
- try {
180
- const stream = this.preloadSlot.resource.playStream;
181
- if (stream && typeof stream.destroy === "function" && !stream.destroyed) {
182
- stream.destroy();
183
- }
184
- } catch {
185
- // ignore
186
- }
187
- }
188
-
189
- if (this.preloadSlot.streamId) {
190
- this.streamManager.unregisterStream(this.preloadSlot.streamId, true);
191
- }
192
-
193
- this.preloadSlot.resource = null;
194
- this.preloadSlot.track = null;
195
- this.preloadSlot.streamId = null;
196
- this.preloadSlot.abortController = null;
197
- this.preloadSlot.isValid = false;
198
- this.preloadSlot.isLoading = false;
199
- this.preloadSlot.loadPromise = null;
200
- }
201
-
202
- private async executePreload(track: Track, abortController: AbortController): Promise<void> {
203
- if (this.isDestroyed()) throw new Error("PLAYER_DESTROYED");
204
- this.debugLog(`[Preload] Starting preload for: ${track.title}`);
205
-
206
- if (abortController.signal.aborted) {
207
- throw new Error("PRELOAD_CANCELLED");
208
- }
209
-
210
- if (this.getNextTrack()?.id !== track.id) {
211
- this.debugLog(`[Preload] Track changed, cancelling`);
212
- throw new Error("PRELOAD_CANCELLED");
213
- }
214
-
215
- const streamInfo = await this.getStreamWithCancel(track, abortController.signal);
216
- if (abortController.signal.aborted) {
217
- throw new Error("PRELOAD_CANCELLED");
218
- }
219
- if (this.getNextTrack()?.id !== track.id) {
220
- this.debugLog(`[Preload] Track changed after stream fetch`);
221
- throw new Error("PRELOAD_CANCELLED");
222
- }
223
- if (!streamInfo?.stream) {
224
- throw new Error(`No stream available`);
225
- }
226
-
227
- const streamId = this.streamManager.registerStream(streamInfo.stream, track, {
228
- source: track.source || "preload",
229
- isPreload: true,
230
- priority: 5,
231
- });
232
-
233
- const resource = createAudioResource(streamInfo.stream, {
234
- inlineVolume: true,
235
- metadata: { ...track, preloaded: true },
236
- });
237
-
238
- if (!resource.playStream || resource.playStream.readable === false) {
239
- throw new Error("Resource not readable");
240
- }
241
-
242
- this.preloadSlot.resource = resource;
243
- this.preloadSlot.streamId = streamId;
244
- this.preloadSlot.isValid = true;
245
- this.preloadSlot.track = track;
246
-
247
- this.debugLog(`[Preload] Successfully preloaded: ${track.title} (Stream ID: ${streamId})`);
248
- }
249
-
250
- private async getStreamWithCancel(track: Track, signal: AbortSignal): Promise<StreamInfo | null> {
251
- if (this.isDestroyed()) throw new Error("PLAYER_DESTROYED");
252
- const abortPromise = new Promise<never>((_, reject) => {
253
- if (signal.aborted) {
254
- reject(new Error("PRELOAD_CANCELLED"));
255
- return;
256
- }
257
- const handler = () => {
258
- signal.removeEventListener("abort", handler);
259
- reject(new Error("PRELOAD_CANCELLED"));
260
- };
261
- signal.addEventListener("abort", handler);
262
- });
263
-
264
- const existingStream = this.streamManager.getStreamByTrack(track.id || track.title);
265
- if (existingStream && !existingStream.destroyed && existingStream.readable !== false) {
266
- this.debugLog(`[Stream] Using existing stream for preload: ${track.title}`);
267
- return { stream: existingStream, type: "arbitrary" };
268
- }
269
-
270
- const streamPromise = this.getStream(track);
271
- const result = await Promise.race([streamPromise, abortPromise]);
272
- return result as StreamInfo | null;
273
- }
274
- }
1
+ import { createAudioResource, AudioResource } from "@discordjs/voice";
2
+ import type { Track, StreamInfo, StreamSlot } from "../types";
3
+ import type { StreamManager } from "./StreamManager";
4
+
5
+ interface PreloadManagerDeps {
6
+ streamManager: StreamManager;
7
+ debug: (message?: any, ...optionalParams: any[]) => void;
8
+ getNextTrack: () => Track | null;
9
+ getStream: (track: Track) => Promise<StreamInfo | null>;
10
+ isDestroyed: () => boolean;
11
+ isEnabled: () => boolean;
12
+ }
13
+
14
+ export class PreloadManager {
15
+ private readonly streamManager: StreamManager;
16
+ private readonly debugLog: (message?: any, ...optionalParams: any[]) => void;
17
+ private readonly getNextTrack: () => Track | null;
18
+ private readonly getStream: (track: Track) => Promise<StreamInfo | null>;
19
+ private readonly isDestroyed: () => boolean;
20
+ private readonly isEnabled: () => boolean;
21
+
22
+ private preloadLock = false;
23
+ private readonly preloadSlot: StreamSlot = {
24
+ resource: null,
25
+ track: null,
26
+ streamId: null,
27
+ abortController: null,
28
+ isValid: false,
29
+ isLoading: false,
30
+ loadPromise: null,
31
+ };
32
+
33
+ constructor(deps: PreloadManagerDeps) {
34
+ this.streamManager = deps.streamManager;
35
+ this.debugLog = deps.debug;
36
+ this.getNextTrack = deps.getNextTrack;
37
+ this.getStream = deps.getStream;
38
+ this.isDestroyed = deps.isDestroyed;
39
+ this.isEnabled = deps.isEnabled;
40
+ }
41
+
42
+ private trackMatches(a: Track | null, b: Track | null): boolean {
43
+ if (!a || !b) return false;
44
+ if (a === b) return true;
45
+ if (a.id !== undefined && b.id !== undefined) return a.id === b.id;
46
+ // At least one id is missing — require url to match too
47
+ return a.url === b.url && a.url !== undefined;
48
+ }
49
+
50
+ public hasValidPreload(track: Track): boolean {
51
+ return !!(
52
+ this.preloadSlot.isValid &&
53
+ this.trackMatches(this.preloadSlot.track, track) &&
54
+ this.preloadSlot.resource &&
55
+ this.preloadSlot.resource.playStream?.readable !== false
56
+ );
57
+ }
58
+
59
+ public promoteToCurrent(track: Track, currentSlot: StreamSlot): AudioResource | null {
60
+ const promotedResource = this.preloadSlot.resource;
61
+ const promotedStreamId = this.preloadSlot.streamId;
62
+ if (!promotedResource) return null;
63
+
64
+ // upgrade stream priority BEFORE clearing the preload slot so
65
+ // that if registerStream for the next preload triggers eviction in the same
66
+ // tick, the promoted stream is already marked high-priority.
67
+ if (promotedStreamId) {
68
+ this.streamManager.updateMetadata(promotedStreamId, {
69
+ isPreload: false,
70
+ priority: 10,
71
+ });
72
+ this.debugLog(`[Preload] Promoted stream ${promotedStreamId} metadata updated to current (priority:10, isPreload:false)`);
73
+ }
74
+
75
+ currentSlot.resource = promotedResource;
76
+ currentSlot.track = track;
77
+ currentSlot.streamId = promotedStreamId;
78
+ currentSlot.abortController = null;
79
+ currentSlot.isValid = true;
80
+ currentSlot.isLoading = false;
81
+ currentSlot.loadPromise = null;
82
+
83
+ this.preloadSlot.resource = null;
84
+ this.preloadSlot.track = null;
85
+ this.preloadSlot.streamId = null;
86
+ this.preloadSlot.abortController = null;
87
+ this.preloadSlot.isValid = false;
88
+ this.preloadSlot.isLoading = false;
89
+ this.preloadSlot.loadPromise = null;
90
+
91
+ return promotedResource;
92
+ }
93
+
94
+ public async preloadNextTrack(): Promise<void> {
95
+ if (this.isDestroyed()) return;
96
+ if (!this.isEnabled()) {
97
+ this.debugLog(`[Preload] Disabled by options/runtime profile`);
98
+ return;
99
+ }
100
+
101
+ if (this.preloadLock) {
102
+ this.debugLog(`[Preload] Already preloading, skipping`);
103
+ return;
104
+ }
105
+
106
+ const nextTrack = this.getNextTrack();
107
+ if (!nextTrack) {
108
+ this.debugLog(`[Preload] No next track to preload`);
109
+ return;
110
+ }
111
+
112
+ if (this.preloadSlot.isValid && this.trackMatches(this.preloadSlot.track, nextTrack) && this.preloadSlot.resource) {
113
+ this.debugLog(`[Preload] Already have valid preload for: ${nextTrack.title}`);
114
+ return;
115
+ }
116
+
117
+ if (this.preloadSlot.isLoading && this.trackMatches(this.preloadSlot.track, nextTrack)) {
118
+ this.debugLog(`[Preload] Currently loading same track, waiting...`);
119
+ if (this.preloadSlot.loadPromise) {
120
+ await this.preloadSlot.loadPromise;
121
+ }
122
+ return;
123
+ }
124
+
125
+ if (this.preloadSlot.isValid && !this.trackMatches(this.preloadSlot.track, nextTrack)) {
126
+ this.debugLog(`[Preload] Cancelling old preload for different track: ${this.preloadSlot.track?.title}`);
127
+ await this.safeCancelPreload();
128
+ }
129
+
130
+ this.preloadLock = true;
131
+ const abortController = new AbortController();
132
+ this.preloadSlot.track = nextTrack;
133
+ this.preloadSlot.abortController = abortController;
134
+ this.preloadSlot.isLoading = true;
135
+
136
+ const loadPromise = this.executePreload(nextTrack, abortController);
137
+ this.preloadSlot.loadPromise = loadPromise;
138
+
139
+ try {
140
+ await loadPromise;
141
+ } catch (err) {
142
+ if (err instanceof Error && err.message === "PRELOAD_CANCELLED") {
143
+ this.debugLog(`[Preload] Cancelled for ${nextTrack.title}`);
144
+ } else {
145
+ this.debugLog(`[Preload] Failed for ${nextTrack.title}:`, err);
146
+ }
147
+ this.clearPreloadSlot();
148
+ } finally {
149
+ this.preloadLock = false;
150
+ this.preloadSlot.isLoading = false;
151
+ this.preloadSlot.loadPromise = null;
152
+ }
153
+ }
154
+
155
+ public async safeCancelPreload(): Promise<void> {
156
+ if (!this.preloadSlot.abortController && !this.preloadSlot.resource) {
157
+ return;
158
+ }
159
+
160
+ this.debugLog(`[Preload] Safely cancelling preload for: ${this.preloadSlot.track?.title || "unknown"}`);
161
+
162
+ if (this.preloadSlot.abortController) {
163
+ this.preloadSlot.abortController.abort();
164
+ this.preloadSlot.abortController = null;
165
+ }
166
+
167
+ if (this.preloadSlot.streamId) {
168
+ this.streamManager.unregisterStream(this.preloadSlot.streamId, true);
169
+ }
170
+
171
+ if (this.preloadSlot.resource) {
172
+ try {
173
+ const stream = this.preloadSlot.resource.playStream;
174
+ if (stream && typeof stream.destroy === "function" && !stream.destroyed) {
175
+ stream.destroy();
176
+ }
177
+ } catch {
178
+ // ignore
179
+ }
180
+ }
181
+
182
+ this.clearPreloadSlot();
183
+ }
184
+
185
+ public cancelPreload(): void {
186
+ if (this.preloadSlot.abortController) {
187
+ this.debugLog(`[Preload] Cancelling preload for: ${this.preloadSlot.track?.title}`);
188
+ this.preloadSlot.abortController.abort();
189
+ }
190
+ if (this.preloadSlot.streamId) {
191
+ this.streamManager.unregisterStream(this.preloadSlot.streamId, true);
192
+ }
193
+ this.clearPreloadSlot();
194
+ }
195
+
196
+ public clearPreloadSlot(): void {
197
+ if (this.preloadSlot.resource) {
198
+ try {
199
+ const stream = this.preloadSlot.resource.playStream;
200
+ if (stream && typeof stream.destroy === "function" && !stream.destroyed) {
201
+ stream.destroy();
202
+ }
203
+ } catch {
204
+ // ignore
205
+ }
206
+ }
207
+
208
+ if (this.preloadSlot.streamId) {
209
+ this.streamManager.unregisterStream(this.preloadSlot.streamId, true);
210
+ }
211
+
212
+ this.preloadSlot.resource = null;
213
+ this.preloadSlot.track = null;
214
+ this.preloadSlot.streamId = null;
215
+ this.preloadSlot.abortController = null;
216
+ this.preloadSlot.isValid = false;
217
+ this.preloadSlot.isLoading = false;
218
+ this.preloadSlot.loadPromise = null;
219
+ }
220
+
221
+ private async executePreload(track: Track, abortController: AbortController): Promise<void> {
222
+ if (this.isDestroyed()) throw new Error("PLAYER_DESTROYED");
223
+ this.debugLog(`[Preload] Starting preload for: ${track.title}`);
224
+
225
+ if (abortController.signal.aborted) {
226
+ throw new Error("PRELOAD_CANCELLED");
227
+ }
228
+
229
+ if (!this.trackMatches(this.getNextTrack(), track)) {
230
+ this.debugLog(`[Preload] Track changed, cancelling`);
231
+ throw new Error("PRELOAD_CANCELLED");
232
+ }
233
+
234
+ const streamInfo = await this.getStreamWithCancel(track, abortController.signal);
235
+ if (abortController.signal.aborted) {
236
+ throw new Error("PRELOAD_CANCELLED");
237
+ }
238
+ if (!this.trackMatches(this.getNextTrack(), track)) {
239
+ this.debugLog(`[Preload] Track changed after stream fetch`);
240
+ throw new Error("PRELOAD_CANCELLED");
241
+ }
242
+ if (!streamInfo?.stream) {
243
+ throw new Error(`No stream available`);
244
+ }
245
+
246
+ const streamId = this.streamManager.registerStream(streamInfo.stream, track, {
247
+ source: track.source || "preload",
248
+ isPreload: true,
249
+ priority: 5,
250
+ });
251
+
252
+ const resource = createAudioResource(streamInfo.stream, {
253
+ inlineVolume: true,
254
+ metadata: { ...track, preloaded: true },
255
+ });
256
+
257
+ if (!resource.playStream || resource.playStream.readable === false) {
258
+ throw new Error("Resource not readable");
259
+ }
260
+
261
+ this.preloadSlot.resource = resource;
262
+ this.preloadSlot.streamId = streamId;
263
+ this.preloadSlot.isValid = true;
264
+ this.preloadSlot.track = track;
265
+
266
+ this.debugLog(`[Preload] Successfully preloaded: ${track.title} (Stream ID: ${streamId})`);
267
+ }
268
+
269
+ private async getStreamWithCancel(track: Track, signal: AbortSignal): Promise<StreamInfo | null> {
270
+ if (this.isDestroyed()) throw new Error("PLAYER_DESTROYED");
271
+ const abortPromise = new Promise<never>((_, reject) => {
272
+ if (signal.aborted) {
273
+ reject(new Error("PRELOAD_CANCELLED"));
274
+ return;
275
+ }
276
+ const handler = () => {
277
+ signal.removeEventListener("abort", handler);
278
+ reject(new Error("PRELOAD_CANCELLED"));
279
+ };
280
+ signal.addEventListener("abort", handler);
281
+ });
282
+
283
+ const existingStream = this.streamManager.getStreamByTrack(track.id || track.title);
284
+ if (existingStream && !existingStream.destroyed && existingStream.readable !== false) {
285
+ this.debugLog(`[Stream] Using existing stream for preload: ${track.title}`);
286
+ return { stream: existingStream, type: "arbitrary" };
287
+ }
288
+
289
+ const streamPromise = this.getStream(track);
290
+ const result = await Promise.race([streamPromise, abortPromise]);
291
+ return result as StreamInfo | null;
292
+ }
293
+ }
@@ -231,6 +231,11 @@ export class Queue {
231
231
  this.current = this.tracks.shift() || null;
232
232
  }
233
233
 
234
+ // Skip bypassed track loop but no other track exists → restore current from history
235
+ if (!this.current && this._loop === "track" && ignoreLoop && this.history.length > 0) {
236
+ this.current = this.history.pop() || null;
237
+ }
238
+
234
239
  return this.current;
235
240
  }
236
241