Working
This commit is contained in:
65
README.md
Normal file
65
README.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# Firefox Splice Download Helper
|
||||||
|
|
||||||
|
This Firefox extension captures Splice sample preview context from GraphQL responses, then attempts to download full-file URLs when available. If full retrieval is unavailable, it falls back to decoding preview audio bytes (splicedd-style) and downloads decoded MP3.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Captures sample context from Splice GraphQL responses (`preview_mp3`, sample UUID/hash).
|
||||||
|
- Tracks observed audio sample URLs from network/probe fallback.
|
||||||
|
- Download action is orchestrated as:
|
||||||
|
1. Attempt full-file URL retrieval first.
|
||||||
|
2. If unavailable, decode scrambled preview bytes and download decoded MP3.
|
||||||
|
- Injects two controls in the target row:
|
||||||
|
- **Download**: full-first, decoded-preview fallback
|
||||||
|
- **History**: opens extension history page with past captured URLs
|
||||||
|
- Popup/history show each captured sample with source and mode hints.
|
||||||
|
|
||||||
|
## Targeted Page Element
|
||||||
|
|
||||||
|
Inline controls are injected into:
|
||||||
|
|
||||||
|
- `.playbar-playback-info-bar.playback-row-section.svelte-11gjrk3`
|
||||||
|
|
||||||
|
The inserted controls use sibling-style wrappers based on:
|
||||||
|
|
||||||
|
- `bordered-item meta-item svelte-11gjrk3`
|
||||||
|
|
||||||
|
## URL Rules
|
||||||
|
|
||||||
|
- Page scope: `*://*.splice.com/*`
|
||||||
|
- Audio sample paths include `/audio_samples/`
|
||||||
|
- Waveform files (`.wv.json`) are excluded from download candidates
|
||||||
|
|
||||||
|
## Load in Firefox (Temporary Add-on)
|
||||||
|
|
||||||
|
1. Open `about:debugging#/runtime/this-firefox`
|
||||||
|
2. Click **Load Temporary Add-on...**
|
||||||
|
3. Select this extension's `manifest.json`
|
||||||
|
4. Visit a matching `*.splice.com` page
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. Open a page under `*.splice.com` that loads S3 audio links.
|
||||||
|
2. Wait for inline `Download` and `History` controls to appear in the playback info bar.
|
||||||
|
3. Click:
|
||||||
|
- **Download** to run full-first retrieval, then decode fallback if needed
|
||||||
|
- **History** to open full captured history
|
||||||
|
4. Optionally click the extension icon to open popup for current-tab entries.
|
||||||
|
|
||||||
|
## Manual Verification Checklist
|
||||||
|
|
||||||
|
- [ ] On matching domain, controls appear in target playback row.
|
||||||
|
- [ ] Controls remain after SPA/Svelte rerenders.
|
||||||
|
- [ ] GraphQL preview context is captured for visible samples.
|
||||||
|
- [ ] `Download` attempts full retrieval first.
|
||||||
|
- [ ] When full retrieval is unavailable, fallback decoded MP3 is downloaded and playable.
|
||||||
|
- [ ] `History` opens and allows downloading older entries.
|
||||||
|
- [ ] Popup updates with captured entries for active tab.
|
||||||
|
- [ ] Non-`*.splice.com` pages do not capture/store URLs.
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
- Full-file URL retrieval depends on backend behavior and session state; it may fail for some assets.
|
||||||
|
- Fallback decode uses scrambled preview streams, so fallback output follows preview-source quality/availability.
|
||||||
|
- Signed preview URLs can expire; expired URLs require recapture.
|
||||||
|
- `saveAs: true` prompts download location each time by design.
|
||||||
553
background.js
Normal file
553
background.js
Normal file
@@ -0,0 +1,553 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const TAB_CAPTURED = new Map();
|
||||||
|
const FULL_URL_BY_SAMPLE = new Map();
|
||||||
|
const HISTORY_KEY = "capturedS3History";
|
||||||
|
const MAX_HISTORY_ITEMS = 500;
|
||||||
|
const SPLICE_DOMAIN_RE = /^https?:\/\/([a-z0-9-]+\.)*splice\.com(\/|$)/i;
|
||||||
|
const AUDIO_SAMPLE_PATH_RE = /\/audio_samples\//i;
|
||||||
|
const WAVEFORM_PATH_RE = /\.wv\.json$/i;
|
||||||
|
const SCRAMBLED_PATH_RE = /-scrambled\//i;
|
||||||
|
const HASH_RE = /^[a-f0-9]{64}$/i;
|
||||||
|
const GRAPHQL_URL = "https://surfaces-graphql.splice.com/graphql";
|
||||||
|
|
||||||
|
function isDomainAllowed(pageUrl) {
|
||||||
|
return typeof pageUrl === "string" && SPLICE_DOMAIN_RE.test(pageUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isS3Url(url) {
|
||||||
|
if (typeof url !== "string") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (parsed.protocol !== "https:") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const host = parsed.hostname.toLowerCase();
|
||||||
|
const isAwsHost = host.endsWith(".amazonaws.com") || host.endsWith(".amazonaws.com.cn");
|
||||||
|
const isS3Host = host.includes(".s3.") || host.startsWith("s3.");
|
||||||
|
return isAwsHost && isS3Host;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAudioSampleUrl(url) {
|
||||||
|
if (!isS3Url(url)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return AUDIO_SAMPLE_PATH_RE.test(parsed.pathname || "");
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWaveformUrl(url) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return WAVEFORM_PATH_RE.test(parsed.pathname || "");
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPreviewUrl(url) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return SCRAMBLED_PATH_RE.test(parsed.pathname || "");
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFullUrl(url) {
|
||||||
|
return isAudioSampleUrl(url) && !isWaveformUrl(url) && !isPreviewUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUrl(url) {
|
||||||
|
try {
|
||||||
|
return new URL(url).toString();
|
||||||
|
} catch (error) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSampleIdFromUrl(url) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const path = parsed.pathname || "";
|
||||||
|
const marker = "/audio_samples/";
|
||||||
|
const idx = path.indexOf(marker);
|
||||||
|
if (idx < 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const rest = path.slice(idx + marker.length);
|
||||||
|
const parts = rest.split("/").filter(Boolean);
|
||||||
|
const first = parts[0] || "";
|
||||||
|
const second = parts[1] || "";
|
||||||
|
|
||||||
|
const firstNormalized = first.replace(/-scrambled$/i, "");
|
||||||
|
if (HASH_RE.test(firstNormalized)) {
|
||||||
|
return firstNormalized.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const secondStem = second.split(".")[0];
|
||||||
|
if (HASH_RE.test(secondStem)) {
|
||||||
|
return secondStem.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (HASH_RE.test(first)) {
|
||||||
|
return first.toLowerCase();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
} catch (error) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureTabState(tabId) {
|
||||||
|
if (!TAB_CAPTURED.has(tabId)) {
|
||||||
|
TAB_CAPTURED.set(tabId, {
|
||||||
|
latestSampleId: "",
|
||||||
|
entriesBySample: new Map()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return TAB_CAPTURED.get(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tabIdFromSender(sender) {
|
||||||
|
return sender && sender.tab && typeof sender.tab.id === "number" ? sender.tab.id : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeClearTabState(tabId) {
|
||||||
|
if (typeof tabId === "number" && tabId >= 0) {
|
||||||
|
TAB_CAPTURED.delete(tabId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHistoryRow(entry) {
|
||||||
|
return {
|
||||||
|
sampleId: entry.sampleId || "",
|
||||||
|
previewUrl: entry.previewUrl || "",
|
||||||
|
fullUrl: entry.fullUrl || "",
|
||||||
|
url: entry.fullUrl || entry.previewUrl || "",
|
||||||
|
source: entry.source || "",
|
||||||
|
capturedAt: entry.capturedAt || Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeEntry(tabState, inputEntry, source) {
|
||||||
|
const sampleId = inputEntry.sampleId || extractSampleIdFromUrl(inputEntry.previewUrl || inputEntry.fullUrl || "");
|
||||||
|
if (!sampleId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = tabState.entriesBySample.get(sampleId) || {
|
||||||
|
sampleId,
|
||||||
|
previewUrl: "",
|
||||||
|
fullUrl: "",
|
||||||
|
source: source || "unknown",
|
||||||
|
capturedAt: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
const merged = {
|
||||||
|
...existing,
|
||||||
|
sampleId,
|
||||||
|
source: source || existing.source || "unknown",
|
||||||
|
capturedAt: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (inputEntry.previewUrl) {
|
||||||
|
merged.previewUrl = inputEntry.previewUrl;
|
||||||
|
}
|
||||||
|
if (inputEntry.fullUrl) {
|
||||||
|
merged.fullUrl = inputEntry.fullUrl;
|
||||||
|
FULL_URL_BY_SAMPLE.set(sampleId, inputEntry.fullUrl);
|
||||||
|
} else if (!merged.fullUrl && FULL_URL_BY_SAMPLE.has(sampleId)) {
|
||||||
|
merged.fullUrl = FULL_URL_BY_SAMPLE.get(sampleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
tabState.entriesBySample.set(sampleId, merged);
|
||||||
|
tabState.latestSampleId = sampleId;
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertPreviewContext(tabId, context, source) {
|
||||||
|
const tabState = ensureTabState(tabId);
|
||||||
|
const previewUrl = normalizeUrl(context.previewUrl || "");
|
||||||
|
if (!previewUrl || !isAudioSampleUrl(previewUrl) || isWaveformUrl(previewUrl)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const sampleId = (context.sampleId || extractSampleIdFromUrl(previewUrl)).toLowerCase();
|
||||||
|
if (!sampleId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return mergeEntry(
|
||||||
|
tabState,
|
||||||
|
{ sampleId, previewUrl, fullUrl: context.fullUrl ? normalizeUrl(context.fullUrl) : "" },
|
||||||
|
source || "graphql_preview"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertUrl(tabId, url, source) {
|
||||||
|
const normalized = normalizeUrl(url);
|
||||||
|
if (!normalized || !isAudioSampleUrl(normalized) || isWaveformUrl(normalized)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleId = extractSampleIdFromUrl(normalized);
|
||||||
|
if (!sampleId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = {
|
||||||
|
sampleId,
|
||||||
|
previewUrl: isPreviewUrl(normalized) ? normalized : "",
|
||||||
|
fullUrl: isFullUrl(normalized) ? normalized : ""
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof tabId === "number" && tabId >= 0) {
|
||||||
|
return mergeEntry(ensureTabState(tabId), entry, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.fullUrl) {
|
||||||
|
FULL_URL_BY_SAMPLE.set(sampleId, entry.fullUrl);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function appendHistoryRows(rows) {
|
||||||
|
if (!rows.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await browser.storage.local.get(HISTORY_KEY);
|
||||||
|
const current = Array.isArray(data[HISTORY_KEY]) ? data[HISTORY_KEY] : [];
|
||||||
|
const combined = [...current];
|
||||||
|
const seen = new Set(current.map((item) => `${item.sampleId}|${item.url}`));
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = `${row.sampleId}|${row.url}`;
|
||||||
|
if (!row.url || seen.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
combined.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.storage.local.set({ [HISTORY_KEY]: combined.slice(-MAX_HISTORY_ITEMS) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodePass(startIdx, arr, encodeBlk, endIdx) {
|
||||||
|
let blockIdx = 0;
|
||||||
|
for (let i = startIdx; i < endIdx; i += 1) {
|
||||||
|
if (blockIdx > encodeBlk.length - 1) {
|
||||||
|
blockIdx = 0;
|
||||||
|
}
|
||||||
|
arr[i] = arr[i] ^ encodeBlk.charCodeAt(blockIdx);
|
||||||
|
blockIdx += 1;
|
||||||
|
}
|
||||||
|
return endIdx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeSpliceAudio(data) {
|
||||||
|
const sizeData = Array.from(data.subarray(2, 10));
|
||||||
|
let size = 0;
|
||||||
|
for (let i = sizeData.length - 1; i >= 0; i -= 1) {
|
||||||
|
size = (256 * size) + sizeData[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodingData = data.subarray(10, 28);
|
||||||
|
const chunks = [];
|
||||||
|
for (let i = 0; i < encodingData.length; i += 32768) {
|
||||||
|
chunks.push(String.fromCharCode(...Array.from(encodingData.subarray(i, i + 32768))));
|
||||||
|
}
|
||||||
|
const encodeBlk = chunks.join("");
|
||||||
|
const audioData = data.slice(28);
|
||||||
|
|
||||||
|
const passIdx = decodePass(0, audioData, encodeBlk, size) + size;
|
||||||
|
decodePass(passIdx, audioData, encodeBlk, passIdx + size);
|
||||||
|
return audioData;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveFilename(entry, mode) {
|
||||||
|
const base = entry.sampleId || `splice-${Date.now()}`;
|
||||||
|
if (mode === "full") {
|
||||||
|
const full = entry.fullUrl || "";
|
||||||
|
if (full.endsWith(".wav") || full.includes(".wav?")) {
|
||||||
|
return `${base}.wav`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${base}-decoded.mp3`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadDirectUrl(url, entry) {
|
||||||
|
const filename = deriveFilename(entry, "full");
|
||||||
|
await browser.downloads.download({
|
||||||
|
url,
|
||||||
|
saveAs: true,
|
||||||
|
filename,
|
||||||
|
conflictAction: "uniquify"
|
||||||
|
});
|
||||||
|
return { ok: true, mode: "full", filename };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadDecodedPreview(previewUrl, entry) {
|
||||||
|
const response = await fetch(previewUrl, { credentials: "include" });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Preview fetch failed (${response.status}).`);
|
||||||
|
}
|
||||||
|
const raw = new Uint8Array(await response.arrayBuffer());
|
||||||
|
const decoded = decodeSpliceAudio(raw);
|
||||||
|
const blob = new Blob([decoded], { type: "audio/mpeg" });
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
const filename = deriveFilename(entry, "decoded");
|
||||||
|
try {
|
||||||
|
await browser.downloads.download({
|
||||||
|
url: objectUrl,
|
||||||
|
saveAs: true,
|
||||||
|
filename,
|
||||||
|
conflictAction: "uniquify"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30000);
|
||||||
|
}
|
||||||
|
return { ok: true, mode: "decoded", filename };
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAudioUrlsFromObject(input, output) {
|
||||||
|
if (!input) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof input === "string") {
|
||||||
|
const normalized = normalizeUrl(input);
|
||||||
|
if (normalized && isFullUrl(normalized)) {
|
||||||
|
output.add(normalized);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(input)) {
|
||||||
|
for (const item of input) {
|
||||||
|
extractAudioUrlsFromObject(item, output);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof input === "object") {
|
||||||
|
for (const value of Object.values(input)) {
|
||||||
|
extractAudioUrlsFromObject(value, output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryBackendForFullUrl(entry) {
|
||||||
|
const sampleId = entry.sampleId || "";
|
||||||
|
if (!sampleId) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (FULL_URL_BY_SAMPLE.has(sampleId)) {
|
||||||
|
return FULL_URL_BY_SAMPLE.get(sampleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
operationName: "SampleLookupForDownload",
|
||||||
|
query:
|
||||||
|
"query SampleLookupForDownload($query: String!, $limit: Int = 5) {" +
|
||||||
|
" assetsSearch(filter: {legacy: true, published: true, asset_type_slug: sample, query: $query}," +
|
||||||
|
" pagination: {page: 1, limit: $limit}, sort: {sort: relevance, order: DESC}) {" +
|
||||||
|
" items { ... on IAsset { uuid files { asset_file_type_slug url path } } } } }",
|
||||||
|
variables: { query: sampleId, limit: 5 }
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(GRAPHQL_URL, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const json = await response.json();
|
||||||
|
const urls = new Set();
|
||||||
|
extractAudioUrlsFromObject(json, urls);
|
||||||
|
for (const url of urls) {
|
||||||
|
const id = extractSampleIdFromUrl(url);
|
||||||
|
if (id === sampleId && isFullUrl(url)) {
|
||||||
|
FULL_URL_BY_SAMPLE.set(sampleId, url);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
} catch (error) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveAndDownloadEntry(entryInput) {
|
||||||
|
const entry = entryInput || {};
|
||||||
|
const sampleId = entry.sampleId || extractSampleIdFromUrl(entry.previewUrl || entry.fullUrl || "");
|
||||||
|
const normalizedEntry = {
|
||||||
|
sampleId,
|
||||||
|
previewUrl: normalizeUrl(entry.previewUrl || ""),
|
||||||
|
fullUrl: normalizeUrl(entry.fullUrl || "")
|
||||||
|
};
|
||||||
|
|
||||||
|
if (normalizedEntry.fullUrl && isFullUrl(normalizedEntry.fullUrl)) {
|
||||||
|
return downloadDirectUrl(normalizedEntry.fullUrl, normalizedEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const discoveredFull = await queryBackendForFullUrl(normalizedEntry);
|
||||||
|
if (discoveredFull) {
|
||||||
|
normalizedEntry.fullUrl = discoveredFull;
|
||||||
|
return downloadDirectUrl(discoveredFull, normalizedEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedEntry.previewUrl) {
|
||||||
|
return downloadDecodedPreview(normalizedEntry.previewUrl, normalizedEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("No preview or full URL available for this sample.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLatestEntryForTab(tabId) {
|
||||||
|
const state = TAB_CAPTURED.get(tabId);
|
||||||
|
if (!state || !state.latestSampleId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return state.entriesBySample.get(state.latestSampleId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeEntry(entry) {
|
||||||
|
return {
|
||||||
|
sampleId: entry.sampleId || "",
|
||||||
|
previewUrl: entry.previewUrl || "",
|
||||||
|
fullUrl: entry.fullUrl || "",
|
||||||
|
source: entry.source || "",
|
||||||
|
capturedAt: entry.capturedAt || Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeTabEntries(state) {
|
||||||
|
return Array.from(state.entriesBySample.values())
|
||||||
|
.sort((a, b) => (b.capturedAt || 0) - (a.capturedAt || 0))
|
||||||
|
.map(serializeEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
browser.runtime.onMessage.addListener((message, sender) => {
|
||||||
|
if (!message || typeof message.type !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === "CAPTURE_PREVIEW_CONTEXT") {
|
||||||
|
const tabId = tabIdFromSender(sender);
|
||||||
|
if (tabId < 0) {
|
||||||
|
return Promise.resolve({ ok: false, added: 0 });
|
||||||
|
}
|
||||||
|
const senderUrl = sender && sender.tab ? sender.tab.url : "";
|
||||||
|
if (!isDomainAllowed(senderUrl)) {
|
||||||
|
return Promise.resolve({ ok: false, added: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contexts = Array.isArray(message.contexts) ? message.contexts : [];
|
||||||
|
const rows = [];
|
||||||
|
for (const context of contexts) {
|
||||||
|
const merged = upsertPreviewContext(tabId, context, "graphql_preview");
|
||||||
|
if (merged) {
|
||||||
|
rows.push(toHistoryRow(merged));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return appendHistoryRows(rows).then(() => ({ ok: true, added: rows.length }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === "CAPTURE_URLS") {
|
||||||
|
const tabId = tabIdFromSender(sender);
|
||||||
|
const urls = Array.isArray(message.urls) ? message.urls : [];
|
||||||
|
const rows = [];
|
||||||
|
for (const raw of urls) {
|
||||||
|
const merged = upsertUrl(tabId, raw, message.source || "network_preview");
|
||||||
|
if (merged) {
|
||||||
|
rows.push(toHistoryRow(merged));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return appendHistoryRows(rows).then(() => ({ ok: true, added: rows.length }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === "GET_TAB_URLS") {
|
||||||
|
const tabId = typeof message.tabId === "number" ? message.tabId : -1;
|
||||||
|
const state = TAB_CAPTURED.get(tabId);
|
||||||
|
if (!state) {
|
||||||
|
return Promise.resolve({ ok: true, entries: [] });
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
entries: serializeTabEntries(state)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === "GET_HISTORY") {
|
||||||
|
return browser.storage.local.get(HISTORY_KEY).then((data) => {
|
||||||
|
const rows = Array.isArray(data[HISTORY_KEY]) ? data[HISTORY_KEY] : [];
|
||||||
|
rows.sort((a, b) => (b.capturedAt || 0) - (a.capturedAt || 0));
|
||||||
|
return { ok: true, history: rows };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === "DOWNLOAD_ENTRY") {
|
||||||
|
return resolveAndDownloadEntry(message.entry)
|
||||||
|
.then((result) => result)
|
||||||
|
.catch((error) => ({ ok: false, error: error.message || "Download failed." }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === "DOWNLOAD_LATEST") {
|
||||||
|
const tabId = tabIdFromSender(sender);
|
||||||
|
const latest = getLatestEntryForTab(tabId);
|
||||||
|
if (!latest) {
|
||||||
|
return Promise.resolve({ ok: false, error: "No captured sample is available yet." });
|
||||||
|
}
|
||||||
|
return resolveAndDownloadEntry(latest)
|
||||||
|
.then((result) => result)
|
||||||
|
.catch((error) => ({ ok: false, error: error.message || "Download failed." }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === "OPEN_HISTORY") {
|
||||||
|
const historyUrl = browser.runtime.getURL("history.html");
|
||||||
|
return browser.tabs.create({ url: historyUrl }).then(() => ({ ok: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleWebRequestCapture(details) {
|
||||||
|
const url = typeof details.url === "string" ? details.url : "";
|
||||||
|
if (!isAudioSampleUrl(url) || isWaveformUrl(url)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabId = typeof details.tabId === "number" ? details.tabId : -1;
|
||||||
|
const merged = upsertUrl(tabId, url, "webrequest");
|
||||||
|
if (merged) {
|
||||||
|
appendHistoryRows([toHistoryRow(merged)]).catch(() => {
|
||||||
|
// Ignore history write failures.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
browser.webRequest.onBeforeRequest.addListener(
|
||||||
|
handleWebRequestCapture,
|
||||||
|
{ urls: ["*://*.amazonaws.com/*", "*://*.amazonaws.com.cn/*"] }
|
||||||
|
);
|
||||||
|
|
||||||
|
browser.tabs.onRemoved.addListener((tabId) => {
|
||||||
|
maybeClearTabState(tabId);
|
||||||
|
});
|
||||||
|
|
||||||
|
browser.webNavigation.onCommitted.addListener((details) => {
|
||||||
|
if (details.frameId !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
maybeClearTabState(details.tabId);
|
||||||
|
});
|
||||||
598
content-script.js
Normal file
598
content-script.js
Normal file
@@ -0,0 +1,598 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const TARGET_SELECTOR = ".playbar-playback-info-bar.playback-row-section.svelte-11gjrk3";
|
||||||
|
const INJECTED_CONTAINER_ID = "s3-audio-capture-controls";
|
||||||
|
const SPLICE_DOMAIN_RE = /^https?:\/\/([a-z0-9-]+\.)*splice\.com(\/|$)/i;
|
||||||
|
const GRAPHQL_URL_FRAGMENT = "surfaces-graphql.splice.com/graphql";
|
||||||
|
const PROBE_EVENT_SOURCE = "S3_CAPTURE_PROBE";
|
||||||
|
const DEBUG_CAPTURE = true;
|
||||||
|
let knownUrls = new Set();
|
||||||
|
let knownPerformanceUrls = new Set();
|
||||||
|
let observer = null;
|
||||||
|
let captureObserver = null;
|
||||||
|
let performanceObserver = null;
|
||||||
|
let fetchPatched = false;
|
||||||
|
let xhrPatched = false;
|
||||||
|
let probeListenerInstalled = false;
|
||||||
|
|
||||||
|
function isDomainAllowed(url) {
|
||||||
|
return typeof url === "string" && SPLICE_DOMAIN_RE.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isS3Url(url) {
|
||||||
|
if (typeof url !== "string") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (parsed.protocol !== "https:") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const host = parsed.hostname.toLowerCase();
|
||||||
|
const isAwsHost = host.endsWith(".amazonaws.com") || host.endsWith(".amazonaws.com.cn");
|
||||||
|
const isS3Host = host.includes(".s3.") || host.startsWith("s3.");
|
||||||
|
if (!isAwsHost || !isS3Host) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const path = parsed.pathname || "";
|
||||||
|
if (!path.includes("/audio_samples/")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (path.endsWith(".wv.json")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUrl(rawUrl) {
|
||||||
|
try {
|
||||||
|
return new URL(rawUrl, window.location.href).toString();
|
||||||
|
} catch (error) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logDebug(message, extra) {
|
||||||
|
if (!DEBUG_CAPTURE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof extra === "undefined") {
|
||||||
|
console.debug("[S3 capture]", message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.debug("[S3 capture]", message, extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectProbeCandidateUrls(payload) {
|
||||||
|
if (!payload || typeof payload !== "object") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = [];
|
||||||
|
const keys = ["requestUrl", "responseUrl", "linkedUrl", "url", "blobUrl"];
|
||||||
|
for (const key of keys) {
|
||||||
|
if (typeof payload[key] === "string" && payload[key]) {
|
||||||
|
candidates.push(payload[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSampleIdFromUrl(url) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const marker = "/audio_samples/";
|
||||||
|
const idx = parsed.pathname.indexOf(marker);
|
||||||
|
if (idx < 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const rest = parsed.pathname.slice(idx + marker.length);
|
||||||
|
const parts = rest.split("/").filter(Boolean);
|
||||||
|
const first = (parts[0] || "").replace(/-scrambled$/i, "");
|
||||||
|
const second = (parts[1] || "").split(".")[0];
|
||||||
|
const hashRe = /^[a-f0-9]{64}$/i;
|
||||||
|
if (hashRe.test(first)) {
|
||||||
|
return first.toLowerCase();
|
||||||
|
}
|
||||||
|
if (hashRe.test(second)) {
|
||||||
|
return second.toLowerCase();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
} catch (error) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recursivelyCollectAudioUrls(value, output) {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const normalized = normalizeUrl(value);
|
||||||
|
if (normalized && isS3Url(normalized)) {
|
||||||
|
output.add(normalized);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) {
|
||||||
|
recursivelyCollectAudioUrls(item, output);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
for (const nested of Object.values(value)) {
|
||||||
|
recursivelyCollectAudioUrls(nested, output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPreviewContextsFromGraphql(json) {
|
||||||
|
const contexts = [];
|
||||||
|
const items = json &&
|
||||||
|
json.data &&
|
||||||
|
json.data.assetsSearch &&
|
||||||
|
Array.isArray(json.data.assetsSearch.items)
|
||||||
|
? json.data.assetsSearch.items
|
||||||
|
: [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item || typeof item !== "object") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const sampleId = typeof item.uuid === "string" ? item.uuid.toLowerCase() : "";
|
||||||
|
const files = Array.isArray(item.files) ? item.files : [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file || typeof file !== "object") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const typeSlug = String(file.asset_file_type_slug || "").toLowerCase();
|
||||||
|
const fileUrl = normalizeUrl(file.url || "");
|
||||||
|
if (typeSlug !== "preview_mp3" || !fileUrl) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
contexts.push({
|
||||||
|
sampleId: sampleId || extractSampleIdFromUrl(fileUrl),
|
||||||
|
previewUrl: fileUrl
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return contexts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendPreviewContexts(contexts) {
|
||||||
|
if (!contexts.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
browser.runtime.sendMessage({
|
||||||
|
type: "CAPTURE_PREVIEW_CONTEXT",
|
||||||
|
contexts
|
||||||
|
}).catch(() => {
|
||||||
|
// Ignore transient extension messaging failures.
|
||||||
|
});
|
||||||
|
logDebug("Captured preview contexts", contexts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inspectGraphqlResponse(candidateUrl, response) {
|
||||||
|
if (typeof candidateUrl !== "string" || !candidateUrl.includes(GRAPHQL_URL_FRAGMENT)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!response || typeof response.clone !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.clone().json().then((json) => {
|
||||||
|
const contexts = extractPreviewContextsFromGraphql(json);
|
||||||
|
if (contexts.length) {
|
||||||
|
sendPreviewContexts(contexts);
|
||||||
|
}
|
||||||
|
const urls = new Set();
|
||||||
|
recursivelyCollectAudioUrls(json, urls);
|
||||||
|
if (urls.size) {
|
||||||
|
captureUrls(Array.from(urls));
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
// Some GraphQL responses may not be parseable JSON.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureUrls(inputUrls) {
|
||||||
|
if (!isDomainAllowed(window.location.href)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newUrls = [];
|
||||||
|
for (const rawUrl of inputUrls) {
|
||||||
|
const normalized = normalizeUrl(rawUrl);
|
||||||
|
if (!normalized || !isS3Url(normalized) || knownUrls.has(normalized)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
knownUrls.add(normalized);
|
||||||
|
newUrls.push(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newUrls.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
browser.runtime.sendMessage({
|
||||||
|
type: "CAPTURE_URLS",
|
||||||
|
urls: newUrls,
|
||||||
|
source: "content_capture"
|
||||||
|
}).catch(() => {
|
||||||
|
// Ignore transient extension context failures.
|
||||||
|
});
|
||||||
|
logDebug("Captured S3 URLs", newUrls);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanNodeForUrls(root) {
|
||||||
|
if (!(root instanceof Element || root instanceof Document)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlCandidates = [];
|
||||||
|
const selectors = [
|
||||||
|
"a[href]",
|
||||||
|
"audio[src]",
|
||||||
|
"source[src]",
|
||||||
|
"[data-url]",
|
||||||
|
"[data-src]"
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const selector of selectors) {
|
||||||
|
const nodes = root.querySelectorAll(selector);
|
||||||
|
for (const node of nodes) {
|
||||||
|
const attrs = ["href", "src", "data-url", "data-src"];
|
||||||
|
for (const attr of attrs) {
|
||||||
|
const value = node.getAttribute(attr);
|
||||||
|
if (value) {
|
||||||
|
urlCandidates.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
captureUrls(urlCandidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchFetch() {
|
||||||
|
if (fetchPatched || typeof window.fetch !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const originalFetch = window.fetch.bind(window);
|
||||||
|
window.fetch = function patchedFetch(input, init) {
|
||||||
|
const candidate = typeof input === "string" ? input : input && input.url;
|
||||||
|
if (candidate) {
|
||||||
|
captureUrls([candidate]);
|
||||||
|
}
|
||||||
|
if (init && typeof init === "object" && typeof init.url === "string") {
|
||||||
|
captureUrls([init.url]);
|
||||||
|
}
|
||||||
|
return originalFetch(input, init).then((response) => {
|
||||||
|
if (candidate) {
|
||||||
|
inspectGraphqlResponse(candidate, response);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
fetchPatched = true;
|
||||||
|
} catch (error) {
|
||||||
|
// Firefox may expose fetch as read-only in content script worlds.
|
||||||
|
fetchPatched = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchXhr() {
|
||||||
|
if (xhrPatched || !window.XMLHttpRequest) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const originalOpen = window.XMLHttpRequest.prototype.open;
|
||||||
|
window.XMLHttpRequest.prototype.open = function patchedOpen(method, url) {
|
||||||
|
if (typeof url === "string") {
|
||||||
|
captureUrls([url]);
|
||||||
|
}
|
||||||
|
return originalOpen.apply(this, arguments);
|
||||||
|
};
|
||||||
|
xhrPatched = true;
|
||||||
|
} catch (error) {
|
||||||
|
xhrPatched = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function capturePerformanceResourceUrls() {
|
||||||
|
if (typeof performance === "undefined" || typeof performance.getEntriesByType !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resources = performance.getEntriesByType("resource");
|
||||||
|
const candidates = [];
|
||||||
|
|
||||||
|
for (const entry of resources) {
|
||||||
|
if (!entry || typeof entry.name !== "string") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (knownPerformanceUrls.has(entry.name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
knownPerformanceUrls.add(entry.name);
|
||||||
|
candidates.push(entry.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.length) {
|
||||||
|
captureUrls(candidates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPerformanceObserver() {
|
||||||
|
if (performanceObserver || typeof PerformanceObserver !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
performanceObserver = new PerformanceObserver((list) => {
|
||||||
|
const candidates = [];
|
||||||
|
for (const entry of list.getEntries()) {
|
||||||
|
if (!entry || typeof entry.name !== "string") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (knownPerformanceUrls.has(entry.name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
knownPerformanceUrls.add(entry.name);
|
||||||
|
candidates.push(entry.name);
|
||||||
|
}
|
||||||
|
if (candidates.length) {
|
||||||
|
captureUrls(candidates);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
performanceObserver.observe({ type: "resource", buffered: true });
|
||||||
|
} catch (error) {
|
||||||
|
performanceObserver = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createIcon(kind) {
|
||||||
|
const ns = "http://www.w3.org/2000/svg";
|
||||||
|
const svg = document.createElementNS(ns, "svg");
|
||||||
|
svg.setAttribute("viewBox", "0 0 24 24");
|
||||||
|
svg.setAttribute("width", "14");
|
||||||
|
svg.setAttribute("height", "14");
|
||||||
|
svg.setAttribute("aria-hidden", "true");
|
||||||
|
|
||||||
|
if (kind === "download") {
|
||||||
|
const arrow = document.createElementNS(ns, "path");
|
||||||
|
arrow.setAttribute("d", "M12 3v10m0 0l4-4m-4 4l-4-4");
|
||||||
|
arrow.setAttribute("fill", "none");
|
||||||
|
arrow.setAttribute("stroke", "currentColor");
|
||||||
|
arrow.setAttribute("stroke-width", "2");
|
||||||
|
arrow.setAttribute("stroke-linecap", "round");
|
||||||
|
arrow.setAttribute("stroke-linejoin", "round");
|
||||||
|
|
||||||
|
const tray = document.createElementNS(ns, "path");
|
||||||
|
tray.setAttribute("d", "M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2");
|
||||||
|
tray.setAttribute("fill", "none");
|
||||||
|
tray.setAttribute("stroke", "currentColor");
|
||||||
|
tray.setAttribute("stroke-width", "2");
|
||||||
|
tray.setAttribute("stroke-linecap", "round");
|
||||||
|
tray.setAttribute("stroke-linejoin", "round");
|
||||||
|
|
||||||
|
svg.appendChild(arrow);
|
||||||
|
svg.appendChild(tray);
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
const circle = document.createElementNS(ns, "circle");
|
||||||
|
circle.setAttribute("cx", "12");
|
||||||
|
circle.setAttribute("cy", "12");
|
||||||
|
circle.setAttribute("r", "9");
|
||||||
|
circle.setAttribute("fill", "none");
|
||||||
|
circle.setAttribute("stroke", "currentColor");
|
||||||
|
circle.setAttribute("stroke-width", "2");
|
||||||
|
|
||||||
|
const hand = document.createElementNS(ns, "path");
|
||||||
|
hand.setAttribute("d", "M12 7v5l3 2");
|
||||||
|
hand.setAttribute("fill", "none");
|
||||||
|
hand.setAttribute("stroke", "currentColor");
|
||||||
|
hand.setAttribute("stroke-width", "2");
|
||||||
|
hand.setAttribute("stroke-linecap", "round");
|
||||||
|
hand.setAttribute("stroke-linejoin", "round");
|
||||||
|
|
||||||
|
svg.appendChild(circle);
|
||||||
|
svg.appendChild(hand);
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createButtonMetaItem(iconType, ariaLabel, onClick) {
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.className = "bordered-item meta-item svelte-11gjrk3";
|
||||||
|
|
||||||
|
const inner = document.createElement("div");
|
||||||
|
inner.className = "meta-wrap svelte-11gjrk3";
|
||||||
|
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "meta-value svelte-11gjrk3 s3-capture-button";
|
||||||
|
button.setAttribute("aria-label", ariaLabel);
|
||||||
|
button.setAttribute("title", ariaLabel);
|
||||||
|
button.appendChild(createIcon(iconType));
|
||||||
|
button.addEventListener("click", onClick);
|
||||||
|
|
||||||
|
inner.appendChild(button);
|
||||||
|
wrapper.appendChild(inner);
|
||||||
|
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureInlineStyles() {
|
||||||
|
if (document.getElementById("s3-audio-capture-style")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.id = "s3-audio-capture-style";
|
||||||
|
style.textContent = `
|
||||||
|
.s3-capture-button {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font: inherit;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.s3-capture-button:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.16);
|
||||||
|
}
|
||||||
|
.s3-capture-button svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectControls() {
|
||||||
|
const target = document.querySelector(TARGET_SELECTOR);
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.querySelector("#" + INJECTED_CONTAINER_ID)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureInlineStyles();
|
||||||
|
|
||||||
|
const controlsRoot = document.createElement("div");
|
||||||
|
controlsRoot.id = INJECTED_CONTAINER_ID;
|
||||||
|
controlsRoot.style.display = "contents";
|
||||||
|
|
||||||
|
const currentButton = createButtonMetaItem("download", "Download current audio", () => {
|
||||||
|
browser.runtime.sendMessage({ type: "DOWNLOAD_LATEST" }).then((result) => {
|
||||||
|
if (!result || !result.ok) {
|
||||||
|
window.alert((result && result.error) || "No URL available for download.");
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
window.alert("Extension message failed.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const historyButton = createButtonMetaItem("history", "Open captured history", () => {
|
||||||
|
browser.runtime.sendMessage({ type: "OPEN_HISTORY" }).catch(() => {
|
||||||
|
window.alert("Unable to open history.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
controlsRoot.appendChild(currentButton);
|
||||||
|
controlsRoot.appendChild(historyButton);
|
||||||
|
target.appendChild(controlsRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startUiObserver() {
|
||||||
|
if (observer) {
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
observer = new MutationObserver(() => {
|
||||||
|
injectControls();
|
||||||
|
});
|
||||||
|
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCaptureObserver() {
|
||||||
|
if (captureObserver) {
|
||||||
|
captureObserver.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
captureObserver = new MutationObserver((mutations) => {
|
||||||
|
for (const mutation of mutations) {
|
||||||
|
for (const addedNode of mutation.addedNodes) {
|
||||||
|
scanNodeForUrls(addedNode);
|
||||||
|
}
|
||||||
|
if (mutation.type === "attributes" && mutation.target instanceof Element) {
|
||||||
|
scanNodeForUrls(mutation.target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
captureObserver.observe(document.documentElement, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["href", "src", "data-url", "data-src"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function installPageProbeListener() {
|
||||||
|
if (probeListenerInstalled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("message", (event) => {
|
||||||
|
if (event.source !== window) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = event.data;
|
||||||
|
if (!data || data.source !== PROBE_EVENT_SOURCE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kind = typeof data.kind === "string" ? data.kind : "unknown";
|
||||||
|
const payload = data.payload && typeof data.payload === "object" ? data.payload : {};
|
||||||
|
const candidateUrls = collectProbeCandidateUrls(payload);
|
||||||
|
if (candidateUrls.length) {
|
||||||
|
captureUrls(candidateUrls);
|
||||||
|
}
|
||||||
|
logDebug("Probe event: " + kind, payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
probeListenerInstalled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectPageProbeScript() {
|
||||||
|
if (document.getElementById("s3-capture-page-probe")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = document.head || document.documentElement;
|
||||||
|
if (!root) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.id = "s3-capture-page-probe";
|
||||||
|
script.src = browser.runtime.getURL("page-probe.js");
|
||||||
|
script.async = false;
|
||||||
|
script.onload = () => {
|
||||||
|
script.remove();
|
||||||
|
logDebug("Injected page probe.");
|
||||||
|
};
|
||||||
|
script.onerror = () => {
|
||||||
|
logDebug("Failed to inject page probe.");
|
||||||
|
};
|
||||||
|
root.appendChild(script);
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
if (!isDomainAllowed(window.location.href)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
installPageProbeListener();
|
||||||
|
injectPageProbeScript();
|
||||||
|
scanNodeForUrls(document);
|
||||||
|
patchFetch();
|
||||||
|
patchXhr();
|
||||||
|
capturePerformanceResourceUrls();
|
||||||
|
startPerformanceObserver();
|
||||||
|
window.setInterval(capturePerformanceResourceUrls, 2000);
|
||||||
|
injectControls();
|
||||||
|
startUiObserver();
|
||||||
|
startCaptureObserver();
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
58
history.css
Normal file
58
history.css
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background: #0e1117;
|
||||||
|
color: #e6edf7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-page {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #aeb7cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
#historyList {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item {
|
||||||
|
border: 1px solid #2a2f40;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #151a26;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-url {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
word-break: break-all;
|
||||||
|
color: #d2d9ea;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-meta {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
color: #98a5c0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 1px solid #2f66ff;
|
||||||
|
background: #2f66ff;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
23
history.html
Normal file
23
history.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>S3 Audio Capture History</title>
|
||||||
|
<link rel="stylesheet" href="history.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<main class="history-page">
|
||||||
|
<header>
|
||||||
|
<h1>S3 Audio Capture History</h1>
|
||||||
|
<p>All captured Splice S3 URLs.</p>
|
||||||
|
</header>
|
||||||
|
<p id="status">Loading history...</p>
|
||||||
|
<ul id="historyList"></ul>
|
||||||
|
</main>
|
||||||
|
<script src="history.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
70
history.js
Normal file
70
history.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const statusEl = document.getElementById("status");
|
||||||
|
const listEl = document.getElementById("historyList");
|
||||||
|
|
||||||
|
function formatDate(ms) {
|
||||||
|
try {
|
||||||
|
return new Date(ms).toLocaleString();
|
||||||
|
} catch (error) {
|
||||||
|
return "unknown time";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildItem(entry) {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.className = "history-item";
|
||||||
|
|
||||||
|
const url = document.createElement("p");
|
||||||
|
url.className = "history-url";
|
||||||
|
url.textContent = entry.fullUrl || entry.previewUrl || entry.url || "";
|
||||||
|
|
||||||
|
const meta = document.createElement("p");
|
||||||
|
meta.className = "history-meta";
|
||||||
|
const mode = entry.fullUrl ? "full candidate" : "preview fallback";
|
||||||
|
meta.textContent = `Captured: ${formatDate(entry.capturedAt)} | Mode: ${mode} | Source: ${entry.source || "unknown"}`;
|
||||||
|
|
||||||
|
const download = document.createElement("button");
|
||||||
|
download.type = "button";
|
||||||
|
download.textContent = "Download";
|
||||||
|
download.addEventListener("click", async () => {
|
||||||
|
const result = await browser.runtime.sendMessage({
|
||||||
|
type: "DOWNLOAD_ENTRY",
|
||||||
|
entry
|
||||||
|
});
|
||||||
|
if (!result || !result.ok) {
|
||||||
|
window.alert((result && result.error) || "Download failed.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
statusEl.textContent = result.mode === "full" ? "Downloaded full file URL." : "Downloaded decoded preview fallback.";
|
||||||
|
});
|
||||||
|
|
||||||
|
li.appendChild(url);
|
||||||
|
li.appendChild(meta);
|
||||||
|
li.appendChild(download);
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHistory() {
|
||||||
|
const response = await browser.runtime.sendMessage({ type: "GET_HISTORY" });
|
||||||
|
if (!response || !response.ok) {
|
||||||
|
statusEl.textContent = "Unable to load history.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = Array.isArray(response.history) ? response.history : [];
|
||||||
|
if (!rows.length) {
|
||||||
|
statusEl.textContent = "No captured S3 URLs yet.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusEl.textContent = `Total captured URLs: ${rows.length}`;
|
||||||
|
listEl.innerHTML = "";
|
||||||
|
for (const entry of rows) {
|
||||||
|
listEl.appendChild(buildItem(entry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadHistory().catch(() => {
|
||||||
|
statusEl.textContent = "Unexpected error while loading history.";
|
||||||
|
});
|
||||||
48
manifest.json
Normal file
48
manifest.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 2,
|
||||||
|
"name": "Splice Downloader",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Capture Splice S3 audio links on matching pages and download them.",
|
||||||
|
"permissions": [
|
||||||
|
"tabs",
|
||||||
|
"storage",
|
||||||
|
"downloads",
|
||||||
|
"webRequest",
|
||||||
|
"*://*.splice.com/*",
|
||||||
|
"*://*.amazonaws.com/*",
|
||||||
|
"*://*.amazonaws.com.cn/*",
|
||||||
|
"webNavigation"
|
||||||
|
],
|
||||||
|
"background": {
|
||||||
|
"scripts": [
|
||||||
|
"background.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": [
|
||||||
|
"*://*.splice.com/*"
|
||||||
|
],
|
||||||
|
"js": [
|
||||||
|
"content-script.js"
|
||||||
|
],
|
||||||
|
"run_at": "document_idle"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"browser_action": {
|
||||||
|
"default_title": "S3 Audio Capture",
|
||||||
|
"default_popup": "popup.html"
|
||||||
|
},
|
||||||
|
"web_accessible_resources": [
|
||||||
|
"history.html",
|
||||||
|
"history.js",
|
||||||
|
"history.css",
|
||||||
|
"page-probe.js"
|
||||||
|
],
|
||||||
|
"applications": {
|
||||||
|
"gecko": {
|
||||||
|
"id": "s3-audio-capture@splice.local",
|
||||||
|
"strict_min_version": "109.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
182
page-probe.js
Normal file
182
page-probe.js
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
(function initPageProbe() {
|
||||||
|
const EVENT_SOURCE = "S3_CAPTURE_PROBE";
|
||||||
|
let lastAudioResponseUrl = "";
|
||||||
|
|
||||||
|
function emit(kind, payload) {
|
||||||
|
try {
|
||||||
|
window.postMessage(
|
||||||
|
{
|
||||||
|
source: EVENT_SOURCE,
|
||||||
|
kind,
|
||||||
|
payload: payload || {}
|
||||||
|
},
|
||||||
|
"*"
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore probe emission errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeString(value) {
|
||||||
|
return typeof value === "string" ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function markLastAudioUrl(url, contentType) {
|
||||||
|
const maybeUrl = safeString(url);
|
||||||
|
const maybeType = safeString(contentType).toLowerCase();
|
||||||
|
if (!maybeUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (maybeType.startsWith("audio/") || maybeUrl.includes("audio_samples/")) {
|
||||||
|
lastAudioResponseUrl = maybeUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchFetch() {
|
||||||
|
if (typeof window.fetch !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentFetch = window.fetch;
|
||||||
|
if (currentFetch.__s3CapturePatched) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrappedFetch = function wrappedFetch(input, init) {
|
||||||
|
const requestUrl =
|
||||||
|
typeof input === "string"
|
||||||
|
? input
|
||||||
|
: input && typeof input.url === "string"
|
||||||
|
? input.url
|
||||||
|
: "";
|
||||||
|
|
||||||
|
emit("fetch-request", { requestUrl });
|
||||||
|
|
||||||
|
return currentFetch.call(this, input, init).then(
|
||||||
|
(response) => {
|
||||||
|
const responseUrl = response && typeof response.url === "string" ? response.url : "";
|
||||||
|
const contentType = response && response.headers ? response.headers.get("content-type") : "";
|
||||||
|
markLastAudioUrl(responseUrl, contentType);
|
||||||
|
emit("fetch-response", {
|
||||||
|
requestUrl,
|
||||||
|
responseUrl,
|
||||||
|
status: response ? response.status : 0,
|
||||||
|
contentType: contentType || ""
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
emit("fetch-error", {
|
||||||
|
requestUrl,
|
||||||
|
error: error && error.message ? error.message : "fetch failed"
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
wrappedFetch.__s3CapturePatched = true;
|
||||||
|
window.fetch = wrappedFetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchXhr() {
|
||||||
|
if (!window.XMLHttpRequest || !window.XMLHttpRequest.prototype) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proto = window.XMLHttpRequest.prototype;
|
||||||
|
if (proto.open.__s3CapturePatched) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalOpen = proto.open;
|
||||||
|
const originalSend = proto.send;
|
||||||
|
|
||||||
|
proto.open = function patchedOpen(method, url) {
|
||||||
|
this.__s3CaptureRequestUrl = safeString(url);
|
||||||
|
emit("xhr-open", {
|
||||||
|
method: safeString(method),
|
||||||
|
requestUrl: this.__s3CaptureRequestUrl
|
||||||
|
});
|
||||||
|
return originalOpen.apply(this, arguments);
|
||||||
|
};
|
||||||
|
proto.open.__s3CapturePatched = true;
|
||||||
|
|
||||||
|
proto.send = function patchedSend() {
|
||||||
|
const xhr = this;
|
||||||
|
xhr.addEventListener(
|
||||||
|
"loadend",
|
||||||
|
function onLoadEnd() {
|
||||||
|
const responseUrl = safeString(xhr.responseURL);
|
||||||
|
const contentType = safeString(xhr.getResponseHeader("content-type"));
|
||||||
|
markLastAudioUrl(responseUrl || xhr.__s3CaptureRequestUrl, contentType);
|
||||||
|
emit("xhr-response", {
|
||||||
|
requestUrl: safeString(xhr.__s3CaptureRequestUrl),
|
||||||
|
responseUrl,
|
||||||
|
status: xhr.status || 0,
|
||||||
|
contentType
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
return originalSend.apply(this, arguments);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchCreateObjectURL() {
|
||||||
|
if (!window.URL || typeof window.URL.createObjectURL !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const current = window.URL.createObjectURL;
|
||||||
|
if (current.__s3CapturePatched) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrapped = function wrappedCreateObjectURL(blob) {
|
||||||
|
const objectUrl = current.call(this, blob);
|
||||||
|
const blobType = blob && typeof blob.type === "string" ? blob.type : "";
|
||||||
|
const blobSize = blob && typeof blob.size === "number" ? blob.size : 0;
|
||||||
|
emit("blob-created", {
|
||||||
|
blobUrl: objectUrl,
|
||||||
|
blobType,
|
||||||
|
blobSize,
|
||||||
|
linkedUrl: lastAudioResponseUrl
|
||||||
|
});
|
||||||
|
return objectUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
wrapped.__s3CapturePatched = true;
|
||||||
|
window.URL.createObjectURL = wrapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchDecodeAudioData(ctor) {
|
||||||
|
if (!ctor || !ctor.prototype || typeof ctor.prototype.decodeAudioData !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const current = ctor.prototype.decodeAudioData;
|
||||||
|
if (current.__s3CapturePatched) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrapped = function wrappedDecodeAudioData(audioData) {
|
||||||
|
const byteLength =
|
||||||
|
audioData && typeof audioData.byteLength === "number" ? audioData.byteLength : 0;
|
||||||
|
emit("decode-audio-data", {
|
||||||
|
byteLength,
|
||||||
|
linkedUrl: lastAudioResponseUrl
|
||||||
|
});
|
||||||
|
return current.apply(this, arguments);
|
||||||
|
};
|
||||||
|
|
||||||
|
wrapped.__s3CapturePatched = true;
|
||||||
|
ctor.prototype.decodeAudioData = wrapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
patchFetch();
|
||||||
|
patchXhr();
|
||||||
|
patchCreateObjectURL();
|
||||||
|
patchDecodeAudioData(window.AudioContext);
|
||||||
|
patchDecodeAudioData(window.OfflineAudioContext);
|
||||||
|
emit("probe-ready", { href: window.location.href });
|
||||||
|
})();
|
||||||
74
popup.css
Normal file
74
popup.css
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background: #111319;
|
||||||
|
color: #e9ecf1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup {
|
||||||
|
width: 380px;
|
||||||
|
max-height: 520px;
|
||||||
|
padding: 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 1px solid #2f66ff;
|
||||||
|
background: #2f66ff;
|
||||||
|
color: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 9px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
border-color: #445;
|
||||||
|
background: #1e2230;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin: 12px 0;
|
||||||
|
color: #adb6cb;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-item {
|
||||||
|
border: 1px solid #2a2f40;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #171b26;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-text {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
word-break: break-all;
|
||||||
|
color: #c6d0e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
22
popup.html
Normal file
22
popup.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>S3 Audio Capture</title>
|
||||||
|
<link rel="stylesheet" href="popup.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="popup">
|
||||||
|
<header class="popup-header">
|
||||||
|
<h1>S3 Audio Capture</h1>
|
||||||
|
<button id="openHistoryBtn" type="button">Open Full History</button>
|
||||||
|
</header>
|
||||||
|
<section>
|
||||||
|
<p id="status" class="status">Loading captured URLs...</p>
|
||||||
|
<ul id="urlList" class="url-list"></ul>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
98
popup.js
Normal file
98
popup.js
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const statusEl = document.getElementById("status");
|
||||||
|
const listEl = document.getElementById("urlList");
|
||||||
|
const openHistoryBtn = document.getElementById("openHistoryBtn");
|
||||||
|
|
||||||
|
function setStatus(message) {
|
||||||
|
statusEl.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryLabel(entry) {
|
||||||
|
return entry && entry.fullUrl ? "full candidate" : "preview fallback";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRow(entry) {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.className = "url-item";
|
||||||
|
|
||||||
|
const p = document.createElement("p");
|
||||||
|
p.className = "url-text";
|
||||||
|
p.textContent = entry.previewUrl || entry.fullUrl || "";
|
||||||
|
|
||||||
|
const status = document.createElement("p");
|
||||||
|
status.className = "status";
|
||||||
|
status.textContent = `Source: ${entry.source || "unknown"} | Mode: ${entryLabel(entry)}`;
|
||||||
|
|
||||||
|
const actions = document.createElement("div");
|
||||||
|
actions.className = "actions";
|
||||||
|
|
||||||
|
const download = document.createElement("button");
|
||||||
|
download.type = "button";
|
||||||
|
download.textContent = "Download";
|
||||||
|
download.addEventListener("click", async () => {
|
||||||
|
const result = await browser.runtime.sendMessage({ type: "DOWNLOAD_ENTRY", entry });
|
||||||
|
if (!result || !result.ok) {
|
||||||
|
window.alert((result && result.error) || "Download failed.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus(result.mode === "full" ? "Downloaded full file URL." : "Downloaded decoded preview fallback.");
|
||||||
|
});
|
||||||
|
|
||||||
|
const copy = document.createElement("button");
|
||||||
|
copy.type = "button";
|
||||||
|
copy.className = "secondary";
|
||||||
|
copy.textContent = "Copy URL";
|
||||||
|
copy.addEventListener("click", async () => {
|
||||||
|
const url = entry.fullUrl || entry.previewUrl || "";
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
actions.appendChild(download);
|
||||||
|
actions.appendChild(copy);
|
||||||
|
li.appendChild(p);
|
||||||
|
li.appendChild(status);
|
||||||
|
li.appendChild(actions);
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCapturedUrls() {
|
||||||
|
const tabs = await browser.tabs.query({ active: true, currentWindow: true });
|
||||||
|
const activeTab = tabs[0];
|
||||||
|
|
||||||
|
if (!activeTab || typeof activeTab.id !== "number") {
|
||||||
|
setStatus("No active tab found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await browser.runtime.sendMessage({
|
||||||
|
type: "GET_TAB_URLS",
|
||||||
|
tabId: activeTab.id
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response || !response.ok) {
|
||||||
|
setStatus("Unable to load captured URLs.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = "";
|
||||||
|
const entries = Array.isArray(response.entries) ? response.entries : [];
|
||||||
|
if (!entries.length) {
|
||||||
|
setStatus("No captured S3 URLs for this tab yet.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(`Captured samples for this tab: ${entries.length}`);
|
||||||
|
for (const entry of entries) {
|
||||||
|
listEl.appendChild(buildRow(entry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openHistoryBtn.addEventListener("click", async () => {
|
||||||
|
await browser.runtime.sendMessage({ type: "OPEN_HISTORY" });
|
||||||
|
window.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
loadCapturedUrls().catch(() => {
|
||||||
|
setStatus("Unexpected error while loading URLs.");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user