diff --git a/src/media/mediaQueue.ts b/src/media/mediaQueue.ts new file mode 100644 index 0000000..5460c43 --- /dev/null +++ b/src/media/mediaQueue.ts @@ -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 { + return { + current: this.current ? { ...this.current } : null, + queue: this.items.map((item) => ({ ...item })), + }; + } +} \ No newline at end of file diff --git a/src/media/mediaTypes.ts b/src/media/mediaTypes.ts new file mode 100644 index 0000000..c6a1f56 --- /dev/null +++ b/src/media/mediaTypes.ts @@ -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; + stop(): void; +} + +export interface MusicPlayer { + play(source: ResolvedMediaSource): MusicPlayback; +} + +export interface DiscordAudioPlayer { + isConnected(): boolean; + playStream(stream: Readable): void; + stop(): void; +} \ No newline at end of file diff --git a/tests/media/mediaQueue.test.ts b/tests/media/mediaQueue.test.ts new file mode 100644 index 0000000..d63d5e7 --- /dev/null +++ b/tests/media/mediaQueue.test.ts @@ -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 { + 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: [] }); + }); +});