yukimu 1.0.0

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 (48) hide show
  1. package/.cache/replit/modules/nodejs-20.res +1 -0
  2. package/.cache/replit/modules/replit.res +1 -0
  3. package/.cache/replit/modules.stamp +0 -0
  4. package/.cache/replit/nix/dotreplitenv.json +1 -0
  5. package/.cache/replit/toolchain.json +1 -0
  6. package/.local/state/workflow-logs/7zVU0iVo-fBL1ccMCmELy/configure_your_app.packager.installForAll.0 +9 -0
  7. package/.local/state/workflow-logs/7zVU0iVo-fBL1ccMCmELy/configure_your_app.shell.exec.1 +1 -0
  8. package/.local/state/workflow-logs/U0AinJQVHonnwGjj0RXLn/configure_your_app.packager.installForAll.0 +2 -0
  9. package/.replit +4 -0
  10. package/.upm/store.json +1 -0
  11. package/README.md +152 -0
  12. package/dist/Node.d.ts +32 -0
  13. package/dist/Node.d.ts.map +1 -0
  14. package/dist/Node.js +186 -0
  15. package/dist/Node.js.map +1 -0
  16. package/dist/Player.d.ts +63 -0
  17. package/dist/Player.d.ts.map +1 -0
  18. package/dist/Player.js +205 -0
  19. package/dist/Player.js.map +1 -0
  20. package/dist/Queue.d.ts +29 -0
  21. package/dist/Queue.d.ts.map +1 -0
  22. package/dist/Queue.js +75 -0
  23. package/dist/Queue.js.map +1 -0
  24. package/dist/Resolver.d.ts +30 -0
  25. package/dist/Resolver.d.ts.map +1 -0
  26. package/dist/Resolver.js +121 -0
  27. package/dist/Resolver.js.map +1 -0
  28. package/dist/Yukimu.d.ts +59 -0
  29. package/dist/Yukimu.d.ts.map +1 -0
  30. package/dist/Yukimu.js +135 -0
  31. package/dist/Yukimu.js.map +1 -0
  32. package/dist/index.d.ts +7 -0
  33. package/dist/index.d.ts.map +1 -0
  34. package/dist/index.js +29 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/types.d.ts +137 -0
  37. package/dist/types.d.ts.map +1 -0
  38. package/dist/types.js +15 -0
  39. package/dist/types.js.map +1 -0
  40. package/package.json +24 -0
  41. package/src/Node.ts +326 -0
  42. package/src/Player.ts +245 -0
  43. package/src/Queue.ts +82 -0
  44. package/src/Resolver.ts +127 -0
  45. package/src/Yukimu.ts +177 -0
  46. package/src/index.ts +6 -0
  47. package/src/types.ts +178 -0
  48. package/tsconfig.json +18 -0
