# Media Echo Fix and YouTube Screenshare Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Prevent media playback echo by making audio player ownership explicit, then add a YouTube Go Live screenshare path through the existing media API. **Architecture:** `DiscordPlayer` becomes the ownership gate for the shared Discord voice audio player. Music playback claims `music`, browser bridge claims `browser-bridge`, and screenshare uses a new `ScreenShareController` with `@dank074/discord-video-stream` while coordinating busy state through `MediaController`. **Tech Stack:** TypeScript, Vitest, Express, `@discordjs/voice`, `prism-media`, `yt-dlp`, `@dank074/discord-video-stream`. --- ## File Structure - Modify `src/player.ts`: add player owner types and claim/release behavior while preserving pause/unpause/status APIs. - Modify `src/media/mediaTypes.ts`: add `MediaMode`, queue mode options, screen controller interfaces, and owner-aware `DiscordAudioPlayer` methods. - Modify `src/media/musicPlayer.ts`: claim `music` ownership before starting ffmpeg output and release ownership on stop or normal close. - Modify `src/webserver.ts`: lazily start browser bridge and make it claim `browser-bridge` only when no media owner is active. - Modify `src/media/mediaQueue.ts`: accept a mode argument instead of hardcoding `music`. - Modify `src/media/mediaController.ts`: route `mode: "screen"` to the new screen controller and enforce busy-state rules. - Modify `src/routes/mediaRoutes.ts`: parse optional `mode` from POST body and pass it to controller. - Modify `src/media/ytdlp.ts`: add `getDirectVideoUrl` for screenshare-friendly direct media URLs. - Create `src/media/screenShareController.ts`: encapsulate Go Live lifecycle and dependency injection for tests. - Create `tests/player.test.ts`: test ownership behavior. - Modify `tests/media/musicPlayer.test.ts`: update mocks for ownership methods and verify release behavior. - Modify `tests/media/mediaController.test.ts`: cover mode routing and busy conflicts. - Modify `tests/routes/mediaRoutes.test.ts`: cover mode body parsing. - Modify `tests/media/ytdlp.test.ts`: cover direct video URL command. - Create `tests/media/screenShareController.test.ts`: test screenshare lifecycle with mocked dependencies. --- ### Task 1: Add DiscordPlayer Ownership **Files:** - Modify: `src/player.ts` - Modify: `src/media/mediaTypes.ts` - Create: `tests/player.test.ts` - [ ] **Step 1: Write the failing ownership tests** Create `tests/player.test.ts`: ```ts import { PassThrough } from "node:stream"; import { beforeEach, describe, expect, it, vi } from "vitest"; const audioPlayer = { on: vi.fn(), play: vi.fn(), pause: vi.fn(), unpause: vi.fn(() => true), stop: vi.fn(), state: { status: "idle" }, }; vi.mock("@discordjs/voice", () => ({ AudioPlayerStatus: { Idle: "idle", Playing: "playing" }, StreamType: { OggOpus: "ogg/opus" }, createAudioPlayer: vi.fn(() => audioPlayer), createAudioResource: vi.fn((stream, options) => ({ stream, options })), })); describe("DiscordPlayer ownership", () => { beforeEach(() => { vi.clearAllMocks(); audioPlayer.state.status = "idle"; }); it("prevents browser bridge from overriding music playback", async () => { const { DiscordPlayer } = await import("../src/player"); const player = new DiscordPlayer(); player.playStream(new PassThrough(), "music"); expect(() => player.playStream(new PassThrough(), "browser-bridge")).toThrow( "Discord audio player is owned by music", ); expect(audioPlayer.play).toHaveBeenCalledTimes(1); expect(player.getOwner()).toBe("music"); }); it("allows the current owner to replace its own stream", async () => { const { DiscordPlayer } = await import("../src/player"); const player = new DiscordPlayer(); player.playStream(new PassThrough(), "browser-bridge"); player.playStream(new PassThrough(), "browser-bridge"); expect(audioPlayer.play).toHaveBeenCalledTimes(2); expect(player.getOwner()).toBe("browser-bridge"); }); it("releases ownership when the owner stops playback", async () => { const { DiscordPlayer } = await import("../src/player"); const player = new DiscordPlayer(); player.playStream(new PassThrough(), "music"); player.stop("music"); expect(audioPlayer.stop).toHaveBeenCalledTimes(1); expect(player.getOwner()).toBe("none"); }); it("ignores stop calls from non-owners", async () => { const { DiscordPlayer } = await import("../src/player"); const player = new DiscordPlayer(); player.playStream(new PassThrough(), "music"); player.stop("browser-bridge"); expect(audioPlayer.stop).not.toHaveBeenCalled(); expect(player.getOwner()).toBe("music"); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: ```bash pnpm exec vitest run tests/player.test.ts ``` Expected: FAIL with TypeScript/runtime errors because `playStream` does not accept an owner and `getOwner` does not exist. - [ ] **Step 3: Add owner types** Modify `src/media/mediaTypes.ts`: ```ts import type { Readable } from "node:stream"; export type MediaMode = "music" | "screen"; export type DiscordPlayerOwner = "none" | "browser-bridge" | MediaMode; export type MediaSourceKind = | "url" | "local" | "youtube" | "spotify" | "search"; export type MediaQueueItemStatus = "queued" | "playing" | "failed"; export interface ResolvedMediaSource { source: string; title: string; kind: MediaSourceKind; } export interface QueueMediaOptions { mode?: MediaMode; requestedBy?: string; } export interface MediaQueueItem extends ResolvedMediaSource { id: string; mode: MediaMode; requestedBy: string; addedAt: number; status: MediaQueueItemStatus; } export interface MediaState { playing: boolean; activeMode: MediaMode | null; current: MediaQueueItem | null; queue: MediaQueueItem[]; } export interface MusicPlayback { done: Promise; stop(): void; } export interface MusicPlayer { play(source: ResolvedMediaSource): MusicPlayback; } export interface ScreenSharePlayback { done: Promise; stop(): void; } export interface ScreenShareController { isActive(): boolean; start(source: string): Promise; } export interface DiscordAudioPlayer { getOwner(): DiscordPlayerOwner; isConnected(): boolean; playStream(stream: Readable, owner: DiscordPlayerOwner): void; pause(owner?: DiscordPlayerOwner): void; unpause(owner?: DiscordPlayerOwner): boolean; stop(owner?: DiscordPlayerOwner): void; } ``` - [ ] **Step 4: Implement ownership in DiscordPlayer** Replace `src/player.ts` with: ```ts import { Readable } from "node:stream"; import { AudioPlayer, AudioPlayerStatus, createAudioPlayer, createAudioResource, StreamType, VoiceConnection, } from "@discordjs/voice"; import type { DiscordPlayerOwner } from "./media/mediaTypes"; export class DiscordPlayer { private player: AudioPlayer; private connection: VoiceConnection | null = null; private owner: DiscordPlayerOwner = "none"; constructor() { this.player = createAudioPlayer(); this.player.on(AudioPlayerStatus.Playing, () => { console.log("[player] Audio player is now playing!"); }); this.player.on("error", (error) => { console.error(`[player] Error: ${error.message}`); this.owner = "none"; }); } public setConnection(connection: VoiceConnection) { this.connection = connection; this.connection.subscribe(this.player); } public getOwner(): DiscordPlayerOwner { return this.owner; } public isConnected(): boolean { return this.connection !== null; } public playStream(stream: Readable, owner: DiscordPlayerOwner) { if (owner === "none") { throw new Error("Discord audio player owner is required"); } this.assertOwnerAvailable(owner); console.log("[player] Starting new audio stream..."); const resource = createAudioResource(stream, { inputType: StreamType.OggOpus, }); this.owner = owner; this.player.play(resource); this.connection?.subscribe(this.player); } public getStatus(): AudioPlayerStatus { return this.player.state.status; } public pause(owner?: DiscordPlayerOwner) { if (!this.canControl(owner)) return; this.player.pause(true); } public unpause(owner?: DiscordPlayerOwner): boolean { if (!this.canControl(owner)) return false; return this.player.unpause(); } public stop(owner?: DiscordPlayerOwner) { if (!this.canControl(owner)) return; this.player.stop(); this.owner = "none"; } private assertOwnerAvailable(owner: DiscordPlayerOwner): void { if (this.owner !== "none" && this.owner !== owner) { throw new Error(`Discord audio player is owned by ${this.owner}`); } } private canControl(owner?: DiscordPlayerOwner): boolean { return !owner || this.owner === "none" || this.owner === owner; } } export const discordPlayer = new DiscordPlayer(); ``` - [ ] **Step 5: Run ownership tests** Run: ```bash pnpm exec vitest run tests/player.test.ts ``` Expected: PASS. - [ ] **Step 6: Commit Task 1** Run: ```bash git add src/player.ts src/media/mediaTypes.ts tests/player.test.ts git commit -m "feat: add discord player ownership" ``` --- ### Task 2: Make Music Playback Claim Ownership **Files:** - Modify: `src/media/musicPlayer.ts` - Modify: `tests/media/musicPlayer.test.ts` - [ ] **Step 1: Update failing music player tests** In `tests/media/musicPlayer.test.ts`, update all fake `DiscordAudioPlayer` objects to include `getOwner`, owner-aware methods, and assertions: ```ts const discordPlayer: DiscordAudioPlayer = { getOwner: () => "none", isConnected: () => true, playStream: vi.fn(), pause: vi.fn(), unpause: vi.fn(() => true), stop: vi.fn(), }; ``` Change the first test assertion to: ```ts expect(discordPlayer.playStream).toHaveBeenCalledWith(proc.stdout, "music"); ``` Change the stop assertion to: ```ts expect(discordPlayer.stop).toHaveBeenCalledWith("music"); ``` Add this test before the closing `});`: ```ts it("releases music ownership when ffmpeg exits normally", async () => { const proc = new FakeProcess(); const discordPlayer: DiscordAudioPlayer = { getOwner: () => "none", isConnected: () => true, playStream: vi.fn(), pause: vi.fn(), unpause: vi.fn(() => true), stop: vi.fn(), }; const player = createMusicPlayer({ spawn: vi.fn(() => proc), discordPlayer, }); const playback = player.play({ source: "/tmp/song.ogg", title: "song.ogg", kind: "local", }); proc.emit("close", 0); await playback.done; expect(discordPlayer.stop).toHaveBeenCalledWith("music"); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: ```bash pnpm exec vitest run tests/media/musicPlayer.test.ts ``` Expected: FAIL because `createMusicPlayer` still calls `playStream(proc.stdout)` and does not release ownership on normal close. - [ ] **Step 3: Update music player ownership** Modify `src/media/musicPlayer.ts` so the `play` body uses owner-aware methods: ```ts play(source: ResolvedMediaSource): MusicPlayback { if (!audioPlayer.isConnected()) { throw new Error("Discord audio player is not connected"); } const proc = spawn("ffmpeg", buildFfmpegArgs(source.source), { stdio: ["ignore", "pipe", "pipe"], }) as unknown as ChildProcessWithoutNullStreams; proc.stderr.resume(); audioPlayer.playStream(proc.stdout, "music"); let stopped = false; let released = false; const release = () => { if (released) return; released = true; audioPlayer.stop("music"); }; const done = new Promise((resolve, reject) => { proc.on("error", (error) => { release(); reject(error); }); proc.stdout.on("error", (error) => { release(); reject(error); }); proc.on("close", (code) => { release(); if (code === 0 || stopped) { resolve(); return; } reject(new Error(`ffmpeg exited with code ${code}`)); }); }); return { done, stop() { if (stopped) return; stopped = true; proc.kill("SIGTERM"); release(); }, }; }, ``` - [ ] **Step 4: Run music player tests** Run: ```bash pnpm exec vitest run tests/media/musicPlayer.test.ts ``` Expected: PASS. - [ ] **Step 5: Commit Task 2** Run: ```bash git add src/media/musicPlayer.ts tests/media/musicPlayer.test.ts git commit -m "fix: claim music playback ownership" ``` --- ### Task 3: Make Browser Audio Bridge Lazy and Owner-Aware **Files:** - Modify: `src/webserver.ts:282-412` - [ ] **Step 1: Update bridge start/control logic** In `src/webserver.ts`, remove the eager call: ```ts startBrowserAudioBridge(); ``` Replace `startBrowserAudioBridge` and `ensureBrowserAudioBridge` with: ```ts function startBrowserAudioBridge(): void { opusEncoder = new prism.opus.Encoder({ rate: RATE, channels: CHANNELS, frameSize: FRAME_SIZE, }); const oggBitstream = new prism.opus.OggLogicalBitstream({ opusHead: new prism.opus.OpusHead({ channelCount: CHANNELS, sampleRate: RATE, }), pageSizeControl: { maxPackets: 1 }, crc: true, }); opusEncoder.on("error", () => {}); opusEncoder.pipe(oggBitstream); opusEncoder.write(Buffer.alloc(BYTES_PER_FRAME, 0)); discordPlayer.playStream(oggBitstream, "browser-bridge"); discordPlayer.pause("browser-bridge"); bridgePlayerPaused = true; } function ensureBrowserAudioBridge(): boolean { const owner = discordPlayer.getOwner(); if (owner !== "none" && owner !== "browser-bridge") return false; if (owner === "none" || discordPlayer.getStatus() === AudioPlayerStatus.Idle) { startBrowserAudioBridge(); } return true; } ``` In the 20ms interval, replace: ```ts ensureBrowserAudioBridge(); if (bridgePlayerPaused) { const unpaused = discordPlayer.unpause(); ``` with: ```ts if (!ensureBrowserAudioBridge()) { pcmBuffer = Buffer.alloc(0); return; } if (bridgePlayerPaused) { const unpaused = discordPlayer.unpause("browser-bridge"); ``` Replace: ```ts discordPlayer.pause(); ``` with: ```ts discordPlayer.pause("browser-bridge"); ``` - [ ] **Step 2: Run typecheck** Run: ```bash pnpm run typecheck ``` Expected: PASS. If it fails because `opusEncoder` may be used before assignment, change its declaration to `let opusEncoder: prism.opus.Encoder | null = null;` and write with `opusEncoder?.write(frame)` guarded by `if (!opusEncoder) return;`. - [ ] **Step 3: Commit Task 3** Run: ```bash git add src/webserver.ts git commit -m "fix: isolate browser audio bridge ownership" ``` --- ### Task 4: Add Media Mode Parsing and Queue Support **Files:** - Modify: `src/media/mediaQueue.ts` - Modify: `src/media/mediaController.ts` - Modify: `src/routes/mediaRoutes.ts` - Modify: `tests/media/mediaController.test.ts` - Modify: `tests/routes/mediaRoutes.test.ts` - [ ] **Step 1: Write failing route test for mode** In `tests/routes/mediaRoutes.test.ts`, change the existing queue assertion to default mode: ```ts expect(controller.queue).toHaveBeenCalledWith("https://example.com/song.mp3", { mode: "music", }); ``` Add this test: ```ts it("queues a screen source", async () => { const state = { playing: true, activeMode: "screen" as const, current: null, queue: [] }; const controller = { getState: vi.fn(), queue: vi.fn(async () => state), skip: vi.fn(), stop: vi.fn(), }; const handler = getHandler( createMediaRoutes(controller), "/media/queue", "post", ); const json = vi.fn(); await handler?.( { body: { source: "https://youtu.be/video", mode: "screen" } } as Request, { json } as unknown as Response, vi.fn(), ); expect(controller.queue).toHaveBeenCalledWith("https://youtu.be/video", { mode: "screen", }); expect(json).toHaveBeenCalledWith(state); }); ``` - [ ] **Step 2: Run route test to verify it fails** Run: ```bash pnpm exec vitest run tests/routes/mediaRoutes.test.ts ``` Expected: FAIL because route does not pass mode/options. - [ ] **Step 3: Update media route parsing** Replace `MediaRouteController` and queue handler in `src/routes/mediaRoutes.ts` with: ```ts export type MediaRouteController = Pick< MediaController, "getState" | "queue" | "skip" | "stop" >; type MediaQueueBody = { source?: string; mode?: "music" | "screen"; }; ``` ```ts router.post("/media/queue", async (req, res, next) => { try { const { source, mode = "music" } = req.body as MediaQueueBody; if (!source) { throw new AppError( "Media source is required", "MISSING_MEDIA_SOURCE", 400, ); } if (mode !== "music" && mode !== "screen") { throw new AppError("Media mode is invalid", "INVALID_MEDIA_MODE", 400); } res.json(await controller.queue(source, { mode })); } catch (error) { next(error); } }); ``` - [ ] **Step 4: Update MediaQueue mode support** Replace `add` in `src/media/mediaQueue.ts` with: ```ts add( source: ResolvedMediaSource, mode: MediaQueueItem["mode"] = "music", requestedBy = "dashboard", ): MediaQueueItem { const item: MediaQueueItem = { id: this.createId(), mode, requestedBy, addedAt: this.now(), status: "queued", ...source, }; this.items.push(item); return { ...item }; } ``` - [ ] **Step 5: Update MediaController state and queue signature** In `src/media/mediaController.ts`, import `QueueMediaOptions` and update `getState` and `queue`: ```ts import type { MediaState, MusicPlayback, MusicPlayer, QueueMediaOptions, ResolvedMediaSource, } from "./mediaTypes"; ``` ```ts getState(): MediaState { const snapshot = this.queueStore.snapshot(); return { playing: snapshot.current?.status === "playing", activeMode: snapshot.current?.mode ?? null, ...snapshot, }; } async queue( source: string, options: QueueMediaOptions = {}, ): Promise { const mode = options.mode ?? "music"; this.assertCanStart(); const resolved = await ( this.dependencies.resolveMediaSource ?? resolveMediaSource )(source); this.queueStore.add(resolved, mode, options.requestedBy); this.startNextIfIdle(); return this.emitState(); } ``` - [ ] **Step 6: Update affected media controller expectations** In `tests/media/mediaController.test.ts`, update state equality in the stop test to: ```ts expect(state).toEqual({ playing: false, activeMode: null, current: null, queue: [], }); ``` No other expectations need full state equality. - [ ] **Step 7: Run route and media controller tests** Run: ```bash pnpm exec vitest run tests/routes/mediaRoutes.test.ts tests/media/mediaController.test.ts ``` Expected: PASS. - [ ] **Step 8: Commit Task 4** Run: ```bash git add src/media/mediaQueue.ts src/media/mediaController.ts src/routes/mediaRoutes.ts tests/media/mediaController.test.ts tests/routes/mediaRoutes.test.ts git commit -m "feat: add media mode routing" ``` --- ### Task 5: Add yt-dlp Direct Video URL Support **Files:** - Modify: `src/media/ytdlp.ts` - Modify: `tests/media/ytdlp.test.ts` - [ ] **Step 1: Write failing yt-dlp test** In `tests/media/ytdlp.test.ts`, add: ```ts it("gets a direct video URL", async () => { const spawn = createSpawn("https://cdn.example.com/video.mp4\n"); const ytdlp = createYtDlp({ spawn }); const result = await ytdlp.getDirectVideoUrl("https://youtu.be/video"); expect(result).toBe("https://cdn.example.com/video.mp4"); expect(spawn).toHaveBeenCalledWith( "yt-dlp", [ "https://youtu.be/video", "--get-url", "--format", "bestvideo[protocol^=http]+bestaudio[protocol^=http]/best[protocol^=http]/best", "--no-playlist", "--no-warnings", "--quiet", ], { stdio: ["ignore", "pipe", "pipe"] }, ); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: ```bash pnpm exec vitest run tests/media/ytdlp.test.ts ``` Expected: FAIL because `getDirectVideoUrl` does not exist. - [ ] **Step 3: Add direct video method** In `src/media/ytdlp.ts`, update `YtDlpClient`: ```ts export interface YtDlpClient { getMetadata(url: string): Promise; getDirectAudioUrl(url: string): Promise; getDirectVideoUrl(url: string): Promise; } ``` Add this method after `getDirectAudioUrl`: ```ts async getDirectVideoUrl(url: string): Promise { const value = await runYtDlp(spawn, [ url, "--get-url", "--format", "bestvideo[protocol^=http]+bestaudio[protocol^=http]/best[protocol^=http]/best", "--no-playlist", "--no-warnings", "--quiet", ]); return value.trim().split("\n")[0] || url; }, ``` - [ ] **Step 4: Run yt-dlp tests** Run: ```bash pnpm exec vitest run tests/media/ytdlp.test.ts ``` Expected: PASS. - [ ] **Step 5: Commit Task 5** Run: ```bash git add src/media/ytdlp.ts tests/media/ytdlp.test.ts git commit -m "feat: resolve direct video urls" ``` --- ### Task 6: Add ScreenShareController **Files:** - Create: `src/media/screenShareController.ts` - Create: `tests/media/screenShareController.test.ts` - [ ] **Step 1: Write failing screenshare controller tests** Create `tests/media/screenShareController.test.ts`: ```ts import { PassThrough } from "node:stream"; import { describe, expect, it, vi } from "vitest"; import { AppError } from "../../src/errors"; import { createScreenShareController } from "../../src/media/screenShareController"; function createDependencies() { const output = new PassThrough(); return { getVoiceStatus: vi.fn(() => ({ connected: true, activeGuildId: "guild-1", activeChannelId: "channel-1", })), getPlayerOwner: vi.fn(() => "none" as const), getDirectVideoUrl: vi.fn(async () => "https://cdn.example.com/video.mp4"), prepareStream: vi.fn(() => ({ command: { on: vi.fn(), kill: vi.fn() }, output })), playStream: vi.fn(async () => undefined), streamer: { id: "streamer" }, }; } describe("createScreenShareController", () => { it("starts a YouTube Go Live stream", async () => { const dependencies = createDependencies(); const controller = createScreenShareController(dependencies); const playback = await controller.start("https://youtu.be/video"); expect(dependencies.getDirectVideoUrl).toHaveBeenCalledWith( "https://youtu.be/video", ); expect(dependencies.prepareStream).toHaveBeenCalledWith( "https://cdn.example.com/video.mp4", expect.objectContaining({ includeAudio: true }), ); expect(dependencies.playStream).toHaveBeenCalledWith( dependencies.prepareStream.mock.results[0].value.output, dependencies.streamer, { type: "go-live" }, ); expect(controller.isActive()).toBe(true); playback.stop(); expect(controller.isActive()).toBe(false); }); it("rejects when voice is not connected", async () => { const dependencies = createDependencies(); dependencies.getVoiceStatus.mockReturnValue({ connected: false, activeGuildId: null, activeChannelId: null, }); const controller = createScreenShareController(dependencies); await expect(controller.start("https://youtu.be/video")).rejects.toMatchObject({ code: "VOICE_NOT_CONNECTED", statusCode: 409, } satisfies Partial); }); it("rejects when music owns the shared player", async () => { const dependencies = createDependencies(); dependencies.getPlayerOwner.mockReturnValue("music"); const controller = createScreenShareController(dependencies); await expect(controller.start("https://youtu.be/video")).rejects.toMatchObject({ code: "MEDIA_BUSY", statusCode: 409, } satisfies Partial); }); it("wraps stream startup failures", async () => { const dependencies = createDependencies(); dependencies.playStream.mockRejectedValue(new Error("go live failed")); const controller = createScreenShareController(dependencies); await expect(controller.start("https://youtu.be/video")).rejects.toMatchObject({ code: "SCREEN_STREAM_FAILED", statusCode: 500, } satisfies Partial); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: ```bash pnpm exec vitest run tests/media/screenShareController.test.ts ``` Expected: FAIL because `src/media/screenShareController.ts` does not exist. - [ ] **Step 3: Implement ScreenShareController** Create `src/media/screenShareController.ts`: ```ts import { Encoders, playStream as defaultPlayStream, prepareStream as defaultPrepareStream, Utils, } from "@dank074/discord-video-stream"; import { AppError } from "../errors"; import { discordPlayer } from "../player"; import type { DiscordPlayerOwner, ScreenSharePlayback, } from "./mediaTypes"; import { createYtDlp } from "./ytdlp"; export interface ScreenShareVoiceStatus { connected: boolean; activeGuildId: string | null; activeChannelId: string | null; } export interface ScreenShareControllerDependencies { getVoiceStatus: () => ScreenShareVoiceStatus; getPlayerOwner?: () => DiscordPlayerOwner; getDirectVideoUrl?: (source: string) => Promise; prepareStream?: typeof defaultPrepareStream; playStream?: typeof defaultPlayStream; streamer: unknown; } export function createScreenShareController( dependencies: ScreenShareControllerDependencies, ) { let active: ScreenSharePlayback | null = null; const ytdlp = createYtDlp(); const getPlayerOwner = dependencies.getPlayerOwner ?? (() => discordPlayer.getOwner()); const getDirectVideoUrl = dependencies.getDirectVideoUrl ?? ((source) => ytdlp.getDirectVideoUrl(source)); const prepareStream = dependencies.prepareStream ?? defaultPrepareStream; const playStream = dependencies.playStream ?? defaultPlayStream; return { isActive(): boolean { return active !== null; }, async start(source: string): Promise { const status = dependencies.getVoiceStatus(); if (!status.connected || !status.activeGuildId || !status.activeChannelId) { throw new AppError( "Connect to a voice channel before sharing screen", "VOICE_NOT_CONNECTED", 409, ); } if (active || getPlayerOwner() !== "none") { throw new AppError("Another media mode is active", "MEDIA_BUSY", 409); } try { const directUrl = await getDirectVideoUrl(source); const { command, output } = prepareStream(directUrl, { encoder: Encoders.software({ x264: { preset: "superfast" }, }), height: 720, fps: 30, bitrateVideo: 2500, bitrateVideoMax: 4000, includeAudio: true, videoCodec: Utils.normalizeVideoCodec("H264"), }); let stopped = false; const done = playStream(output, dependencies.streamer, { type: "go-live", }).finally(() => { active = null; }); active = { done, stop() { if (stopped) return; stopped = true; command.kill?.("SIGTERM"); active = null; }, }; return active; } catch (error) { active = null; throw new AppError( error instanceof Error ? error.message : "Screen stream failed", "SCREEN_STREAM_FAILED", 500, ); } }, }; } ``` - [ ] **Step 4: Run screenshare controller tests** Run: ```bash pnpm exec vitest run tests/media/screenShareController.test.ts ``` Expected: PASS. If TypeScript rejects the `streamer: unknown` type, replace it with `streamer: Parameters[1]` and cast the fake streamer in the test with `as Parameters[1]`. - [ ] **Step 5: Commit Task 6** Run: ```bash git add src/media/screenShareController.ts tests/media/screenShareController.test.ts git commit -m "feat: add youtube screenshare controller" ``` --- ### Task 7: Wire Screen Mode into MediaController and Webserver **Files:** - Modify: `src/media/mediaController.ts` - Modify: `src/webserver.ts` - Modify: `tests/media/mediaController.test.ts` - [ ] **Step 1: Write failing MediaController screen tests** In `tests/media/mediaController.test.ts`, update imports: ```ts import type { MusicPlayback, MusicPlayer, ResolvedMediaSource, ScreenShareController, } from "../../src/media/mediaTypes"; ``` Add tests before `emits state changes`: ```ts it("starts screen share mode without resolving music source", async () => { const screenPlayback = deferred(); const screenController: ScreenShareController = { isActive: vi.fn(() => false), start: vi.fn(async () => ({ done: screenPlayback.promise, stop: vi.fn() })), }; const resolveMediaSource = vi.fn(async (input) => source(input)); const controller = new MediaController({ isVoiceConnected: () => true, isBrowserStreaming: () => false, resolveMediaSource, musicPlayer: { play: vi.fn() }, screenController, }); const state = await controller.queue("https://youtu.be/video", { mode: "screen" }); expect(screenController.start).toHaveBeenCalledWith("https://youtu.be/video"); expect(resolveMediaSource).not.toHaveBeenCalled(); expect(state).toMatchObject({ playing: true, activeMode: "screen" }); }); it("rejects music while screen share is active", async () => { const screenController: ScreenShareController = { isActive: vi.fn(() => true), start: vi.fn(), }; const controller = new MediaController({ isVoiceConnected: () => true, isBrowserStreaming: () => false, resolveMediaSource: async (input) => source(input), musicPlayer: { play: vi.fn() }, screenController, }); await expect(controller.queue("https://example.com/song.mp3")).rejects.toMatchObject({ code: "MEDIA_BUSY", statusCode: 409, } satisfies Partial); }); ``` - [ ] **Step 2: Run controller tests to verify failure** Run: ```bash pnpm exec vitest run tests/media/mediaController.test.ts ``` Expected: FAIL because `screenController` dependency and screen mode are not implemented. - [ ] **Step 3: Add screen dependency and state to MediaController** In `src/media/mediaController.ts`, update imports: ```ts import type { MediaMode, MediaState, MusicPlayback, MusicPlayer, QueueMediaOptions, ResolvedMediaSource, ScreenShareController, ScreenSharePlayback, } from "./mediaTypes"; ``` Update dependencies: ```ts export interface MediaControllerDependencies { isVoiceConnected?: () => boolean; isBrowserStreaming?: () => boolean; resolveMediaSource?: (source: string) => Promise; musicPlayer?: MusicPlayer; screenController?: ScreenShareController; onStateChange?: (state: MediaState) => void; } ``` Add properties: ```ts private screenPlayback: ScreenSharePlayback | null = null; private activeMode: MediaMode | null = null; ``` Update `getState`: ```ts getState(): MediaState { const snapshot = this.queueStore.snapshot(); return { playing: this.activeMode === "screen" || snapshot.current?.status === "playing", activeMode: this.activeMode ?? snapshot.current?.mode ?? null, ...snapshot, }; } ``` Replace `queue` with: ```ts async queue( source: string, options: QueueMediaOptions = {}, ): Promise { const mode = options.mode ?? "music"; if (mode === "screen") { return this.startScreen(source); } this.assertCanStartMusic(); const resolved = await ( this.dependencies.resolveMediaSource ?? resolveMediaSource )(source); this.queueStore.add(resolved, mode, options.requestedBy); this.startNextIfIdle(); return this.emitState(); } ``` Rename `assertCanStart` to `assertCanStartMusic` and add screen busy check: ```ts private assertCanStartMusic(): void { const isVoiceConnected = this.dependencies.isVoiceConnected ?? (() => discordPlayer.isConnected()); if (!isVoiceConnected()) { throw new AppError( "Connect to a voice channel before playing media", "VOICE_NOT_CONNECTED", 409, ); } if (this.screenPlayback || this.dependencies.screenController?.isActive()) { throw new AppError("Another media mode is active", "MEDIA_BUSY", 409); } if (this.dependencies.isBrowserStreaming?.()) { throw new AppError( "Stop browser microphone streaming before playing media", "BROWSER_STREAM_ACTIVE", 409, ); } } ``` Add `startScreen`: ```ts private async startScreen(source: string): Promise { if (this.playback || this.queueStore.snapshot().current) { throw new AppError("Another media mode is active", "MEDIA_BUSY", 409); } const screenController = this.dependencies.screenController; if (!screenController) { throw new AppError("Screen sharing is unavailable", "SCREEN_UNAVAILABLE", 500); } this.activeMode = "screen"; try { this.screenPlayback = await screenController.start(source); } catch (error) { this.activeMode = null; throw error; } this.screenPlayback.done.then( () => this.finishScreen(), () => this.finishScreen(), ); return this.emitState(); } private finishScreen(): void { this.screenPlayback = null; if (this.activeMode === "screen") { this.activeMode = null; } this.emitState(); } ``` Update `stop` to stop screen too: ```ts async stop(): Promise { this.playbackToken++; this.playback?.stop(); this.playback = null; this.screenPlayback?.stop(); this.screenPlayback = null; this.activeMode = null; this.queueStore.clear(); return this.emitState(); } ``` - [ ] **Step 4: Wire controller in webserver** In `src/webserver.ts`, add imports: ```ts import { Streamer } from "@dank074/discord-video-stream"; import { createScreenShareController } from "./media/screenShareController"; ``` Before `const mediaController = new MediaController({`, add: ```ts const streamer = new Streamer(_client); const screenController = createScreenShareController({ getVoiceStatus: () => voiceController.getStatus(), streamer, }); ``` Update MediaController dependencies: ```ts const mediaController = new MediaController({ isVoiceConnected: () => voiceController.getStatus().connected, isBrowserStreaming: () => sharedUIState.isStreaming, screenController, onStateChange: (state) => broadcaster.mediaState(state), }); ``` - [ ] **Step 5: Run controller tests and typecheck** Run: ```bash pnpm exec vitest run tests/media/mediaController.test.ts pnpm run typecheck ``` Expected: both PASS. - [ ] **Step 6: Commit Task 7** Run: ```bash git add src/media/mediaController.ts src/webserver.ts tests/media/mediaController.test.ts git commit -m "feat: wire screen mode into media controller" ``` --- ### Task 8: Final Verification **Files:** - All changed implementation and test files. - [ ] **Step 1: Run focused media tests** Run: ```bash pnpm exec vitest run tests/player.test.ts tests/media/musicPlayer.test.ts tests/media/mediaController.test.ts tests/routes/mediaRoutes.test.ts tests/media/ytdlp.test.ts tests/media/screenShareController.test.ts ``` Expected: PASS. - [ ] **Step 2: Run full test suite** Run: ```bash pnpm run test ``` Expected: PASS. - [ ] **Step 3: Run typecheck** Run: ```bash pnpm run typecheck ``` Expected: PASS. - [ ] **Step 4: Run lint** Run: ```bash pnpm run lint ``` Expected: PASS. - [ ] **Step 5: Check git status** Run: ```bash git status --short ``` Expected: clean or only intentional uncommitted planning/spec files if the user requested no commits. - [ ] **Step 6: Report result** Report exact verification commands and outcomes. Do not claim completion unless all commands above pass.