Files
vdm/extension/src/content/video-panel.ts
T
samiandClaude Sonnet 5 03b6253b5a 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
2026-09-11 12:26:48 +04:00

103 lines
3.3 KiB
TypeScript

// In-page "Download this video ▾" panel (docs/05 §3). Lists variants the daemon
// enumerated via media.listVariants — the extension never parses the manifest itself.
// DRM-protected variants (and a wholly DRM-protected manifest) are shown greyed out
// with "Protected content" rather than attempted.
import type { MediaVariant } from '../shared/protocol/index.js';
export interface VariantsPort {
listVariants(manifestUrl: string): Promise<{ variants: MediaVariant[]; drmProtected: boolean } | { error: string }>;
addVariant(manifestUrl: string, variantId: string): Promise<{ taskId: string } | { error: string }>;
}
function variantLabel(v: MediaVariant): string {
const bits = [v.resolution, v.codec, v.bitrateBps ? `${Math.round(v.bitrateBps / 1000)} kbps` : null].filter(Boolean);
return bits.length > 0 ? bits.join(' · ') : v.variantId;
}
const PANEL_ID = 'velox-video-panel';
export class VideoPanel {
private root: HTMLElement | null = null;
constructor(
private readonly port: VariantsPort,
private readonly doc: Document = document,
) {}
/** Shows (or replaces) the floating panel for one detected manifest URL. */
show(manifestUrl: string): void {
this.remove();
const root = this.doc.createElement('div');
root.id = PANEL_ID;
Object.assign(root.style, {
position: 'fixed',
bottom: '16px',
right: '16px',
zIndex: '2147483647',
font: '13px sans-serif',
} satisfies Partial<CSSStyleDeclaration>);
const button = this.doc.createElement('button');
button.textContent = 'Download this video ▾';
root.appendChild(button);
const menu = this.doc.createElement('ul');
menu.hidden = true;
Object.assign(menu.style, { listStyle: 'none', margin: '4px 0 0', padding: '4px' } satisfies Partial<CSSStyleDeclaration>);
root.appendChild(menu);
button.addEventListener('click', () => {
menu.hidden = !menu.hidden;
if (!menu.hidden) void this.populate(menu, manifestUrl);
});
this.doc.body.appendChild(root);
this.root = root;
}
remove(): void {
this.root?.remove();
this.root = null;
}
private async populate(menu: HTMLUListElement, manifestUrl: string): Promise<void> {
menu.replaceChildren(this.loadingItem());
const result = await this.port.listVariants(manifestUrl);
if ('error' in result) {
menu.replaceChildren(this.messageItem(`Could not read variants: ${result.error}`));
return;
}
if (result.drmProtected || result.variants.length === 0) {
menu.replaceChildren(this.messageItem(result.drmProtected ? 'Protected content' : 'No downloadable variants found'));
return;
}
menu.replaceChildren(
...result.variants.map((v) => {
const li = this.doc.createElement('li');
const item = this.doc.createElement('button');
item.textContent = v.drm ? `${variantLabel(v)} — Protected content` : variantLabel(v);
item.disabled = v.drm;
if (!v.drm) {
item.addEventListener('click', () => void this.port.addVariant(manifestUrl, v.variantId));
}
li.appendChild(item);
return li;
}),
);
}
private loadingItem(): HTMLLIElement {
return this.messageItem('Loading…');
}
private messageItem(text: string): HTMLLIElement {
const li = this.doc.createElement('li');
li.textContent = text;
return li;
}
}