package/src/Node.ts ADDED
@@ -0,0 +1,326 @@
1
+ import WebSocket from "ws";
2
+ import { NodeOptions, NodeStats, Track, SearchResult, LavalinkException } from "./types";
3
+ import type { Yukimu } from "./Yukimu";
4
+
5
+ export class Node {
6
+ public readonly manager: Yukimu;
7
+ public readonly options: NodeOptions;
8
+ public ws: WebSocket | null = null;
9
+ public connected: boolean = false;
10
+ public stats: NodeStats | null = null;
11
+ public sessionId: string | null = null;
12
+
13
+ /** Lavalink version this node runs (3 or 4) */
14
+ public readonly version: 3 | 4;
15
+
16
+ private reconnectAttempts: number = 0;
17
+ private reconnectTimeout?: ReturnType<typeof setTimeout>;
18
+
19
+ constructor(manager: Yukimu, options: NodeOptions) {
20
+ this.manager = manager;
21
+ this.options = {
22
+ secure: false,
23
+ retries: 5,
24
+ version: 4,
25
+ ...options,
26
+ };
27
+ this.version = this.options.version ?? 4;
28
+ }
29
+
30
+ // ─── URL Helpers ──────────────────────────────────────────────────
31
+
32
+ /** WebSocket URL differs between v3 and v4 */
33
+ get wsUrl(): string {
34
+ const protocol = this.options.secure ? "wss" : "ws";
35
+ const base = `${protocol}://${this.options.host}:${this.options.port}`;
36
+ return this.version === 4 ? `${base}/v4/websocket` : base;
37
+ }
38
+
39
+ /** REST base URL */
40
+ get restUrl(): string {
41
+ const protocol = this.options.secure ? "https" : "http";
42
+ return `${protocol}://${this.options.host}:${this.options.port}`;
43
+ }
44
+
45
+ /** API prefix — v4 uses /v4, v3 uses nothing */
46
+ get apiPrefix(): string {
47
+ return this.version === 4 ? "/v4" : "";
48
+ }
49
+
50
+ get headers(): Record<string, string> {
51
+ const h: Record<string, string> = {
52
+ Authorization: this.options.password,
53
+ "User-Id": this.manager.options.clientId,
54
+ "Client-Name": "Yukimu/1.0.0",
55
+ };
56
+ // v3 uses Num-Shards header
57
+ if (this.version === 3) {
58
+ h["Num-Shards"] = "1";
59
+ }
60
+ return h;
61
+ }
62
+
63
+ // ─── Connection ───────────────────────────────────────────────────
64
+
65
+ public connect(): void {
66
+ if (this.ws?.readyState === WebSocket.OPEN) return;
67
+ this.ws = new WebSocket(this.wsUrl, { headers: this.headers });
68
+ this.ws.on("open", () => this.onOpen());
69
+ this.ws.on("message", (data) => this.onMessage(data));
70
+ this.ws.on("close", (code, reason) => this.onClose(code, reason.toString()));
71
+ this.ws.on("error", (err) => this.onError(err));
72
+ }
73
+
74
+ public destroy(): void {
75
+ if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout);
76
+ this.ws?.removeAllListeners();
77
+ this.ws?.close();
78
+ this.ws = null;
79
+ this.connected = false;
80
+ }
81
+
82
+ // ─── WebSocket Events ─────────────────────────────────────────────
83
+
84
+ private onOpen(): void {
85
+ this.connected = true;
86
+ this.reconnectAttempts = 0;
87
+ this.manager.emit("nodeConnect", this);
88
+ console.log(`[Yukimu] Node "${this.options.name}" connected (Lavalink v${this.version})`);
89
+
90
+ // v3 doesn't send a "ready" op — mark ready immediately on open
91
+ if (this.version === 3) {
92
+ this.sessionId = "v3-no-session";
93
+ this.manager.emit("nodeReady", this);
94
+ }
95
+ }
96
+
97
+ private onMessage(raw: WebSocket.RawData): void {
98
+ let payload: Record<string, unknown>;
99
+ try {
100
+ payload = JSON.parse(raw.toString());
101
+ } catch {
102
+ return;
103
+ }
104
+
105
+ const op = payload.op as string;
106
+
107
+ // ── v4 ops ──
108
+ if (this.version === 4) {
109
+ switch (op) {
110
+ case "ready":
111
+ this.sessionId = payload.sessionId as string;
112
+ this.manager.emit("nodeReady", this);
113
+ console.log(`[Yukimu] Node "${this.options.name}" ready | session: ${this.sessionId}`);
114
+ break;
115
+ case "stats":
116
+ this.stats = payload as unknown as NodeStats;
117
+ break;
118
+ case "playerUpdate":
119
+ this.handlePlayerUpdate(payload);
120
+ break;
121
+ case "event":
122
+ this.handleEvent(payload);
123
+ break;
124
+ }
125
+ return;
126
+ }
127
+
128
+ // ── v3 ops ──
129
+ switch (op) {
130
+ case "stats":
131
+ this.stats = payload as unknown as NodeStats;
132
+ break;
133
+ case "playerUpdate":
134
+ this.handlePlayerUpdate(payload);
135
+ break;
136
+ case "event":
137
+ this.handleEvent(payload);
138
+ break;
139
+ }
140
+ }
141
+
142
+ private onClose(code: number, reason: string): void {
143
+ this.connected = false;
144
+ this.manager.emit("nodeDisconnect", this, code, reason);
145
+ console.warn(`[Yukimu] Node "${this.options.name}" disconnected (${code}): ${reason}`);
146
+ this.scheduleReconnect();
147
+ }
148
+
149
+ private onError(error: Error): void {
150
+ this.manager.emit("nodeError", this, error);
151
+ console.error(`[Yukimu] Node "${this.options.name}" error:`, error.message);
152
+ }
153
+
154
+ // ─── Reconnect ────────────────────────────────────────────────────
155
+
156
+ private scheduleReconnect(): void {
157
+ const maxRetries = this.options.retries ?? 5;
158
+ if (this.reconnectAttempts >= maxRetries) {
159
+ console.error(`[Yukimu] Node "${this.options.name}" max reconnect attempts reached`);
160
+ return;
161
+ }
162
+ const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 30000);
163
+ this.reconnectAttempts++;
164
+ console.log(`[Yukimu] Reconnecting node "${this.options.name}" in ${delay}ms (attempt ${this.reconnectAttempts})`);
165
+ this.reconnectTimeout = setTimeout(() => this.connect(), delay);
166
+ }
167
+
168
+ // ─── Event Handlers ───────────────────────────────────────────────
169
+
170
+ private handlePlayerUpdate(payload: Record<string, unknown>): void {
171
+ const player = this.manager.players.get(payload.guildId as string);
172
+ if (!player) return;
173
+ const state = payload.state as { position?: number; connected?: boolean; ping?: number };
174
+ player.position = state.position ?? 0;
175
+ player.ping = state.ping ?? -1;
176
+ this.manager.emit("playerUpdate", player);
177
+ }
178
+
179
+ private handleEvent(payload: Record<string, unknown>): void {
180
+ const player = this.manager.players.get(payload.guildId as string);
181
+ if (!player) return;
182
+
183
+ // Normalize track — v3 uses { track: "encoded_string" }, v4 uses { track: { encoded, info } }
184
+ let track: Track;
185
+ if (this.version === 3) {
186
+ track = {
187
+ encoded: payload.track as string,
188
+ info: {} as Track["info"],
189
+ };
190
+ } else {
191
+ track = payload.track as Track;
192
+ }
193
+
194
+ switch (payload.type) {
195
+ case "TrackStartEvent":
196
+ player.playing = true;
197
+ this.manager.emit("trackStart", player, track);
198
+ break;
199
+
200
+ case "TrackEndEvent":
201
+ player.playing = false;
202
+ player.position = 0;
203
+ this.manager.emit("trackEnd", player, track, payload.reason as string);
204
+ if (payload.reason !== "replaced" && payload.reason !== "stopped" && payload.reason !== "REPLACED" && payload.reason !== "STOPPED") {
205
+ player.queue.next();
206
+ if (player.queue.current) {
207
+ player.play(player.queue.current);
208
+ } else {
209
+ this.manager.emit("queueEnd", player);
210
+ }
211
+ }
212
+ break;
213
+
214
+ case "TrackExceptionEvent":
215
+ this.manager.emit("trackError", player, track, payload.exception as LavalinkException);
216
+ break;
217
+
218
+ case "TrackStuckEvent":
219
+ this.manager.emit("trackStuck", player, track, payload.thresholdMs as number);
220
+ break;
221
+
222
+ case "WebSocketClosedEvent":
223
+ this.manager.emit("socketClosed", player, payload.code as number, payload.reason as string);
224
+ break;
225
+ }
226
+ }
227
+
228
+ // ─── REST API ─────────────────────────────────────────────────────
229
+
230
+ public async request<T = unknown>(
231
+ method: string,
232
+ path: string,
233
+ body?: unknown
234
+ ): Promise<T> {
235
+ const url = `${this.restUrl}${this.apiPrefix}${path}`;
236
+ const res = await fetch(url, {
237
+ method,
238
+ headers: {
239
+ ...this.headers,
240
+ "Content-Type": "application/json",
241
+ },
242
+ body: body ? JSON.stringify(body) : undefined,
243
+ });
244
+
245
+ if (!res.ok) {
246
+ const text = await res.text();
247
+ throw new Error(`[Yukimu] REST error ${res.status} on ${method} ${path}: ${text}`);
248
+ }
249
+
250
+ if (res.status === 204) return undefined as T;
251
+ return res.json() as Promise<T>;
252
+ }
253
+
254
+ /** Load tracks — works for both v3 and v4 */
255
+ public async loadTracks(identifier: string): Promise<SearchResult> {
256
+ const raw = await this.request<Record<string, unknown>>(
257
+ "GET",
258
+ `/loadtracks?identifier=${encodeURIComponent(identifier)}`
259
+ );
260
+
261
+ // Normalize v3 response to v4 format
262
+ if (this.version === 3) {
263
+ return this.normalizeV3Response(raw);
264
+ }
265
+
266
+ return raw as unknown as SearchResult;
267
+ }
268
+
269
+ /**
270
+ * Convert v3 loadtracks response to v4 format so the rest
271
+ * of the codebase only needs to handle one format
272
+ */
273
+ private normalizeV3Response(raw: Record<string, unknown>): SearchResult {
274
+ const loadType = (raw.loadType as string).toLowerCase();
275
+
276
+ // v3 tracks are { track: "encoded", info: {...} }
277
+ // v4 tracks are { encoded: "...", info: {...} }
278
+ const normalizeTracks = (tracks: unknown[]): Track[] =>
279
+ tracks.map((t: unknown) => {
280
+ const track = t as Record<string, unknown>;
281
+ return {
282
+ encoded: (track.track ?? track.encoded) as string,
283
+ info: track.info as Track["info"],
284
+ pluginInfo: track.pluginInfo as Record<string, unknown> | undefined,
285
+ };
286
+ });
287
+
288
+ switch (loadType) {
289
+ case "track_loaded":
290
+ return {
291
+ loadType: "track",
292
+ tracks: normalizeTracks(raw.tracks as unknown[]),
293
+ };
294
+ case "playlist_loaded":
295
+ return {
296
+ loadType: "playlist",
297
+ tracks: normalizeTracks(raw.tracks as unknown[]),
298
+ playlistInfo: raw.playlistInfo as SearchResult["playlistInfo"],
299
+ };
300
+ case "search_result":
301
+ return {
302
+ loadType: "search",
303
+ tracks: normalizeTracks(raw.tracks as unknown[]),
304
+ };
305
+ case "no_matches":
306
+ return { loadType: "empty", tracks: [] };
307
+ case "load_failed":
308
+ return {
309
+ loadType: "error",
310
+ tracks: [],
311
+ exception: raw.exception as SearchResult["exception"],
312
+ };
313
+ default:
314
+ return { loadType: "empty", tracks: [] };
315
+ }
316
+ }
317
+
318
+ /** Get player endpoint path — differs between v3 and v4 */
319
+ public playerPath(guildId: string): string {
320
+ if (this.version === 4) {
321
+ return `/sessions/${this.sessionId}/players/${guildId}`;
322
+ }
323
+ // v3 uses /players/{guildId}
324
+ return `/players/${guildId}`;
325
+ }
326
+ }
package/src/Player.ts ADDED
@@ -0,0 +1,245 @@
1
+ import { Queue } from "./Queue";
2
+ import { Track, PlayerOptions, VoiceState } from "./types";
3
+ import type { Yukimu } from "./Yukimu";
4
+ import type { Node } from "./Node";
5
+
6
+ export class Player {
7
+ public readonly manager: Yukimu;
8
+ public readonly node: Node;
9
+ public readonly queue: Queue;
10
+
11
+ // IDs
12
+ public readonly guildId: string;
13
+ public voiceChannelId: string | null;
14
+ public textChannelId?: string;
15
+
16
+ // State
17
+ public playing: boolean = false;
18
+ public paused: boolean = false;
19
+ public connected: boolean = false;
20
+ public position: number = 0;
21
+ public ping: number = -1;
22
+ public volume: number;
23
+
24
+ // Voice connection
25
+ public sessionId: string | null = null;
26
+ public voiceToken: string | null = null;
27
+ public voiceEndpoint: string | null = null;
28
+
29
+ // Filters
30
+ public filters: Record<string, unknown> = {};
31
+
32
+ // Loop mode
33
+ public loop: "none" | "track" | "queue" = "none";
34
+
35
+ constructor(manager: Yukimu, node: Node, options: PlayerOptions) {
36
+ this.manager = manager;
37
+ this.node = node;
38
+ this.queue = new Queue();
39
+
40
+ this.guildId = options.guildId;
41
+ this.voiceChannelId = options.voiceChannelId;
42
+ this.textChannelId = options.textChannelId;
43
+ this.volume = options.volume ?? 100;
44
+
45
+ // Send voice channel join payload to Discord
46
+ this.sendVoicePayload(options.voiceChannelId, options.selfDeaf ?? true, options.selfMute ?? false);
47
+ }
48
+
49
+ // ─── Voice ───────────────────────────────────────────────────────
50
+
51
+ private sendVoicePayload(channelId: string | null, selfDeaf: boolean, selfMute: boolean): void {
52
+ this.manager.sendPayload(this.guildId, {
53
+ op: 4,
54
+ d: {
55
+ guild_id: this.guildId,
56
+ channel_id: channelId,
57
+ self_deaf: selfDeaf,
58
+ self_mute: selfMute,
59
+ },
60
+ });
61
+ }
62
+
63
+ /** Called when both sessionId and voiceToken/endpoint are available */
64
+ public checkVoiceReady(): void {
65
+ if (!this.sessionId || !this.voiceToken || !this.voiceEndpoint) return;
66
+
67
+ const voiceState: VoiceState = {
68
+ token: this.voiceToken,
69
+ endpoint: this.voiceEndpoint,
70
+ sessionId: this.sessionId,
71
+ };
72
+
73
+ this.node
74
+ .request("PATCH", `/sessions/${this.node.sessionId}/players/${this.guildId}`, {
75
+ voice: voiceState,
76
+ })
77
+ .catch(console.error);
78
+ }
79
+
80
+ // ─── Playback ─────────────────────────────────────────────────────
81
+
82
+ /** Play a track */
83
+ public async play(track: Track, options?: { startTime?: number; endTime?: number }): Promise<void> {
84
+ this.queue.current = track;
85
+
86
+ await this.node.request("PATCH", `/sessions/${this.node.sessionId}/players/${this.guildId}?noReplace=false`, {
87
+ track: { encoded: track.encoded },
88
+ volume: this.volume,
89
+ ...(options?.startTime && { position: options.startTime }),
90
+ ...(options?.endTime && { endTime: options.endTime }),
91
+ });
92
+
93
+ this.playing = true;
94
+ this.paused = false;
95
+ }
96
+
97
+ /** Pause or resume playback */
98
+ public async pause(state: boolean = true): Promise<void> {
99
+ await this.node.request("PATCH", `/sessions/${this.node.sessionId}/players/${this.guildId}`, {
100
+ paused: state,
101
+ });
102
+ this.paused = state;
103
+ this.playing = !state;
104
+ }
105
+
106
+ /** Resume playback */
107
+ public async resume(): Promise<void> {
108
+ return this.pause(false);
109
+ }
110
+
111
+ /** Stop playback */
112
+ public async stop(): Promise<void> {
113
+ await this.node.request("PATCH", `/sessions/${this.node.sessionId}/players/${this.guildId}`, {
114
+ track: { encoded: null },
115
+ });
116
+ this.playing = false;
117
+ this.paused = false;
118
+ this.position = 0;
119
+ this.queue.current = null;
120
+ }
121
+
122
+ /** Skip to next track */
123
+ public async skip(): Promise<Track | null> {
124
+ const next = this.queue.next();
125
+ if (next) {
126
+ await this.play(next);
127
+ } else {
128
+ await this.stop();
129
+ this.manager.emit("queueEnd", this);
130
+ }
131
+ return next;
132
+ }
133
+
134
+ /** Seek to position in milliseconds */
135
+ public async seek(position: number): Promise<void> {
136
+ if (!this.queue.current?.info.isSeekable) throw new Error("Current track is not seekable");
137
+ await this.node.request("PATCH", `/sessions/${this.node.sessionId}/players/${this.guildId}`, {
138
+ position,
139
+ });
140
+ this.position = position;
141
+ }
142
+
143
+ /** Set volume (0–1000, Lavalink default 100) */
144
+ public async setVolume(volume: number): Promise<void> {
145
+ if (volume < 0 || volume > 1000) throw new Error("Volume must be between 0 and 1000");
146
+ await this.node.request("PATCH", `/sessions/${this.node.sessionId}/players/${this.guildId}`, {
147
+ volume,
148
+ });
149
+ this.volume = volume;
150
+ }
151
+
152
+ // ─── Filters ──────────────────────────────────────────────────────
153
+
154
+ /** Apply audio filters (bass boost, nightcore, 8D, etc.) */
155
+ public async setFilters(filters: Record<string, unknown>): Promise<void> {
156
+ this.filters = filters;
157
+ await this.node.request("PATCH", `/sessions/${this.node.sessionId}/players/${this.guildId}`, {
158
+ filters,
159
+ });
160
+ }
161
+
162
+ /** Enable bass boost */
163
+ public async setBassBoost(level: "low" | "medium" | "high" | "off"): Promise<void> {
164
+ const bands: { band: number; gain: number }[] = [];
165
+ const gains = {
166
+ off: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
167
+ low: [0.2, 0.15, 0.1, 0.05, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
168
+ medium: [0.4, 0.3, 0.2, 0.1, 0.05, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
169
+ high: [0.6, 0.5, 0.4, 0.25, 0.1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
170
+ };
171
+
172
+ for (let i = 0; i < 15; i++) {
173
+ bands.push({ band: i, gain: gains[level][i] });
174
+ }
175
+
176
+ await this.setFilters({ ...this.filters, equalizer: bands });
177
+ }
178
+
179
+ /** Enable nightcore effect */
180
+ public async setNightcore(enabled: boolean): Promise<void> {
181
+ await this.setFilters({
182
+ ...this.filters,
183
+ timescale: enabled ? { speed: 1.2, pitch: 1.2, rate: 1.0 } : {},
184
+ });
185
+ }
186
+
187
+ /** Enable 8D audio */
188
+ public async set8D(enabled: boolean): Promise<void> {
189
+ await this.setFilters({
190
+ ...this.filters,
191
+ rotation: enabled ? { rotationHz: 0.2 } : {},
192
+ });
193
+ }
194
+
195
+ /** Clear all filters */
196
+ public async clearFilters(): Promise<void> {
197
+ this.filters = {};
198
+ await this.node.request("PATCH", `/sessions/${this.node.sessionId}/players/${this.guildId}`, {
199
+ filters: {},
200
+ });
201
+ }
202
+
203
+ // ─── Queue Helpers ────────────────────────────────────────────────
204
+
205
+ /** Add track(s) to queue and optionally start playing */
206
+ public async add(tracks: Track | Track[], playNow: boolean = false): Promise<void> {
207
+ const arr = Array.isArray(tracks) ? tracks : [tracks];
208
+
209
+ if (!this.queue.current && arr.length > 0) {
210
+ const first = arr.shift()!;
211
+ this.queue.current = first;
212
+ this.queue.add(arr);
213
+ await this.play(first);
214
+ } else {
215
+ this.queue.add(arr);
216
+ if (playNow && arr.length > 0) {
217
+ await this.play(arr[0]);
218
+ }
219
+ }
220
+ }
221
+
222
+ /** Set loop mode */
223
+ public setLoop(mode: "none" | "track" | "queue"): void {
224
+ this.loop = mode;
225
+ }
226
+
227
+ // ─── Disconnect ───────────────────────────────────────────────────
228
+
229
+ /** Disconnect from voice and clean up */
230
+ public async destroy(): Promise<void> {
231
+ this.sendVoicePayload(null, false, false);
232
+ await this.node
233
+ .request("DELETE", `/sessions/${this.node.sessionId}/players/${this.guildId}`)
234
+ .catch(() => {});
235
+ this.playing = false;
236
+ this.connected = false;
237
+ this.queue.clear();
238
+ }
239
+
240
+ /** Move to a different voice channel */
241
+ public async move(channelId: string, selfDeaf: boolean = true): Promise<void> {
242
+ this.voiceChannelId = channelId;
243
+ this.sendVoicePayload(channelId, selfDeaf, false);
244
+ }
245
+ }
package/src/Queue.ts ADDED
@@ -0,0 +1,82 @@
1
+ import { Track } from "./types";
2
+
3
+ export class Queue {
4
+ public current: Track | null = null;
5
+ public previous: Track | null = null;
6
+ private tracks: Track[] = [];
7
+
8
+ // ─── Basic Operations ─────────────────────────────────────────────
9
+
10
+ /** Add tracks to the end of the queue */
11
+ public add(tracks: Track | Track[]): void {
12
+ const arr = Array.isArray(tracks) ? tracks : [tracks];
13
+ this.tracks.push(...arr);
14
+ }
15
+
16
+ /** Remove and return the next track */
17
+ public next(): Track | null {
18
+ if (this.current) this.previous = this.current;
19
+ this.current = this.tracks.shift() ?? null;
20
+ return this.current;
21
+ }
22
+
23
+ /** Peek at upcoming tracks without modifying queue */
24
+ public peek(count: number = 5): Track[] {
25
+ return this.tracks.slice(0, count);
26
+ }
27
+
28
+ /** Remove a track at a specific index */
29
+ public remove(index: number): Track | null {
30
+ if (index < 0 || index >= this.tracks.length) return null;
31
+ const [removed] = this.tracks.splice(index, 1);
32
+ return removed;
33
+ }
34
+
35
+ /** Clear all queued tracks (does not affect current) */
36
+ public clear(): void {
37
+ this.tracks = [];
38
+ this.current = null;
39
+ this.previous = null;
40
+ }
41
+
42
+ // ─── Shuffle ──────────────────────────────────────────────────────
43
+
44
+ /** Fisher-Yates shuffle */
45
+ public shuffle(): void {
46
+ for (let i = this.tracks.length - 1; i > 0; i--) {
47
+ const j = Math.floor(Math.random() * (i + 1));
48
+ [this.tracks[i], this.tracks[j]] = [this.tracks[j], this.tracks[i]];
49
+ }
50
+ }
51
+
52
+ // ─── Move / Reorder ───────────────────────────────────────────────
53
+
54
+ /** Move a track from one position to another */
55
+ public move(from: number, to: number): void {
56
+ if (from < 0 || to < 0 || from >= this.tracks.length || to >= this.tracks.length) return;
57
+ const [track] = this.tracks.splice(from, 1);
58
+ this.tracks.splice(to, 0, track);
59
+ }
60
+
61
+ // ─── Getters ──────────────────────────────────────────────────────
62
+
63
+ /** Total tracks in queue (not including current) */
64
+ get size(): number {
65
+ return this.tracks.length;
66
+ }
67
+
68
+ /** Total duration of all queued tracks in ms */
69
+ get totalDuration(): number {
70
+ return this.tracks.reduce((acc, t) => acc + (t.info.length ?? 0), 0);
71
+ }
72
+
73
+ /** Whether the queue is empty */
74
+ get isEmpty(): boolean {
75
+ return this.tracks.length === 0;
76
+ }
77
+
78
+ /** All tracks as array (read-only view) */
79
+ get list(): ReadonlyArray<Track> {
80
+ return this.tracks;
81
+ }
82
+ }