Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
24740c8
feat: redesign options page with sidebar nav, history view, and S3 co…
jvillegasd Mar 3, 2026
0c1487e
refactor: limit popup downloads tab to in-progress downloads only
jvillegasd Mar 3, 2026
be92158
refactor: move Open File action from popup to history view
jvillegasd Mar 3, 2026
fccbfc5
fix(options): use extension icon in sidebar header, clean up download…
jvillegasd Mar 3, 2026
e0e15bc
refactor(history): replace inline action buttons with a dropdown menu
jvillegasd Mar 3, 2026
5a378d2
feat(history): show check manifest result as toast notification
jvillegasd Mar 3, 2026
b2d2ede
feat(history): show toast when copying URL to clipboard
jvillegasd Mar 3, 2026
ca26cde
feat(history): toast on re-download and refresh history list
jvillegasd Mar 3, 2026
8c1f459
fix(history): use stored metadata for readable filename on re-download
jvillegasd Mar 3, 2026
5ddf646
fix(options): restore active section on page refresh
jvillegasd Mar 3, 2026
b956ef7
feat(history): infinite scroll with IntersectionObserver
jvillegasd Mar 3, 2026
afd1707
refactor(options): rename terminal → finished stages and add live his…
jvillegasd Mar 3, 2026
a86c61f
fix(options): remove redundant check mark from manifest live toast
jvillegasd Mar 3, 2026
c9eb8de
feat(options): add Recording, Notifications, and Advanced settings pages
jvillegasd Mar 4, 2026
548fff1
refactor(config): introduce loadSettings() and consolidate maxConcurrent
jvillegasd Mar 4, 2026
48e632f
refactor(options): consolidate constants and unify notifications via …
jvillegasd Mar 4, 2026
6783cff
refactor(options): unify all time fields to seconds in the UI
jvillegasd Mar 4, 2026
ce1a687
chore(options): remove dead code
jvillegasd Mar 4, 2026
d9d0bf1
docs: update CLAUDE.md and README to reflect refactor/options-page ch…
jvillegasd Mar 4, 2026
41115a8
refactor(styles): extract shared design tokens and badge styles into …
jvillegasd Mar 4, 2026
f10f50d
feat(options): add inline field validation before saving
jvillegasd Mar 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Media Bridge is a Manifest V3 Chrome extension. It has five distinct execution c

4. **Popup** (`src/popup/` → `dist/popup/`): Extension action UI — Videos tab (detected videos), Downloads tab (progress), Manifest tab (manual URL input with quality selector).

5. **Options Page** (`src/options/` → `dist/options/`): FFmpeg timeout and max concurrent downloads configuration.
5. **Options Page** (`src/options/` → `dist/options/`): Full settings UI with sidebar navigation. Sections: Download (FFmpeg timeout, max concurrent), History (completed/failed/cancelled download log with infinite scroll), Google Drive, S3, Recording (HLS poll interval tuning), Notifications, and Advanced (retries, backoff, cache sizes, fragment failure rate, IDB sync interval). All settings changes notify via bottom toast. History button in the popup header opens the options page directly on the `#history` anchor.

### Download Flow

Expand All @@ -62,7 +62,7 @@ Download state is persisted in **IndexedDB** (not `chrome.storage`), in the `med
- `downloads`: Full `DownloadState` objects keyed by `id`
- `chunks`: Raw `Uint8Array` segments keyed by `[downloadId, index]`

Configuration (FFmpeg timeout, max concurrent) lives in `chrome.storage.local` via `ChromeStorage` (`core/storage/chrome-storage.ts`).
Configuration lives in `chrome.storage.local` under the `storage_config` key (`StorageConfig` type). Always access config through `loadSettings()` (`core/storage/settings.ts`) which returns a fully-typed `AppSettings` object with all defaults applied — never read `StorageConfig` directly. `AppSettings` covers: `ffmpegTimeout`, `maxConcurrent`, `historyEnabled`, `googleDrive`, `s3`, `recording`, `notifications`, and `advanced`.

IndexedDB is used as the shared state store because the five execution contexts don't share memory. The service worker writes state via `storeDownload()` (`core/database/downloads.ts`), which is a single IDB `put` upsert keyed by `id`. The popup reads the full list via `getAllDownloads()` on open. The offscreen document reads raw chunks from the `chunks` store during FFmpeg processing. `chrome.storage` is only used for config because it has a 10 MB quota and can't store `ArrayBuffer`.

Expand All @@ -86,7 +86,7 @@ Progress updates use two complementary channels:

### Message Protocol

All inter-component communication uses the `MessageType` enum in `src/shared/messages.ts`. When adding new message types, add them to this enum and handle them in the service worker's `onMessage` listener switch statement.
All inter-component communication uses the `MessageType` enum in `src/shared/messages.ts`. When adding new message types, add them to this enum and handle them in the service worker's `onMessage` listener switch statement. `CHECK_URL` is used by the options page manifest-check feature to probe a URL's content-type via the service worker (bypassing CORS).

### Build System

Expand Down Expand Up @@ -123,6 +123,31 @@ The fix uses `chrome.declarativeNetRequest` dynamic rules (`src/core/downloader/

