ext: content/ media detection (build order step 7)

Two independent signals feed one panel, per docs/05 §3:
- capture/media.ts (background): webRequest-based, recognizes .m3u8/.mpd
  URLs and the HLS/DASH content types, deduped per (tab, url) so an
  HLS live-refresh doesn't re-fire. media-bridge.ts relays a hit to the
  tab's content script over runtime.sendMessage, and separately answers
  the content script's media.listVariants/media.addVariant calls by
  forwarding them to the background page's transport.
- content/media-observer.ts: watches the page's own <video> elements
  (present now, added later, or with src changed) for the same
  extension signal, independent of what the network sniffer saw.

Either firing opens content/video-panel.ts's "Download this video ▾"
panel (built with createElement, matching the popup's innerHTML-free
approach), which lists variants from media.listVariants and greys out
any variant.drm or a wholly drmProtected manifest with "Protected
content" rather than attempting it. The extension still never parses a
manifest itself — that stays in the daemon, one language, one place.

content/index.ts is the manifest-registered entry (content_scripts in
manifest.json, added in the previous commit); build.mjs builds it as an
IIFE rather than ESM, since a manifest content script has no "type":
"module" declaration and an emitted top-level export would be a syntax
error there. tsconfig.json adds DOM.Iterable for NodeList iteration.

docs/05-extension-spec.md gets a short addendum (§8) documenting the
popup/options bridge and this media-detection split, since neither was
in the original design write-up.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Ed8KEmAW48v4YHdxLtqsMB
This commit is contained in:
2026-09-11 12:26:48 +04:00
co-authored by Claude Sonnet 5
parent 55932a2e11
commit 03b6253b5a
11 changed files with 787 additions and 2 deletions
+107
View File
@@ -0,0 +1,107 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { VideoPanel, type VariantsPort } from '../../src/content/video-panel.js';
import type { MediaVariant } from '../../src/shared/protocol/index.js';
function variant(over: Partial<MediaVariant> = {}): MediaVariant {
return { variantId: 'v1', kind: 'muxed', drm: false, ...over };
}
describe('VideoPanel', () => {
beforeEach(() => {
document.body.innerHTML = '';
});
it('renders a button and an initially-hidden menu', () => {
const port: VariantsPort = { listVariants: vi.fn(), addVariant: vi.fn() };
const panel = new VideoPanel(port);
panel.show('https://cdn.example/m.m3u8');
const button = document.querySelector('#velox-video-panel button')!;
expect(button.textContent).toBe('Download this video ▾');
expect((document.querySelector('#velox-video-panel ul') as HTMLUListElement).hidden).toBe(true);
});
it('lists variants on click, skipping the daemon\'s manifest parsing entirely', async () => {
const listVariants = vi.fn(async () => ({
variants: [variant({ variantId: 'v-1080', resolution: '1920x1080' })],
drmProtected: false,
}));
const port: VariantsPort = { listVariants, addVariant: vi.fn() };
const panel = new VideoPanel(port);
panel.show('https://cdn.example/m.m3u8');
document.querySelector('#velox-video-panel button')!.dispatchEvent(new Event('click'));
await vi.waitFor(() => expect(listVariants).toHaveBeenCalledWith('https://cdn.example/m.m3u8'));
await new Promise((r) => setTimeout(r, 0));
const items = document.querySelectorAll('#velox-video-panel li');
expect(items.length).toBe(1);
expect(items[0]!.textContent).toContain('1920x1080');
});
it('greys out a DRM variant with "Protected content" and does not wire a click handler', async () => {
const listVariants = vi.fn(async () => ({
variants: [variant({ variantId: 'v-drm', drm: true, resolution: '1920x1080' })],
drmProtected: false,
}));
const addVariant = vi.fn();
const panel = new VideoPanel({ listVariants, addVariant });
panel.show('https://cdn.example/m.m3u8');
document.querySelector('#velox-video-panel button')!.dispatchEvent(new Event('click'));
await new Promise((r) => setTimeout(r, 0));
const item = document.querySelector<HTMLButtonElement>('#velox-video-panel li button')!;
expect(item.disabled).toBe(true);
expect(item.textContent).toContain('Protected content');
item.dispatchEvent(new Event('click'));
expect(addVariant).not.toHaveBeenCalled();
});
it('shows "Protected content" for a wholly DRM-protected manifest', async () => {
const listVariants = vi.fn(async () => ({ variants: [variant()], drmProtected: true }));
const panel = new VideoPanel({ listVariants, addVariant: vi.fn() });
panel.show('https://cdn.example/m.m3u8');
document.querySelector('#velox-video-panel button')!.dispatchEvent(new Event('click'));
await new Promise((r) => setTimeout(r, 0));
expect(document.querySelector('#velox-video-panel li')!.textContent).toBe('Protected content');
});
it('calls addVariant when a non-DRM item is clicked', async () => {
const listVariants = vi.fn(async () => ({ variants: [variant({ variantId: 'v-720' })], drmProtected: false }));
const addVariant = vi.fn(async () => ({ taskId: 't1' }));
const panel = new VideoPanel({ listVariants, addVariant });
panel.show('https://cdn.example/m.m3u8');
document.querySelector('#velox-video-panel button')!.dispatchEvent(new Event('click'));
await new Promise((r) => setTimeout(r, 0));
document.querySelector<HTMLButtonElement>('#velox-video-panel li button')!.dispatchEvent(new Event('click'));
expect(addVariant).toHaveBeenCalledWith('https://cdn.example/m.m3u8', 'v-720');
});
it('surfaces an error from listVariants instead of an empty menu', async () => {
const listVariants = vi.fn(async () => ({ error: 'refused: not a manifest' }));
const panel = new VideoPanel({ listVariants, addVariant: vi.fn() });
panel.show('https://cdn.example/m.m3u8');
document.querySelector('#velox-video-panel button')!.dispatchEvent(new Event('click'));
await new Promise((r) => setTimeout(r, 0));
expect(document.querySelector('#velox-video-panel li')!.textContent).toContain('refused: not a manifest');
});
it('show() replaces a previous panel rather than stacking them', () => {
const port: VariantsPort = { listVariants: vi.fn(), addVariant: vi.fn() };
const panel = new VideoPanel(port);
panel.show('https://cdn.example/a.m3u8');
panel.show('https://cdn.example/b.m3u8');
expect(document.querySelectorAll('#velox-video-panel')).toHaveLength(1);
});
});