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:
@@ -0,0 +1,102 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user