HLS, M3U8, and DASH handlers support saving partial downloads when cancelled. If `shouldSaveOnCancel()` returns true, the handler transitions to the `MERGING` stage with whatever chunks were collected, runs FFmpeg, and saves a partial MP4. The abort signal is cleared before FFmpeg processing to prevent immediate rejection.

### Constants Ownership

- `src/shared/constants.ts` — only constants used across **multiple** modules (runtime defaults, pipeline values, storage keys)
- `src/options/constants.ts` — constants used exclusively within the options UI (toast duration, UI bounds for all settings inputs in seconds, validation clamp values)

**Time representation**: All runtime/storage values use **milliseconds** (`StorageConfig`, `AppSettings`, all handlers). The options UI uses **seconds** exclusively. Conversion happens only in `options.ts`: divide by 1000 on load, multiply by 1000 on save.

### Options Page Field Validation

All numeric inputs are validated **before** saving via three helpers in `options.ts`:

- `validateField(input, min, max, isInteger?)` — parses the value, returns the number on success or `null` on failure. Calls `markInvalid` automatically.
- `markInvalid(input, message)` — adds `.invalid` class (red border) and inserts a `.form-error` div after the input. Registers a one-time `input` listener to auto-clear when the user edits.
- `clearInvalid(input)` — removes `.invalid` and the `.form-error` div.

Each save handler validates all fields upfront and returns early if any are invalid — the button is never disabled and no write is attempted. Cross-field constraints (e.g. `pollMin < pollMax`) call `markInvalid` on the relevant field directly rather than relying on the toast. The toast is reserved for storage/network errors.

### History

Completed, failed, and cancelled downloads are persisted in IndexedDB when `historyEnabled` (default `true`) is set. The options page History section renders all finished downloads with infinite scroll (`IntersectionObserver`). From history, users can re-download (reuses stored metadata for filename), copy the original URL, or delete entries. `bulkDeleteDownloads()` (`core/database/downloads.ts`) handles batch removal. The popup "History" button navigates to `options.html#history`.

### Post-Download Actions

After a download completes, `handlePostDownloadActions()` in the service worker reads `AppSettings.notifications` and optionally fires an OS notification (`notifyOnCompletion`) or opens the file in Finder/Explorer (`autoOpenFile`).

### DASH-Specific Notes

- No `-bsf:a aac_adtstoasc` bitstream filter — DASH segments are already in ISOBMF container format
Expand Down Expand Up @@ -177,7 +202,8 @@ src/
│ │ ├── downloads.ts # storeDownload(), getDownload(), etc.
│ │ └── chunks.ts # storeChunk(), deleteChunks(), getChunkCount()
│ ├── storage/
│ │ └── chrome-storage.ts
│ │ ├── chrome-storage.ts
│ │ └── settings.ts # AppSettings interface + loadSettings() — always use this
│ ├── cloud/ # ⚠️ Planned — not wired up yet
│ │ ├── google-auth.ts
│ │ ├── google-drive.ts
Expand Down Expand Up @@ -207,6 +233,7 @@ src/
│ └── utils.ts
├── options/
│ ├── options.ts / options.html
│ └── constants.ts # Options-page-only constants (UI bounds, toast duration)
├── offscreen/
│ ├── offscreen.ts / offscreen.html
└── types/
Expand Down
18 changes: 11 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ A Manifest V3 Chromium extension that detects and downloads videos from the web
- **Partial Save on Cancel**: Save whatever segments were collected before cancellation
- **AES-128 Decryption**: Decrypts encrypted HLS segments transparently
- **Header Injection**: Injects `Origin`/`Referer` headers via `declarativeNetRequest` for CDNs that require them
- **Download History**: Completed, failed, and cancelled downloads are persisted and browsable in the options page History section with infinite scroll
- **Notifications**: Optional OS notification and auto-open file on download completion
- **Configurable Settings**: Recording poll intervals, fetch retry behaviour, detection cache sizes, IDB sync rate — all tunable from the options page

