Files
dc-recorder/docs/superpowers/plans/2026-05-16-media-echo-screenshare.md
MythEclipse d50ce8698f feat: implement media echo fix and YouTube screenshare design
- Introduced a new `ScreenShareController` to manage YouTube screenshare functionality.
- Updated `DiscordPlayer` to track ownership of audio streams, preventing conflicts between music playback and screenshare.
- Added error handling for various states including voice connection checks and media busy states.
- Created unit tests for `ScreenShareController` and `DiscordPlayer` ownership rules to ensure correct functionality.
- Added documentation for the new media echo fix and screenshare design.
2026-05-16 15:48:28 +07:00

35 KiB

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:

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:

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:

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<void>;
  stop(): void;
}

export interface MusicPlayer {
  play(source: ResolvedMediaSource): MusicPlayback;
}

export interface ScreenSharePlayback {
  done: Promise<void>;
  stop(): void;
}

export interface ScreenShareController {
  isActive(): boolean;
  start(source: string): Promise<ScreenSharePlayback>;
}

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:

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:

pnpm exec vitest run tests/player.test.ts

Expected: PASS.

  • Step 6: Commit Task 1

Run:

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:

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:

expect(discordPlayer.playStream).toHaveBeenCalledWith(proc.stdout, "music");

Change the stop assertion to:

expect(discordPlayer.stop).toHaveBeenCalledWith("music");

Add this test before the closing });:

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:

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:

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<void>((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:

pnpm exec vitest run tests/media/musicPlayer.test.ts

Expected: PASS.

  • Step 5: Commit Task 2

Run:

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:

startBrowserAudioBridge();

Replace startBrowserAudioBridge and ensureBrowserAudioBridge with:

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:

ensureBrowserAudioBridge();
if (bridgePlayerPaused) {
  const unpaused = discordPlayer.unpause();

with:

if (!ensureBrowserAudioBridge()) {
  pcmBuffer = Buffer.alloc(0);
  return;
}
if (bridgePlayerPaused) {
  const unpaused = discordPlayer.unpause("browser-bridge");

Replace:

discordPlayer.pause();

with:

discordPlayer.pause("browser-bridge");
  • Step 2: Run typecheck

Run:

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:

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:

expect(controller.queue).toHaveBeenCalledWith("https://example.com/song.mp3", {
  mode: "music",
});

Add this test:

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:

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:

export type MediaRouteController = Pick<
  MediaController,
  "getState" | "queue" | "skip" | "stop"
>;

type MediaQueueBody = {
  source?: string;
  mode?: "music" | "screen";
};
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:

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:

import type {
  MediaState,
  MusicPlayback,
  MusicPlayer,
  QueueMediaOptions,
  ResolvedMediaSource,
} from "./mediaTypes";
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<MediaState> {
  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:

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:

pnpm exec vitest run tests/routes/mediaRoutes.test.ts tests/media/mediaController.test.ts

Expected: PASS.

  • Step 8: Commit Task 4

Run:

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:

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:

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:

export interface YtDlpClient {
  getMetadata(url: string): Promise<YtDlpMetadata>;
  getDirectAudioUrl(url: string): Promise<string>;
  getDirectVideoUrl(url: string): Promise<string>;
}

Add this method after getDirectAudioUrl:

async getDirectVideoUrl(url: string): Promise<string> {
  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:

pnpm exec vitest run tests/media/ytdlp.test.ts

Expected: PASS.

  • Step 5: Commit Task 5

Run:

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:

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<AppError>);
  });

  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<AppError>);
  });

  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<AppError>);
  });
});
  • Step 2: Run test to verify it fails

Run:

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:

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<string>;
  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<ScreenSharePlayback> {
      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:

pnpm exec vitest run tests/media/screenShareController.test.ts

Expected: PASS. If TypeScript rejects the streamer: unknown type, replace it with streamer: Parameters<typeof defaultPlayStream>[1] and cast the fake streamer in the test with as Parameters<typeof playStream>[1].

  • Step 5: Commit Task 6

Run:

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:

import type {
  MusicPlayback,
  MusicPlayer,
  ResolvedMediaSource,
  ScreenShareController,
} from "../../src/media/mediaTypes";

Add tests before emits state changes:

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<AppError>);
});
  • Step 2: Run controller tests to verify failure

Run:

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:

import type {
  MediaMode,
  MediaState,
  MusicPlayback,
  MusicPlayer,
  QueueMediaOptions,
  ResolvedMediaSource,
  ScreenShareController,
  ScreenSharePlayback,
} from "./mediaTypes";

Update dependencies:

export interface MediaControllerDependencies {
  isVoiceConnected?: () => boolean;
  isBrowserStreaming?: () => boolean;
  resolveMediaSource?: (source: string) => Promise<ResolvedMediaSource>;
  musicPlayer?: MusicPlayer;
  screenController?: ScreenShareController;
  onStateChange?: (state: MediaState) => void;
}

Add properties:

private screenPlayback: ScreenSharePlayback | null = null;
private activeMode: MediaMode | null = null;

Update getState:

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:

async queue(
  source: string,
  options: QueueMediaOptions = {},
): Promise<MediaState> {
  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:

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:

private async startScreen(source: string): Promise<MediaState> {
  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:

async stop(): Promise<MediaState> {
  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:

import { Streamer } from "@dank074/discord-video-stream";
import { createScreenShareController } from "./media/screenShareController";

Before const mediaController = new MediaController({, add:

const streamer = new Streamer(_client);
const screenController = createScreenShareController({
  getVoiceStatus: () => voiceController.getStatus(),
  streamer,
});

Update MediaController dependencies:

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:

pnpm exec vitest run tests/media/mediaController.test.ts
pnpm run typecheck

Expected: both PASS.

  • Step 6: Commit Task 7

Run:

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:

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:

pnpm run test

Expected: PASS.

  • Step 3: Run typecheck

Run:

pnpm run typecheck

Expected: PASS.

  • Step 4: Run lint

Run:

pnpm run lint

Expected: PASS.

  • Step 5: Check git status

Run:

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.