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
103 lines
3.3 KiB
TypeScript
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;
|
|
}
|
|
}
|