## ⚠️ Output File Size Limit

Expand Down Expand Up @@ -103,8 +106,8 @@ Media Bridge has five distinct execution contexts that communicate via `chrome.r
1. **Service Worker** (`src/service-worker.ts`): Central orchestrator. Routes messages, manages download lifecycle, keeps itself alive via heartbeat.
2. **Content Script** (`src/content.ts`): Runs on all pages. Detects videos via DOM observation and network interception. Proxies fetch requests through the service worker to bypass CORS.
3. **Offscreen Document** (`src/offscreen/`): Hidden page that runs FFmpeg.wasm. Reads segment data from IndexedDB, muxes into MP4, returns a blob URL.
4. **Popup** (`src/popup/`): Extension action UI — Videos tab, Downloads tab, Manifest tab.
5. **Options Page** (`src/options/`): Configuration (FFmpeg timeout, max concurrent).
4. **Popup** (`src/popup/`): Extension action UI — Videos tab (detected videos), Downloads tab (in-progress only), Manifest tab (manual URL + quality selector). A History button opens the options page directly on the history section.
5. **Options Page** (`src/options/`): Full settings UI with sidebar navigation — Download, History, Google Drive, S3, Recording, Notifications, and Advanced sections. All settings changes are confirmed via a bottom toast notification.

### Download Flow

Expand All @@ -123,8 +126,8 @@ Media Bridge has five distinct execution contexts that communicate via `chrome.r

| Store | Data | Reason |
|-------|------|--------|
| **IndexedDB** (`media-bridge` v3) | `downloads` (state), `chunks` (segments) | Survives restarts; supports large `ArrayBuffer` |
| **`chrome.storage.local`** | Config (FFmpeg timeout, concurrency) | Simple K/V; 10 MB quota |
| **IndexedDB** (`media-bridge` v3) | `downloads` (state + history), `chunks` (segments) | Survives restarts; supports large `ArrayBuffer` |
| **`chrome.storage.local`** | All config via `loadSettings()` / `AppSettings` | Simple K/V; 10 MB quota |

### Project Structure

Expand Down Expand Up @@ -168,7 +171,8 @@ src/
│ │ ├── downloads.ts # Download state CRUD
│ │ └── chunks.ts # Segment chunk storage
│ ├── storage/
│ │ └── chrome-storage.ts # Config via chrome.storage.local
│ │ ├── chrome-storage.ts # Raw chrome.storage.local access
│ │ └── settings.ts # AppSettings interface + loadSettings() — always use this
│ ├── cloud/ # ⚠️ Planned — infrastructure exists, not yet wired up
│ │ ├── google-auth.ts
│ │ ├── google-drive.ts
Expand All @@ -189,7 +193,7 @@ src/
│ ├── logger.ts
│ └── url-utils.ts
├── popup/ # Popup UI (Videos / Downloads / Manifest tabs)
├── options/ # Options page (FFmpeg timeout, concurrency)
├── options/ # Options page (Download, History, Drive, S3, Recording, Notifications, Advanced)
├── offscreen/ # Offscreen document (FFmpeg.wasm processing)
└── types/
└── mpd-parser.d.ts # Type declarations for mpd-parser
Expand Down Expand Up @@ -251,7 +255,7 @@ npm run type-check

### FFmpeg Merge Fails
- File may exceed the ~2 GB limit — try a shorter clip or lower quality
- Increase FFmpeg timeout in Options if processing is slow
- Increase FFmpeg timeout in **Options → Download Settings** if processing is slow
- Check the offscreen document console for FFmpeg error output

