feat: add media queue foundation
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
59
src/media/mediaQueue.ts
Normal file
59
src/media/mediaQueue.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import type {
|
||||||
|
MediaQueueItem,
|
||||||
|
MediaState,
|
||||||
|
ResolvedMediaSource,
|
||||||
|
} from "./mediaTypes";
|
||||||
|
|
||||||
|
export class MediaQueue {
|
||||||
|
private current: MediaQueueItem | null = null;
|
||||||
|
private readonly items: MediaQueueItem[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly createId = () => crypto.randomUUID(),
|
||||||
|
private readonly now = () => Date.now(),
|
||||||
|
) {}
|
||||||
|
|
||||||
|
add(source: ResolvedMediaSource, requestedBy = "dashboard"): MediaQueueItem {
|
||||||
|
const item: MediaQueueItem = {
|
||||||
|
id: this.createId(),
|
||||||
|
mode: "music",
|
||||||
|
requestedBy,
|
||||||
|
addedAt: this.now(),
|
||||||
|
status: "queued",
|
||||||
|
...source,
|
||||||
|
};
|
||||||
|
this.items.push(item);
|
||||||
|
return { ...item };
|
||||||
|
}
|
||||||
|
|
||||||
|
startNext(): MediaQueueItem | null {
|
||||||
|
if (this.current) return { ...this.current };
|
||||||
|
const next = this.items.shift();
|
||||||
|
if (!next) return null;
|
||||||
|
this.current = { ...next, status: "playing" };
|
||||||
|
return { ...this.current };
|
||||||
|
}
|
||||||
|
|
||||||
|
completeCurrent(): void {
|
||||||
|
this.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
failCurrent(): void {
|
||||||
|
if (this.current) {
|
||||||
|
this.current = { ...this.current, status: "failed" };
|
||||||
|
}
|
||||||
|
this.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.current = null;
|
||||||
|
this.items.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot(): Pick<MediaState, "current" | "queue"> {
|
||||||
|
return {
|
||||||
|
current: this.current ? { ...this.current } : null,
|
||||||
|
queue: this.items.map((item) => ({ ...item })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/media/mediaTypes.ts
Normal file
40
src/media/mediaTypes.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { Readable } from "node:stream";
|
||||||
|
|
||||||
|
export type MediaMode = "music" | "screen";
|
||||||
|
export type MediaSourceKind = "url" | "local";
|
||||||
|
export type MediaQueueItemStatus = "queued" | "playing" | "failed";
|
||||||
|
|
||||||
|
export interface ResolvedMediaSource {
|
||||||
|
source: string;
|
||||||
|
title: string;
|
||||||
|
kind: MediaSourceKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaQueueItem extends ResolvedMediaSource {
|
||||||
|
id: string;
|
||||||
|
mode: MediaMode;
|
||||||
|
requestedBy: string;
|
||||||
|
addedAt: number;
|
||||||
|
status: MediaQueueItemStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaState {
|
||||||
|
playing: boolean;
|
||||||
|
current: MediaQueueItem | null;
|
||||||
|
queue: MediaQueueItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MusicPlayback {
|
||||||
|
done: Promise<void>;
|
||||||
|
stop(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MusicPlayer {
|
||||||
|
play(source: ResolvedMediaSource): MusicPlayback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscordAudioPlayer {
|
||||||
|
isConnected(): boolean;
|
||||||
|
playStream(stream: Readable): void;
|
||||||
|
stop(): void;
|
||||||
|
}
|
||||||
67
tests/media/mediaQueue.test.ts
Normal file
67
tests/media/mediaQueue.test.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { MediaQueue } from "../../src/media/mediaQueue";
|
||||||
|
import type { ResolvedMediaSource } from "../../src/media/mediaTypes";
|
||||||
|
|
||||||
|
function source(overrides: Partial<ResolvedMediaSource> = {}): ResolvedMediaSource {
|
||||||
|
return {
|
||||||
|
source: "https://example.com/audio.ogg",
|
||||||
|
title: "audio.ogg",
|
||||||
|
kind: "url",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("MediaQueue", () => {
|
||||||
|
it("adds items with stable queue metadata", () => {
|
||||||
|
const queue = new MediaQueue(() => "item-1", () => 1700000000000);
|
||||||
|
|
||||||
|
const item = queue.add(source(), "tester");
|
||||||
|
|
||||||
|
expect(item).toMatchObject({
|
||||||
|
id: "item-1",
|
||||||
|
mode: "music",
|
||||||
|
source: "https://example.com/audio.ogg",
|
||||||
|
title: "audio.ogg",
|
||||||
|
kind: "url",
|
||||||
|
requestedBy: "tester",
|
||||||
|
addedAt: 1700000000000,
|
||||||
|
status: "queued",
|
||||||
|
});
|
||||||
|
expect(queue.snapshot()).toEqual({ current: null, queue: [item] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the next queued item as playing", () => {
|
||||||
|
const queue = new MediaQueue(() => "item-1", () => 1700000000000);
|
||||||
|
const item = queue.add(source(), "tester");
|
||||||
|
|
||||||
|
expect(queue.startNext()).toEqual({ ...item, status: "playing" });
|
||||||
|
expect(queue.snapshot()).toEqual({
|
||||||
|
current: { ...item, status: "playing" },
|
||||||
|
queue: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes current item and starts following item", () => {
|
||||||
|
let id = 0;
|
||||||
|
const queue = new MediaQueue(() => `item-${++id}`, () => 1700000000000);
|
||||||
|
queue.add(source({ title: "first" }), "tester");
|
||||||
|
queue.add(source({ title: "second" }), "tester");
|
||||||
|
queue.startNext();
|
||||||
|
|
||||||
|
queue.completeCurrent();
|
||||||
|
const next = queue.startNext();
|
||||||
|
|
||||||
|
expect(next?.title).toBe("second");
|
||||||
|
expect(queue.snapshot().queue).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears current and queued items", () => {
|
||||||
|
const queue = new MediaQueue(() => "item-1", () => 1700000000000);
|
||||||
|
queue.add(source(), "tester");
|
||||||
|
queue.startNext();
|
||||||
|
|
||||||
|
queue.clear();
|
||||||
|
|
||||||
|
expect(queue.snapshot()).toEqual({ current: null, queue: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user