### Extension Not Detecting Videos
Expand Down
1 change: 1 addition & 0 deletions manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"unlimitedStorage",
"storage",
"downloads",
"notifications",
"tabs",
"activeTab",
"offscreen",
Expand Down
189 changes: 189 additions & 0 deletions public/shared.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/* ---- Fonts ---- */
@font-face {
font-family: 'Inter';
font-weight: 400;
font-style: normal;
font-display: swap;
src: url('/fonts/inter-regular.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-weight: 500;
font-style: normal;
font-display: swap;
src: url('/fonts/inter-medium.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-weight: 600;
font-style: normal;
font-display: swap;
src: url('/fonts/inter-semibold.woff2') format('woff2');
}

/* ---- Design Tokens (Dark Default) ---- */
:root {
/* Surfaces */
--surface-0: #0f1117;
--surface-1: #181a22;
--surface-2: #1e2028;
--surface-3: #252830;

/* Text */
--text-primary: #e8eaf0;
--text-secondary: #8b8fa3;
--text-tertiary: #5c6070;

/* Accent */
--accent: #3b82f6;
--accent-hover: #2563eb;
--accent-subtle: rgba(59, 130, 246, 0.10);

/* Semantic */
--success: #34d399;
--warning: #fbbf24;
--error: #f87171;
--info: #60a5fa;
--recording: #ef4444;

/* Borders */
--border: rgba(255, 255, 255, 0.06);
--border-hover: rgba(255, 255, 255, 0.12);

/* Spacing */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;

/* Radii */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-pill: 9999px;
}

:root.light-mode {
--surface-0: #f4f5f7;
--surface-1: #ffffff;
--surface-2: #f0f1f4;
--surface-3: #e8e9ed;

--text-primary: #1a1c24;
--text-secondary: #5c5f72;
--text-tertiary: #8b8fa3;

--accent: #2563eb;
--accent-hover: #1d4ed8;
--accent-subtle: rgba(37, 99, 235, 0.08);

--border: rgba(0, 0, 0, 0.08);
--border-hover: rgba(0, 0, 0, 0.15);
}

/* ---- Reset ---- */
* { margin: 0; padding: 0; box-sizing: border-box; }

/* ---- Base ---- */
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--surface-0);
color: var(--text-primary);
}

/* ---- Scrollbar ---- */
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: var(--surface-3);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}

/* ---- Badges ---- */
.badge {
display: inline-flex;
align-items: center;
padding: 1px 7px;
border-radius: var(--radius-pill);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.01em;
white-space: nowrap;
}

.badge-resolution {
background: rgba(59, 130, 246, 0.15);
color: var(--info);
}

.badge-format {
background: rgba(139, 92, 246, 0.15);
color: #a78bfa;
}

.badge-link-type {
background: rgba(251, 191, 36, 0.12);
color: var(--warning);
}

.badge-duration {
color: var(--text-tertiary);
font-size: 10px;
}

.badge-completed {
background: rgba(52, 211, 153, 0.12);
color: var(--success);
}

.badge-failed {
background: rgba(248, 113, 113, 0.12);
color: var(--error);
}

.badge-cancelled {
background: rgba(251, 191, 36, 0.12);
color: var(--warning);
}

.badge-live {
background: rgba(248, 113, 113, 0.15);
color: #f87171;
}

/* Light-mode badge overrides */
:root.light-mode .badge-resolution {
background: rgba(37, 99, 235, 0.08);
color: #2563eb;
}

:root.light-mode .badge-format {
background: rgba(124, 58, 237, 0.08);
color: #7c3aed;
}

:root.light-mode .badge-link-type {
background: rgba(217, 119, 6, 0.08);
color: #d97706;
}

:root.light-mode .badge-completed {
background: rgba(22, 163, 74, 0.08);
color: #16a34a;
}

:root.light-mode .badge-failed {
background: rgba(220, 38, 38, 0.08);
color: #dc2626;
}

:root.light-mode .badge-cancelled {
background: rgba(217, 119, 6, 0.08);
color: #d97706;
}
10 changes: 8 additions & 2 deletions src/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
*/

import { MessageType } from "./shared/messages";
import { VideoMetadata, VideoFormat } from "./core/types";
import { VideoMetadata, VideoFormat, StorageConfig } from "./core/types";
import { DetectionManager } from "./core/detection/detection-manager";
import { normalizeUrl } from "./core/utils/url-utils";
import { logger } from "./core/utils/logger";
import { STORAGE_CONFIG_KEY } from "./shared/constants";

let detectedVideos: Record<string, VideoMetadata> = {};
let detectionManager: DetectionManager;
Expand Down Expand Up @@ -158,21 +159,26 @@ function addDetectedVideo(video: VideoMetadata) {
* Initialize content script
* Sets up detection manager, performs initial scan, and monitors DOM changes
*/
function init() {
async function init() {
// Reset icon to gray on page load (only from top frame)
if (!inIframe) {
safeSendMessage({
type: MessageType.SET_ICON_GRAY,
});
}

const stored = await chrome.storage.local.get(STORAGE_CONFIG_KEY);
const config: StorageConfig | undefined = stored[STORAGE_CONFIG_KEY];

detectionManager = new DetectionManager({
onVideoDetected: (video) => {
addDetectedVideo(video);
},
onVideoRemoved: (url) => {
removeDetectedVideo(url);
},
detectionCacheSize: config?.advanced?.detectionCacheSize,
masterPlaylistCacheSize: config?.advanced?.masterPlaylistCacheSize,
});

// Initialize all detection mechanisms
Expand Down
Loading