diff --git a/.github/workflows/build-web-ext.yml b/.github/workflows/build-web-ext.yml
index 3e7f9443..c71f1def 100644
--- a/.github/workflows/build-web-ext.yml
+++ b/.github/workflows/build-web-ext.yml
@@ -20,10 +20,12 @@ jobs:
uses: actions/checkout@v4
- name: Setup Bun
- uses: oven-sh/setup-bun@v1
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: 1.3.14
- name: Install Dependencies
- run: cd web-ext && bun install
+ run: cd web-ext && bun install --frozen-lockfile
- name: Build for Firefox
run: cd web-ext && bun run build:firefox
@@ -35,10 +37,14 @@ jobs:
uses: actions/checkout@v4
- name: Setup Bun
- uses: oven-sh/setup-bun@v1
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: 1.3.14
- name: Install Dependencies
- run: cd web-ext && bun install
+ run: cd web-ext && bun install --frozen-lockfile
+ - name: Check
+ run: cd web-ext && bun run check
- name: Build for Chrome
run: cd web-ext && bun run build
diff --git a/AGENTS.md b/AGENTS.md
index 3d0f29e2..c8edb6fe 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -19,7 +19,7 @@ This file holds the rules that apply **everywhere in the repo**. Anything that a
| You are touching | Read this too | What it is |
| --- | --- | --- |
-| `web-ext/` | _(not written yet — root rules only)_ | Chrome/Firefox extension for YouTube |
+| `web-ext/` | [`AGENTS/web-ext.md`](AGENTS/web-ext.md) | Chrome/Firefox extension for YouTube |
| `website/` | [`website/AGENTS.md`](website/AGENTS.md) | Next.js marketing and docs site (static export) |
| `backend/` | [`backend/AGENTS.md`](backend/AGENTS.md) | Cloudflare Worker behind `/api` |
| `assets/` | _(none)_ | Brand assets |
@@ -78,6 +78,7 @@ The repo predates this rule and still carries comments that break it. Do not lau
- **Smallest diff that works.** Do not restructure files you were not asked to change. Do not add abstractions for a second caller that does not exist yet.
- **Delete over add.** Removing dead code is a complete contribution.
- **Match the surrounding file** in naming, import ordering, and idiom, even where it differs from your own preference.
+- **File order:** imports, then types, then module-level constants and state (selectors, ids, limits, caches, flags), then functions. A value built from the file's own helpers (a settings slice, a feature object, a catalogue) comes after the helpers it uses.
- **No new dependencies** for something a few lines of platform or standard-library code already do. If a dependency is genuinely needed, say why before adding it.
- **Never commit unless asked.** When asked, stage only the files your task touched — the working tree often carries unrelated in-progress work. No `Co-Authored-By` or agent-attribution trailers.
- **Verify before claiming done.** Run the project's type-check or test command and report what it actually printed, including pre-existing failures you did not cause.
diff --git a/AGENTS/web-ext.md b/AGENTS/web-ext.md
new file mode 100644
index 00000000..a9526fe3
--- /dev/null
+++ b/AGENTS/web-ext.md
@@ -0,0 +1,128 @@
+# AGENTS/web-ext.md
+
+Scoped rules for `web-ext/`, the Chrome and Firefox extension. Read [`../AGENTS.md`](../AGENTS.md) first — its rules, especially the comment policy, apply here in full.
+
+---
+
+## 1. COMMANDS
+
+Run from `web-ext/`:
+
+| Command | What it does |
+| --- | --- |
+| `bun run dev` | Build into `dist/` (Chrome, MV3) and rebuild on change |
+| `bun run dev:firefox` | Same, transformed to MV2 |
+| `bun run build` / `build:firefox` | Production build |
+| `bun run check` | `tsc --noEmit` (app and `scripts/`), then `bun test` |
+| `bun run release` / `release:firefox` | Build, then zip `dist/` via `scripts/release.ts` |
+
+The build is `scripts/build.ts`: `Bun.build` per entry (IIFE, no code splitting), Tailwind through its PostCSS API from `tailwind.config.ts`, static copies, and the manifest transform. Set `DIST=
` to build somewhere other than `dist/`, so a Chrome and a Firefox build can coexist. CSS is not imported from JS: the popup and options pages link their stylesheet; the content script ships none, because everything it injects is styled inside its own shadow roots.
+
+---
+
+## 2. ENTRY POINTS
+
+Five entries, all under `app/` (`scripts/build.ts`):
+
+| Entry | Source | Runs in |
+| --- | --- | --- |
+| `background` | `app/background/index.ts` | Service worker (MV3) / background page (MV2) |
+| `content` | `app/content/index.ts` | Injected into YouTube pages |
+| `popup` | `app/popup/index.tsx` | Toolbar popup |
+| `options` | `app/options/index.tsx` | Options page |
+| `offscreen` | `app/offscreen/index.ts` | Chrome only: a hidden page the service worker opens to read location (`lib/location.ts`) |
+
+The content script does one thing: `bootFeatures([...features], onNavigate, { enabled })`. The background serves feature messages, migrates the pre-kernel store, keeps the toolbar icon in step with the master switch, and runs profile schedules (`features/schedules/background.ts`) on a one-minute alarm, with their status on the toolbar badge.
+
+---
+
+## 3. THE KERNEL
+
+Every feature is one folder under `features/` exporting a `Feature` (`kernel/features.ts`). The kernel mounts a feature on each navigation that lands on one of its `routes` and calls the returned unmount on the next, so `mount` always starts from a clean page and never has to be idempotent. YouTube is a single-page app; `youtube/route.ts` turns its `yt-navigate-finish` event into a `Route`.
+
+A feature folder holds, as needed:
+
+- `settings.ts` — its slice: `defineSettings(id, defaults, { legacy?, area? })` (`kernel/settings.ts`), stored as `settings:` in `chrome.storage.sync` (or `local` for data too big or personal to roam). `legacy` maps the pre-kernel store into the slice; `migrateSettings` runs each once. Pages read a slice with `useSettings`.
+- `keys.ts` — its keyboard actions: `defineKeys({ id, title, keys })` (`kernel/keys.ts`). A feature binds handlers at mount with `bindKeys`; the user's overrides live in the `keybindings` slice by action id (`playback.seekForward`); the Shortcuts page renders every group from the registries. Data-driven shortcuts (a saved segment config's own key) are the feature's business.
+- `messages.ts` — anything the background must do for it: `defineMessage(kind)` (`kernel/messaging.ts`), handled in a `background.ts` next to the feature.
+- `index.tsx` — the `Feature`. Where it needs a page element it asks `youtube/` (`resolveVideo`, `resolveRatePanel`, …), which wraps `resolveTarget` (`kernel/dom/resolve.ts`) around ordered selector strategies, and records the outcome with `setCapabilityStatus` so the options page can say a selector broke. `resolveTarget` skips elements that are not rendered, because YouTube keeps pages you left alive but hidden, and a bare `document.querySelector` can answer from one of those. Lookups inside something already resolved live (the player, the Shorts page) use `findWithin(root, strategies)` instead, which does not require visibility: there YouTube hides things on purpose, like the settings menu while closed. **Selectors live in `youtube/`, not in features.**
+- UI is Preact. `mount()` (`kernel/dom/mount.ts`) renders into a shadow root so YouTube's CSS and ours never meet; `mountInline()` renders into a host the caller shapes when the element must take YouTube's own classes (a `.ytp-button`, a settings-menu row). In-page styles read YouTube's colour tokens through `themeTokens` (`kernel/dom/theme.ts`) and so follow the page's theme. `showToast` (`kernel/dom/toast.tsx`) is the shared notice.
+- Profiles are built on primitives: `youtube/primitives.ts` catalogues each page knob (id, routes, strategies, `parse`, idempotent `apply`, `reset`), and `runPrimitives` (`kernel/primitives.ts`) keeps a set of values applied while the page re-renders and restores everything on stop.
+- Tests run under Bun with happy-dom; `test/setup.ts` registers the DOM and an in-memory `chrome.storage` that emits change events. A feature takes anything it cannot get from the page as a `deps` object (a fetcher, a storage) so tests mount it against HTML fixtures of each YouTube layout. `features/playlist-runtime/index.test.ts` and `features/segments/index.test.ts` are the patterns.
+
+Live verification covers both browsers. Chromium: Playwright loading the built `dist/`. Firefox: Puppeteer over WebDriver BiDi with `browser.installExtension`, launched with `-remote-allow-system-access`; BiDi will not navigate to `moz-extension://` pages directly, so a throwaway copy of the build lists `options/*` and `popup/*` as web-accessible and is reached from a normal page. Headless, logged-out YouTube serves older layouts than a signed-in browser, so a strategy list should carry both.
+
+---
+
+## 4. PAGES
+
+`app/popup/` and `app/options/` are Preact pages (`preact/hooks`; `preact/compat` only for portals). Options routes by hash (`app/options/router.ts`); the search box (`app/options/search/`) locates a result by its rendered label, so a copy edit in a page must land in `searchIndex.ts` too.
+
+The pages wear the Toppings brand, shared with the website: ink, bone and ember on `--color-*` tokens in `ui/theme.css`, Gloock for display (`tw-font-display`) and Instrument Sans for text, both bundled in `assets/fonts` so the popup never calls a third party. Only what Toppings draws inside YouTube takes YouTube's palette (`kernel/dom/theme.ts`). Tailwind runs prefixed and without preflight; `ui/base.css` is the reset, and sets the font on `body` because Chrome gives extension pages' body its own system font. Tailwind's opacity modifiers (`tw-bg-accent/50`) do nothing on these variable colours — use `color-mix` in a style attribute instead.
+
+---
+
+## 5. STORAGE
+
+| Layer | Holds | Accessed via |
+| --- | --- | --- |
+| `chrome.storage.sync` | Every settings slice, `settings:` | `kernel/settings.ts` |
+| `chrome.storage.local` | The profile store (`settings:profiles`), schedules and places (`settings:schedules`), schedule state (`settings:schedule-state`), the capability cache (`toppings:capability:`), feature reports, per-device UI flags | `features/profiles/store.ts`, `features/schedules/`, `kernel/dom/capabilities.ts`, `kernel/dom/featureReports.ts`, `lib/useChromeStorageLocal.ts` |
+| IndexedDB | Per-video segment data | `lib/indexedDb.ts`, `features/segments/store.ts` |
+
+Anything the options page or background must read goes in extension storage, never IndexedDB: a content script's IndexedDB belongs to youtube.com.
+
+A slice is read with its defaults merged in, which is also how a new setting reaches existing users. The pre-kernel store (`isExtensionEnabled`, `ui`, `preferences`) is migrated once by `app/background/migrations.ts` and then removed; `migrations.test.ts` pins the mapping.
+
+---
+
+## 6. MANIFEST AND VERSIONING
+
+- `app/manifest.json` is the **MV3 source of truth**. The MV2 Firefox variant is generated at build time by the transform in `scripts/build.ts` (flips `manifest_version`, folds `host_permissions` into `permissions`, renames `action` → `browser_action`, flattens `web_accessible_resources`). Never hand-maintain a second manifest; extend the transform.
+- Version lives in `lib/version.ts` as `EXTENSION_VERSION` and is injected into the manifest at build time; a pre-release suffix (`4.0.0-alpha`) goes to `version_name`, since `version` takes only numbers. It is mirrored in `website/lib/site.ts` (`site.version`) only on a store release — update both then.
+
+---
+
+## 7. LAYOUT
+
+```text
+web-ext/
+├── app/ # The four surfaces, and only their shells
+│ ├── background/ # Install hooks, migrations, message handlers, icon
+│ ├── content/ # bootFeatures
+│ ├── options/ # Preact options page: router, layout, search, routes/
+│ ├── popup/ # Preact toolbar popup
+│ └── settings.ts # The app slice: master switch and theme
+├── kernel/ # Feature contract, settings, keys, messaging, primitives,
+│ # dom/ (resolve, mount, capabilities, reports, theme, toast)
+├── youtube/ # Routes, navigation, selectors per page area, the primitive catalogue
+├── features/ # One folder per thing the extension does
+│ ├── playback/ # Default rate, rate and seek keys, custom rates in the speed panel
+│ ├── playlist-runtime/# Runtime statistics on playlist and watch pages
+│ ├── profiles/ # Presets, profile store, gear-menu panel, native settings, import/export
+│ ├── segments/ # Engine, session, storage, markers, button, panel/
+│ └── shorts/ # Auto-scroll, rate and seek on Shorts
+├── lib/ # Plumbing with no YouTube or kernel knowledge: storage keys,
+│ # indexedDb, duration, urls, version, brand
+├── ui/ # Shared page components: form, feedback, layout, primitives, theme
+├── test/ # Bun test preload
+└── scripts/ # build.ts (Bun.build + manifest transform), release.ts
+```
+
+Imports across folders use the `@/` root alias; same-folder imports stay relative. There are no barrel `index.ts` files: import the file that defines what you need. Options routes stay under `app/options/routes` because they do not map one-to-one onto features.
+
+A helper that only one feature uses lives in that feature's folder. It moves to `kernel/` when it is about running features, to `youtube/` when it is about YouTube's page, and to `lib/` only when it is neither.
+
+---
+
+## 8. YOUTUBE-SPECIFIC GOTCHAS
+
+- Selectors in `youtube/` target YouTube's markup and break when YouTube ships changes. They are the correct place for a "why" comment naming the layout variant a strategy targets.
+- YouTube's Polymer lists (the guide, the settings menu) drop foreign children when they re-render; anything injected into one must be put back on a `MutationObserver` (`features/profiles/index.tsx`).
+- The player's speed panel is a slider with preset chips, not a menu list; the playback feature replaces the chips and syncs the slider and display itself, because YouTube only redraws them from its own state.
+- The desktop player has no double-tap seek overlay to reuse; the playback feature draws its own.
+- An install or update cuts off the content script in open tabs (Firefox unloads it; Chrome leaves it running without the extension). The background starts the new one in those tabs, and a new copy dispatches `tppng:takeover` so an older one stops (`app/content/index.ts`); removing that handshake doubles every shortcut after an update.
+- `process.env.BROWSER` is `"chrome"` or `"firefox"` at build time and branches on it are folded away, so a Firefox bundle carries no Chrome-only API calls for AMO's linter to flag.
+- Location is asked for the way a website asks, from the options page; the manifest holds no location permission, because Chrome cannot make it optional and a required one pauses the extension for every user on update.
+- Firefox's `chrome.*` namespace is callback-only under MV2: called for a promise it returns `undefined`. The code calls `chrome.*` promise-style, and the Firefox build rewrites the global to `browser` (`scripts/build.ts`). Never pass callbacks to extension APIs, or the Firefox build breaks the other way.
+- YouTube's theme reaches shadow roots as `--yt-sys-color-baseline--*` custom properties on ``; a shadow host reset with `all: initial` would discard them.
diff --git a/README.md b/README.md
index 0347fbfe..85ac78db 100644
--- a/README.md
+++ b/README.md
@@ -1,160 +1,106 @@
-# Toppings on YouTube
+
+
+
-A customizable browser extension that gives you total control over YouTube—track playlist runtimes, fine-tune playback speed, auto-scroll Shorts, set custom seek durations, and more. Take control of your YouTube like never before.
+Toppings
----
-
-## Why Toppings?
-
-- **Track Playlist Runtimes**: Know exactly how much time you’ll spend binging playlists.
-- **Custom Playback Controls**: Set precise playback speeds and seek durations.
-- **Auto-Scroll Shorts**: Enjoy an uninterrupted flow of Shorts with automatic scrolling.
-- **Deep Personalization**: Tailor every feature to fit your workflow with custom keybindings.
-- **Privacy-First**: No data collection—your browsing remains yours.
-
----
-
-## Get Started
-
-- **[Download for Chrome](https://chrome.google.com/webstore/detail/toppings/aemiblppibhggpgijajindcmmomboibl)**
-- **[Download for Firefox](https://addons.mozilla.org/en-US/firefox/addon/toppings/)**
-
----
+
+ Your YouTube, your way.
+ A free, open-source browser extension that adds the controls YouTube's player is missing.
+
-## Development
-
-Here’s how to set up Toppings for local development:
-
-### Prerequisites
-
-Ensure the following tools are installed:
-
-- [Bun](https://bun.sh/)
-- [Git](https://git-scm.com/)
-- A [Google API Key](https://console.developers.google.com/) with access to the YouTube API
-
-### 📁 Project Structure
-
-The repository is organized into three main directories:
-
-1. **web-ext**: Contains the browser extension code.
-2. **backend**: Server-side logic built with Cloudflare Workers.
-3. **website**: The official homepage of the Toppings extension, built with Next.js.
+
+ toppings.enry.ch ·
+ Chrome Web Store ·
+ Firefox Add-ons
+
---
-### 🏡 Local Development Setup
+I built Toppings because YouTube's player is missing things I wanted every day. It's a side project, it's free, and it stays that way. Everything it adds looks like YouTube, follows YouTube's light or dark theme, and gets out of the way.
-#### Setting Up the Extension
+## What it does
-1. Clone the repository:
+- **Profiles.** Presets that reshape YouTube in one switch. *Audio* covers the video with a waveform that moves with the sound and leaves the player's controls alone; press B to flip it on and off. *Focus* hides the sidebar, comments and end cards. You can make your own, give each its own shortcut, and export or import them as JSON.
+- **Schedules.** Turn a profile on during set hours (say Audio on weekdays 9 to 6) or while you're at a saved place, and back off afterwards. The toolbar badge and a note on YouTube tell you when a schedule takes over or is about to.
+- **Loop segments.** Mark in and out points and loop a section. You can also line up several segments, each with its own loop count and speed.
+- **Custom playback rates.** Speeds YouTube doesn't offer, from 0.0625× to 16×, right in the player's own speed panel.
+- **Toggle rate.** One key flips between 1× and your preferred fast rate.
+- **Seek shortcuts.** Jump back and forward by however many seconds you like.
+- **Shorts auto-scroll.** Moves on to the next Short when one ends.
+- **Playlist runtime.** Shows the total and average runtime at the top of every playlist.
- ```bash
- git clone https://github.com/enrych/toppings.git
- cd toppings/web-ext
- ```
+Every shortcut can be rebound. The defaults are listed at [toppings.enry.ch/docs/keybindings](https://toppings.enry.ch/docs/keybindings).
-2. Install dependencies:
+## Privacy
- ```bash
- bun install
- ```
+There are no accounts, no analytics and no trackers. Your settings live in your browser's extension storage. The one exception is playlist runtime. YouTube's page doesn't expose video durations, so the extension sends the playlist ID to a small Cloudflare Worker at `toppings.enry.ch/api`, which asks the YouTube Data API. That's all it sends.
-3. Start development mode:
+## Docs
- For Chrome:
+- [Getting started](https://toppings.enry.ch/docs)
+- [Keybindings](https://toppings.enry.ch/docs/keybindings)
+- [FAQ](https://toppings.enry.ch/docs/faq)
+- [Changelog](https://toppings.enry.ch/docs/changelog)
- ```bash
- bun run dev
- ```
+## Hacking on it
- For Firefox:
+The repo holds three projects, each on [Bun](https://bun.sh):
- ```bash
- bun run dev:firefox
- ```
+| Folder | What it is |
+| --- | --- |
+| [`web-ext/`](web-ext) | The extension, for Chrome (MV3) and Firefox (MV2) from one codebase |
+| [`backend/`](backend) | The Cloudflare Worker behind `/api` |
+| [`website/`](website) | [toppings.enry.ch](https://toppings.enry.ch), a static Next.js export on Cloudflare Pages |
----
+If you use a coding agent, point it at [`AGENTS.md`](AGENTS.md) first. That file holds the house rules, and each project has its own scoped notes.
-#### Setting Up the Server (Optional)
+### Extension
-Some features require backend functionality. You can run the server locally using Cloudflare Workers.
+```bash
+cd web-ext
+bun install
+bun run dev # builds dist/ for Chrome and rebuilds on change
+bun run dev:firefox # same, as a Firefox MV2 build
+```
-1. Navigate to the backend directory:
+To load it in Chrome, open `chrome://extensions`, turn on Developer mode, click **Load unpacked** and pick `web-ext/dist`. In Firefox, open `about:debugging`, go to **This Firefox**, click **Load Temporary Add-on** and pick `web-ext/dist/manifest.json`.
- ```bash
- cd ../backend
- ```
+`bun run check` type-checks and runs the tests. `bun run build` makes a production build.
-2. Install `wrangler` (if not already installed):
+Every build, dev included, asks the live API at `toppings.enry.ch/api` for playlist runtimes, so you don't need the backend running. To work on the Worker itself, point a build at it with `TOPPINGS_API=http://127.0.0.1:8787/api bun run dev`.
- ```bash
- npm install -g wrangler
- ```
+### Backend
-3. Add your Google YouTube API secret in `.dev.vars`:
+You need a [YouTube Data API v3 key](https://console.cloud.google.com/apis/library/youtube.googleapis.com).
- ```env
- GOOGLE_SECRET=your-google-youtube-api-key
- ```
+```bash
+cd backend
+bun install
+echo "YOUTUBE_DATA_API_V3_KEY=your-key" > .dev.vars # gitignored
+bun run dev # wrangler dev on http://127.0.0.1:8787
+```
-4. Start the server:
+### Website
- ```bash
- wrangler dev
- ```
+```bash
+cd website
+bun install
+bun run dev
+```
----
+`bun run check` runs the type-check and lint. `bun run build` writes the static site to `out/`.
-#### Running the Website
+Pushes to `main` deploy the website and the backend. The extension ships to the stores by hand.
-1. Navigate to the `website` directory:
+## Bugs, ideas, help
- ```bash
- cd ../website
- ```
+Found something broken or have an idea? [Open an issue](https://github.com/enrych/toppings/issues). YouTube changes its page often, so if a feature shows as unavailable in the options page, the **Report** button there fills in most of the issue for you.
-2. Install dependencies:
+## Add a topping
- ```bash
- bun install
- ```
-
-3. Start the development server:
-
- ```bash
- bun run dev
- ```
-
----
-
-## Feedback and Support
-
-Feedback is always welcome! If you enjoy Toppings, consider:
-
-- Leaving a ⭐ on [GitHub](https://github.com/enrych/toppings)
-- Reviewing on:
- - [Chrome Web Store](https://chrome.google.com/webstore/detail/toppings/aemiblppibhggpgijajindcmmomboibl)
- - [Firefox Add-ons](https://addons.mozilla.org/en-US/firefox/addon/toppings/)
-
-Use these channels to get involved:
-
-- **Discussions**: For questions or feature ideas, join the [GitHub Discussions](https://github.com/enrych/toppings/discussions).
-- **Issues**: Report bugs or request features in the [Issues](https://github.com/enrych/toppings/issues) section.
-- **Wiki**: Find detailed documentation in the [GitHub wiki](https://github.com/enrych/toppings/wiki).
-
----
-
-## Support the Project
-
-Toppings is free and open-source. If you find it helpful, consider supporting the development through [sponsorships](https://darhkvoyd.me/sponsor).
-
-Your support helps improve Toppings and keeps it sustainable.
-
----
+Toppings is free and always will be. If it saves you time, you can [add a topping](https://darhkvoyd.me/sponsor) to keep it going. A ⭐ here or a review on the [Chrome Web Store](https://chrome.google.com/webstore/detail/toppings/aemiblppibhggpgijajindcmmomboibl) or [Firefox Add-ons](https://addons.mozilla.org/en-US/firefox/addon/toppings/) helps a lot too.
## License
-Toppings is licensed under the [GPL-3.0 License](./LICENSE). You’re free to use, modify, and distribute the extension as long as your work complies with the GPL-3.0 terms.
-
-Happy coding! 😊
+[GPL-3.0](LICENSE). Fork it, change it, ship it. Keep it open.
diff --git a/backend/AGENTS.md b/backend/AGENTS.md
index 280dea54..aae9553b 100644
--- a/backend/AGENTS.md
+++ b/backend/AGENTS.md
@@ -11,7 +11,7 @@ A Cloudflare Worker serving `https://toppings.enry.ch/api/*` for the extension.
## Contract with the extension
-`web-ext/app/background/api.ts` calls these; change them together.
+`web-ext/features/playlist-runtime/background.ts` calls these; change them together.
- `GET /api/ping` → `"pong"`
- `GET /api/v1/playlist/:playlistId` → `{ scope: "playlist", payload: { playlistId, totalVideos, totalRuntime, averageRuntime } }`, runtimes in seconds. Errors are `{ status, error }` with the same HTTP status.
diff --git a/backend/src/index.ts b/backend/src/index.ts
index 673282ad..06374ce7 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -2,6 +2,8 @@ import { CORS, HttpError, json } from "./http";
import { getPlaylistRuntime } from "./playlist";
const PLAYLIST_PATH = /^\/api\/v1\/playlist\/([^/]+)$/;
+// Checked before any Data API call, so a junk ID costs no quota.
+const PLAYLIST_ID = /^[\w-]{2,64}$/;
async function route(request: Request, env: Env): Promise {
const { pathname } = new URL(request.url);
@@ -13,10 +15,11 @@ async function route(request: Request, env: Env): Promise {
const playlist = PLAYLIST_PATH.exec(pathname);
if (playlist) {
+ const playlistId = playlist[1];
+ if (!PLAYLIST_ID.test(playlistId)) throw new HttpError(400, "Invalid playlist ID");
if (!env.YOUTUBE_DATA_API_V3_KEY) {
throw new HttpError(500, "YOUTUBE_DATA_API_V3_KEY is not set");
}
- const playlistId = playlist[1];
return json({
scope: "playlist",
payload: await getPlaylistRuntime(playlistId, env.YOUTUBE_DATA_API_V3_KEY),
diff --git a/backend/src/playlist.ts b/backend/src/playlist.ts
index 9fd45309..a025c9d5 100644
--- a/backend/src/playlist.ts
+++ b/backend/src/playlist.ts
@@ -1,13 +1,23 @@
import { errorMessage, HttpError } from "./http";
+// Playlist pages come one after another, since each page's token arrives
+// with the page before it, but a page's durations need nothing further, so
+// they are fetched while the next page loads rather than before it.
export async function getPlaylistRuntime(playlistId: string, apiKey: string) {
let totalVideos = 0;
- let totalRuntime = 0;
+ const durations: Promise[] = [];
+ let failed = false;
for await (const videoIds of fetchVideoIdsByPage(playlistId, apiKey)) {
totalVideos += videoIds.length;
- totalRuntime += await fetchDurationSeconds(videoIds, apiKey);
+ const duration = fetchDurationSeconds(videoIds, apiKey);
+ // The Promise.all below reports the failure; noting it here stops paging
+ // through a list whose answer is already lost, each page costing quota.
+ duration.catch(() => (failed = true));
+ durations.push(duration);
+ if (failed) break;
}
+ const totalRuntime = (await Promise.all(durations)).reduce((sum, seconds) => sum + seconds, 0);
return {
playlistId,
@@ -48,12 +58,13 @@ async function fetchDurationSeconds(videoIds: string[], apiKey: string): Promise
return total;
}
-// YouTube writes durations as ISO 8601, e.g. PT1H2M3S, with any part optional.
+// YouTube writes durations as ISO 8601, e.g. PT1H2M3S or P1DT2H for a video
+// over a day, with any part optional; P0D is a live or upcoming stream.
function toSeconds(isoDuration: string): number {
- const match = /PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?/.exec(isoDuration);
+ const match = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/.exec(isoDuration);
if (!match) return 0;
- const [, hours = "0", minutes = "0", seconds = "0"] = match;
- return Number(hours) * 3600 + Number(minutes) * 60 + Number(seconds);
+ const [, days = "0", hours = "0", minutes = "0", seconds = "0"] = match;
+ return Number(days) * 86400 + Number(hours) * 3600 + Number(minutes) * 60 + Number(seconds);
}
// The Data API answers 404 for a playlist that is private or deleted.
diff --git a/web-ext/app/background/index.ts b/web-ext/app/background/index.ts
new file mode 100644
index 00000000..8ba9833f
--- /dev/null
+++ b/web-ext/app/background/index.ts
@@ -0,0 +1,64 @@
+import { getFeatureReports, markRecovered, removeFeatureReport } from "@/kernel/dom/featureReports";
+import type { CapabilityCacheEntry } from "@/kernel/dom/capabilities";
+import { CHROME_STORAGE_LOCAL_KEY } from "@/lib/storageKeys";
+import { URLS } from "@/lib/urls";
+import { appSettings } from "@/app/settings";
+import { servePlaylistRuntime } from "@/features/playlist-runtime/background";
+import { runSchedules } from "@/features/schedules/background";
+import { migrateLegacyStore } from "./migrations";
+
+servePlaylistRuntime();
+runSchedules();
+
+// Also on every start, not only on install: on a new device, sync can deliver
+// the old settings after the install event has already run. Each slice is
+// migrated once, so repeating this is harmless.
+void migrateLegacyStore();
+
+chrome.runtime.onInstalled.addListener(async ({ reason }) => {
+ if (reason !== "install" && reason !== "update") return;
+ if (process.env.NODE_ENV === "production") {
+ void chrome.tabs.create({ url: URLS.GREETINGS });
+ void chrome.runtime.setUninstallURL(URLS.FAREWELL);
+ }
+ await migrateLegacyStore();
+ await startInOpenTabs();
+});
+
+// Tabs open across an install or update have no working Toppings until they
+// reload, since the old content script is cut off; this starts the new one.
+async function startInOpenTabs(): Promise {
+ for (const tab of await chrome.tabs.query({ url: "https://www.youtube.com/*" })) {
+ if (tab.id === undefined) continue;
+ // A discarded or still-loading tab refuses; it gets the script when it loads.
+ void chrome.scripting.executeScript({ target: { tabId: tab.id, allFrames: true }, files: ["/content.js"] }).catch(() => {});
+ }
+}
+
+// The toolbar icon greys out while the master switch is off.
+function setIcon(enabled: boolean): void {
+ const prefix = enabled ? "" : "disabled_";
+ const path = Object.fromEntries([16, 32, 48, 128].map((size) => [size, `/assets/icons/${prefix}icon${size}.png`]));
+ // Firefox still ships MV2, where the toolbar button is browserAction.
+ // @ts-expect-error chrome-types only declares MV3
+ void (chrome.action ?? chrome.browserAction).setIcon({ path });
+}
+void appSettings.get().then((app) => setIcon(app.enabled));
+appSettings.subscribe((app) => setIcon(app.enabled));
+
+// A reported primitive that a content script now finds is marked recovered
+// for the options page to surface; after an update adds strategies, that is
+// the first YouTube page the new version loads.
+chrome.storage.onChanged.addListener((changes, area) => {
+ if (area !== "local") return;
+ for (const [key, change] of Object.entries(changes)) {
+ const entry = change.newValue as CapabilityCacheEntry | undefined;
+ if (key.startsWith(CHROME_STORAGE_LOCAL_KEY.CAPABILITY_PREFIX) && entry?.status === "supported") void resolveReport(entry.primitiveId);
+ }
+});
+
+async function resolveReport(primitiveId: string): Promise {
+ if (!(await getFeatureReports()).some((report) => report.primitiveId === primitiveId)) return;
+ await markRecovered(primitiveId);
+ await removeFeatureReport(primitiveId);
+}
diff --git a/web-ext/app/background/migrations.test.ts b/web-ext/app/background/migrations.test.ts
new file mode 100644
index 00000000..681c043d
--- /dev/null
+++ b/web-ext/app/background/migrations.test.ts
@@ -0,0 +1,77 @@
+import { beforeEach, describe, expect, test } from "bun:test";
+import { migrateLegacyStore } from "./migrations";
+import { appSettings } from "@/app/settings";
+import { playbackSettings } from "@/features/playback/settings";
+import { shortsSettings } from "@/features/shorts/settings";
+import { segmentsSettings } from "@/features/segments/settings";
+import { profilesSettings } from "@/features/profiles/settings";
+import { profileStore } from "@/features/profiles/store";
+import { keybindings } from "@/kernel/keys";
+
+const legacy = {
+ isExtensionEnabled: false,
+ ui: { theme: "light", gearMenuEnabled: true },
+ preferences: {
+ watch: {
+ isEnabled: true,
+ defaultPlaybackRate: { value: "1.5" },
+ togglePlaybackRate: { key: "T", value: "2" },
+ increasePlaybackRate: { key: "W", value: "0.5" },
+ seekBackward: { key: "A", value: "10" },
+ seekForward: { key: "D", value: "20" },
+ customPlaybackRates: ["1.00", "1.50"],
+ toggleLoopSegment: { key: "L" },
+ nudgeLoopSegment: { startBackwardKey: "Shift+Q", baseStep: "2", multiplier: "3", maxStep: "8" },
+ segments: { freshSlateKey: "Shift+Z", autoLoad: "last-used" },
+ },
+ shorts: { isEnabled: false, reelAutoScroll: { value: false }, togglePlaybackRate: { key: "X", value: "1.75" } },
+ },
+};
+
+beforeEach(async () => {
+ await chrome.storage.sync.clear();
+ await chrome.storage.local.clear();
+});
+
+describe("migrateLegacyStore", () => {
+ test("copies every slice out of the old store and drops the old keys", async () => {
+ await chrome.storage.sync.set(legacy);
+ await chrome.storage.local.set({ "toppings:profile_store": { activeProfileId: "preset:focus", profiles: [] } });
+ await migrateLegacyStore();
+
+ expect(await appSettings.get()).toEqual({ enabled: false, theme: "light" });
+ expect(await playbackSettings.get()).toEqual({ enabled: true, defaultRate: 1.5, toggleRate: 2, rateStep: 0.5, seekBackward: 10, seekForward: 20, customRates: [1, 1.5] });
+ expect(await shortsSettings.get()).toMatchObject({ enabled: false, autoScroll: false, toggleRate: 1.75, seekBackward: 5 });
+ expect(await segmentsSettings.get()).toEqual({ enabled: true, autoLoad: "last-used", nudgeBaseStep: 2, nudgeMultiplier: 3, nudgeMaxStep: 8 });
+ expect(await profilesSettings.get()).toEqual({ gearMenu: true, audioButton: true });
+ expect((await profileStore.get()).activeProfileId).toBe("preset:focus");
+ expect(await keybindings.get()).toEqual({ "playback.toggleRate": "T", "playback.increaseRate": "W", "playback.seekBackward": "A", "playback.seekForward": "D", "segments.toggle": "L", "segments.fresh": "Shift+Z", "segments.nudgeStartBackward": "Shift+Q", "shorts.toggleRate": "X" });
+ expect(Object.keys(await chrome.storage.sync.get()).filter((k) => !k.startsWith("settings:"))).toEqual([]);
+ });
+
+ test("a watch page switched off keeps segments off too", async () => {
+ await chrome.storage.sync.set({ preferences: { watch: { ...legacy.preferences.watch, isEnabled: false } } });
+ await migrateLegacyStore();
+ expect((await playbackSettings.get()).enabled).toBe(false);
+ expect((await segmentsSettings.get()).enabled).toBe(false);
+ });
+
+ test("3.x audio mode's key and player button carry over to the Audio profile", async () => {
+ await chrome.storage.sync.set({ preferences: { watch: { audioMode: { isEnabled: false, toggleAudioMode: { key: "Shift+B" } } } } });
+ await migrateLegacyStore();
+ expect((await keybindings.get())["profiles.preset:audio"]).toBe("Shift+B");
+ expect((await profilesSettings.get()).audioButton).toBe(false);
+ });
+
+ test("a fresh install keeps every default", async () => {
+ await migrateLegacyStore();
+ expect(await appSettings.get()).toEqual({ enabled: true, theme: "system" });
+ expect(await playbackSettings.get()).toEqual(playbackSettings.defaults);
+ });
+
+ test("runs once: a slice the user already changed is left alone", async () => {
+ await chrome.storage.sync.set({ ...legacy, "settings:app": { enabled: true, theme: "dark" } });
+ await migrateLegacyStore();
+ expect(await appSettings.get()).toEqual({ enabled: true, theme: "dark" });
+ });
+});
diff --git a/web-ext/app/background/migrations.ts b/web-ext/app/background/migrations.ts
new file mode 100644
index 00000000..4e353291
--- /dev/null
+++ b/web-ext/app/background/migrations.ts
@@ -0,0 +1,59 @@
+import { keybindings } from "@/kernel/keys";
+import { appSettings } from "@/app/settings";
+import { migrateSettings, type Settings } from "@/kernel/settings";
+import { playlistRuntimeSettings } from "@/features/playlist-runtime/settings";
+import { shortsSettings } from "@/features/shorts/settings";
+import { playbackSettings } from "@/features/playback/settings";
+import { profilesSettings } from "@/features/profiles/settings";
+import { profileStore } from "@/features/profiles/store";
+import { segmentsSettings } from "@/features/segments/settings";
+
+// Where each action's binding lived in the pre-kernel store, by action id.
+const LEGACY_BINDINGS: Record = {
+ "playback.toggleRate": "preferences.watch.togglePlaybackRate.key",
+ "playback.increaseRate": "preferences.watch.increasePlaybackRate.key",
+ "playback.decreaseRate": "preferences.watch.decreasePlaybackRate.key",
+ "playback.seekBackward": "preferences.watch.seekBackward.key",
+ "playback.seekForward": "preferences.watch.seekForward.key",
+ "profiles.cycle": "preferences.watch.cycleProfiles.key",
+ "segments.toggle": "preferences.watch.toggleLoopSegment.key",
+ "segments.fresh": "preferences.watch.segments.freshSlateKey",
+ "segments.setStart": "preferences.watch.setLoopSegmentBegin.key",
+ "segments.setEnd": "preferences.watch.setLoopSegmentEnd.key",
+ "segments.save": "preferences.watch.saveLoopSegment.key",
+ "segments.nudgeStartBackward": "preferences.watch.nudgeLoopSegment.startBackwardKey",
+ "segments.nudgeStartForward": "preferences.watch.nudgeLoopSegment.startForwardKey",
+ "segments.nudgeEndBackward": "preferences.watch.nudgeLoopSegment.endBackwardKey",
+ "segments.nudgeEndForward": "preferences.watch.nudgeLoopSegment.endForwardKey",
+ "shorts.toggleRate": "preferences.shorts.togglePlaybackRate.key",
+ "shorts.seekBackward": "preferences.shorts.seekBackward.key",
+ "shorts.seekForward": "preferences.shorts.seekForward.key",
+ "profiles.preset:audio": "preferences.watch.audioMode.toggleAudioMode.key",
+};
+
+// The pre-kernel store lived under these three keys; once every slice has
+// had its chance to copy from them they are dropped.
+const LEGACY_KEYS = ["isExtensionEnabled", "ui", "preferences"];
+
+function read(store: Record, path: string): unknown {
+ return path.split(".").reduce((node, part) => (node as Record | undefined)?.[part], store);
+}
+
+const legacyKeybindings: Settings> = {
+ ...keybindings,
+ legacy(store) {
+ const bindings: Record = {};
+ for (const [action, path] of Object.entries(LEGACY_BINDINGS)) {
+ const value = read(store, path);
+ if (typeof value === "string") bindings[action] = value;
+ }
+ return Object.keys(bindings).length ? bindings : undefined;
+ },
+};
+
+export async function migrateLegacyStore(): Promise {
+ await migrateSettings([appSettings, playlistRuntimeSettings, shortsSettings, playbackSettings, profilesSettings, profileStore, segmentsSettings, legacyKeybindings]);
+ // Only when present: this runs at every background start, and each removal
+ // counts against sync's write quota.
+ if (Object.keys(await chrome.storage.sync.get(LEGACY_KEYS)).length) await chrome.storage.sync.remove(LEGACY_KEYS);
+}
diff --git a/web-ext/app/content/index.ts b/web-ext/app/content/index.ts
new file mode 100644
index 00000000..5d68dd81
--- /dev/null
+++ b/web-ext/app/content/index.ts
@@ -0,0 +1,43 @@
+import { bootFeatures, type Booted } from "@/kernel/features";
+import { onNavigate } from "@/youtube/route";
+import { appSettings } from "@/app/settings";
+import { playlistRuntime } from "@/features/playlist-runtime";
+import { shorts } from "@/features/shorts";
+import { playback } from "@/features/playback";
+import { profiles } from "@/features/profiles";
+import { segments } from "@/features/segments";
+import { scheduleNotices } from "@/features/schedules";
+
+const TAKEOVER_EVENT = "tppng:takeover";
+
+// An update cuts off the content script in tabs already open: Firefox unloads
+// it, Chrome leaves it running without the extension. The background starts
+// this copy in those tabs, and it asks an older one to step down first, or
+// every shortcut would fire twice.
+document.dispatchEvent(new Event(TAKEOVER_EVENT));
+let booted: Booted | undefined;
+let handedOver = false;
+document.addEventListener(
+ TAKEOVER_EVENT,
+ () => {
+ handedOver = true;
+ booted?.stop();
+ },
+ { once: true },
+);
+
+// Booted only once the master switch is known: the first navigation mounts
+// at once, and with the switch off it must not, say, apply the default rate.
+// A read that fails leaves the defaults, so Toppings still runs.
+void appSettings.get().catch(() => appSettings.defaults).then((app) => {
+ if (handedOver) return;
+ let enabled = app.enabled;
+ booted = bootFeatures([playlistRuntime, shorts, playback, profiles, segments, scheduleNotices], onNavigate, { enabled: () => enabled });
+
+ // The app slice also holds the theme, which features do not read.
+ appSettings.subscribe((next) => {
+ if (next.enabled === enabled) return;
+ enabled = next.enabled;
+ booted?.refresh();
+ });
+});
diff --git a/web-ext/src/manifest.json b/web-ext/app/manifest.json
similarity index 66%
rename from web-ext/src/manifest.json
rename to web-ext/app/manifest.json
index e27a0b6f..a633dec5 100644
--- a/web-ext/src/manifest.json
+++ b/web-ext/app/manifest.json
@@ -7,26 +7,29 @@
"48": "assets/icons/icon48.png",
"128": "assets/icons/icon128.png"
},
- "default_title": "__MSG_popupTitle__",
+ "default_title": "Toppings",
"default_popup": "popup/index.html"
},
"author": "Enrych",
"background": {
"service_worker": "/background.js"
},
- "commands": {},
"content_scripts": [
{
"all_frames": true,
- "js": ["/content.js"],
- "matches": ["https://www.youtube.com/*"],
+ "js": [
+ "/content.js"
+ ],
+ "matches": [
+ "https://www.youtube.com/*"
+ ],
"run_at": "document_end"
}
],
- "default_locale": "en",
- "description": "__MSG_description__",
- "homepage_url": "https://enrych.github.io/toppings",
- "host_permissions": ["*://*.youtube.com/*"],
+ "description": "Your YouTube, your way.",
+ "host_permissions": [
+ "*://*.youtube.com/*"
+ ],
"icons": {
"16": "assets/icons/icon16.png",
"32": "assets/icons/icon32.png",
@@ -39,13 +42,21 @@
"open_in_tab": true,
"page": "/options/index.html"
},
- "permissions": ["webNavigation", "storage"],
+ "permissions": [
+ "alarms",
+ "offscreen",
+ "scripting",
+ "storage"
+ ],
"short_name": "Toppings",
- "version": "0.0.0",
"web_accessible_resources": [
{
- "matches": ["https://*/*"],
- "resources": ["assets/*"]
+ "matches": [
+ "https://*/*"
+ ],
+ "resources": [
+ "assets/*"
+ ]
}
]
}
diff --git a/web-ext/app/offscreen/index.html b/web-ext/app/offscreen/index.html
new file mode 100644
index 00000000..914d08e9
--- /dev/null
+++ b/web-ext/app/offscreen/index.html
@@ -0,0 +1,2 @@
+
+
diff --git a/web-ext/app/offscreen/index.ts b/web-ext/app/offscreen/index.ts
new file mode 100644
index 00000000..d611fbd8
--- /dev/null
+++ b/web-ext/app/offscreen/index.ts
@@ -0,0 +1,3 @@
+import { answerLocationRequests } from "@/lib/location";
+
+answerLocationRequests();
diff --git a/web-ext/app/options/App.tsx b/web-ext/app/options/App.tsx
new file mode 100644
index 00000000..b25a0175
--- /dev/null
+++ b/web-ext/app/options/App.tsx
@@ -0,0 +1,49 @@
+import type { ComponentType } from "preact";
+import { useTheme } from "@/ui/useTheme";
+import { ToastProvider } from "@/ui/feedback/ToastProvider";
+import SectionNav from "@/ui/layout/SectionNav";
+import { OPTIONS_PAGES } from "./data";
+import { useRoute } from "./router";
+import Sidebar from "./layout/Sidebar";
+import General from "./routes/General";
+import Watch from "./routes/Watch";
+import Shorts from "./routes/Shorts";
+import Playlist from "./routes/Playlist";
+import Profiles from "./routes/Profiles";
+import Shortcuts from "./routes/Shortcuts";
+
+const PAGE_BY_SEGMENT: Record = {
+ "": General,
+ watch: Watch,
+ shorts: Shorts,
+ playlist: Playlist,
+ profiles: Profiles,
+ shortcuts: Shortcuts,
+};
+
+export default function App() {
+ useTheme();
+ const segment = useRoute();
+ const page = OPTIONS_PAGES.find((p) => p.segment === segment) ?? OPTIONS_PAGES[0];
+ const Page = PAGE_BY_SEGMENT[page.segment];
+
+ return (
+
+
+
+
+
+
+ {page.sections && (
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/web-ext/app/options/components/CapabilityStatusRow.tsx b/web-ext/app/options/components/CapabilityStatusRow.tsx
new file mode 100644
index 00000000..57236f3e
--- /dev/null
+++ b/web-ext/app/options/components/CapabilityStatusRow.tsx
@@ -0,0 +1,99 @@
+import { useState } from "preact/hooks";
+import Button from "@/ui/primitives/Button";
+import { EXTENSION_VERSION } from "@/lib/version";
+import type { CapabilityStatus } from "@/kernel/dom/capabilities";
+import { addFeatureReport } from "@/kernel/dom/featureReports";
+
+interface CapabilityStatusRowProps {
+ label: string;
+ primitiveId: string; // e.g. "watch.sidebar"
+ status: CapabilityStatus;
+}
+
+const STATUS_CONFIG: Record<
+ CapabilityStatus,
+ { dot: string; text: string; hint: string }
+> = {
+ supported: {
+ dot: "tw-bg-green-500",
+ text: "Working",
+ hint: "This feature is active on your YouTube.",
+ },
+ unsupported: {
+ dot: "tw-bg-amber-500",
+ text: "Unavailable on your YouTube",
+ hint: "YouTube's UI on your account doesn't match any known layout for this feature.",
+ },
+ untested: {
+ dot: "tw-bg-fg-subtle",
+ text: "Not yet checked",
+ hint: "Visit a YouTube watch page to check this feature.",
+ },
+};
+
+export default function CapabilityStatusRow({
+ label,
+ primitiveId,
+ status,
+}: CapabilityStatusRowProps) {
+ const config = STATUS_CONFIG[status];
+ const [reported, setReported] = useState(false);
+
+ const handleReport = async () => {
+ // Stored before the issue is opened so the next update can tell the user
+ // their report started working, whether or not they submit the issue.
+ await addFeatureReport(primitiveId);
+ setReported(true);
+
+ const title = encodeURIComponent(
+ `[Report] Feature unavailable: ${label} (${primitiveId})`,
+ );
+ const body = encodeURIComponent(
+ [
+ `**Feature:** ${label}`,
+ `**Primitive ID:** \`${primitiveId}\``,
+ `**Extension version:** ${EXTENSION_VERSION}`,
+ `**Browser:** ${navigator.userAgent}`,
+ "",
+ "### What happened",
+ "This feature shows as unavailable in my Toppings options page.",
+ "",
+ "### Steps to reproduce",
+ "1. Install Toppings",
+ "2. Open YouTube watch page",
+ "3. Open Options → Watch → Feature Availability",
+ `4. "${label}" shows as unavailable`,
+ ].join("\n"),
+ );
+ window.open(
+ `https://github.com/enrych/toppings/issues/new?title=${title}&body=${body}&labels=Type%3A+Bug+%F0%9F%90%9B%2CStatus%3A+Needs+Investigation`,
+ "_blank",
+ );
+ };
+
+ return (
+
+
+
+ {label}
+
+
+
+ {config.text}
+
+
+
+ {status === "unsupported" && (
+
void handleReport()} disabled={reported} title={reported ? "Reported. The next update will tell you when it works again." : "Report this feature as unavailable"}>
+ {reported ? "Reported" : "Report"}
+
+ )}
+
+ );
+}
diff --git a/web-ext/app/options/components/Schedules.test.ts b/web-ext/app/options/components/Schedules.test.ts
new file mode 100644
index 00000000..01fae03c
--- /dev/null
+++ b/web-ext/app/options/components/Schedules.test.ts
@@ -0,0 +1,172 @@
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import { h, render } from "preact";
+import { act } from "preact/test-utils";
+import { ToastProvider } from "@/ui/feedback/ToastProvider";
+import { getAllProfiles } from "@/features/profiles/store";
+import { schedulesStore, type Place, type Schedule } from "@/features/schedules/settings";
+import { scheduleState } from "@/features/schedules/state";
+import Schedules from "./Schedules";
+
+const root = document.createElement("div");
+document.body.append(root);
+const originalGeolocation = navigator.geolocation;
+
+const settle = () => act(() => new Promise((r) => setTimeout(r, 20)));
+
+const mount = async () => {
+ const profiles = await getAllProfiles();
+ act(() => {
+ render(h(ToastProvider, null, h(Schedules, { profiles })), root);
+ });
+ await settle();
+};
+
+const buttonNamed = (text: string) => [...root.querySelectorAll("button")].find((b) => b.textContent === text)!;
+const inputLabelled = (label: string) => {
+ const el = [...root.querySelectorAll("label")].find((l) => l.textContent === label)!;
+ return root.querySelector(`[id="${el.htmlFor}"]`)!;
+};
+const type = (input: HTMLInputElement, value: string) =>
+ act(() => {
+ input.value = value;
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+
+const work: Place = { id: "work", name: "Work", latitude: 51.5, longitude: -0.12, radiusMeters: 200 };
+const weekdays: Schedule = { id: "a", profileId: "preset:audio", enabled: true, rule: { kind: "time", days: [1, 2, 3, 4, 5], start: "09:00", end: "18:00" } };
+const atWork: Schedule = { id: "b", profileId: "preset:focus", enabled: true, rule: { kind: "place", placeId: "work" } };
+
+beforeEach(async () => {
+ await chrome.storage.local.clear();
+});
+afterEach(() => {
+ act(() => render(null, root));
+ document.body.replaceChildren(root);
+ Object.defineProperty(navigator, "geolocation", { value: originalGeolocation, configurable: true });
+});
+
+describe("Schedules", () => {
+ test("adding a time schedule stores its profile, days and window", async () => {
+ await mount();
+ act(() => buttonNamed("New schedule").click());
+ act(() => buttonNamed("Sat").click());
+ act(() => buttonNamed("Mon").click());
+ type(inputLabelled("Starts"), "22:00");
+ type(inputLabelled("Ends"), "06:30");
+ expect(root.textContent).toContain("Runs overnight");
+ act(() => buttonNamed("Add schedule").click());
+ await settle();
+
+ const { schedules } = await schedulesStore.get();
+ expect(schedules).toHaveLength(1);
+ expect(schedules[0]).toEqual({
+ id: expect.any(String),
+ profileId: "preset:audio",
+ enabled: true,
+ rule: { kind: "time", days: [2, 3, 4, 5, 6], start: "22:00", end: "06:30" },
+ });
+ expect(root.textContent).toContain("Audio · Tue–Sat 22:00–06:30 overnight");
+ });
+
+ test("an empty week or a zero-length window is refused", async () => {
+ await mount();
+ act(() => buttonNamed("New schedule").click());
+ type(inputLabelled("Ends"), "09:00");
+ expect(root.textContent).toContain("End at a different time from the start.");
+ expect(root.textContent).not.toContain("Runs overnight");
+ type(inputLabelled("Ends"), "17:00");
+ for (const day of ["Mon", "Tue", "Wed", "Thu", "Fri"]) act(() => buttonNamed(day).click());
+ expect(root.textContent).toContain("Pick at least one day.");
+ expect(buttonNamed("Add schedule").disabled).toBe(true);
+ });
+
+ test("toggling and reordering persist, and list order is what is stored", async () => {
+ await schedulesStore.set({ schedules: [weekdays, atWork], places: [work] });
+ await mount();
+ expect(root.textContent).toContain("Focus · while at Work");
+
+ const toggle = root.querySelector("[role='switch'][aria-label='Focus · while at Work, enabled']")!;
+ act(() => toggle.click());
+ await settle();
+ expect((await schedulesStore.get()).schedules.map((s) => s.enabled)).toEqual([true, false]);
+
+ const moveUp = root.querySelectorAll("button[aria-label='Move up']");
+ expect(moveUp[0].disabled).toBe(true);
+ act(() => moveUp[1].click());
+ await settle();
+ expect((await schedulesStore.get()).schedules.map((s) => s.id)).toEqual(["b", "a"]);
+ });
+
+ test("adding a place stores where the browser says you are, with the radius", async () => {
+ Object.defineProperty(navigator, "geolocation", {
+ configurable: true,
+ value: {
+ getCurrentPosition: (ok: PositionCallback) => ok({ coords: { latitude: 48.8566, longitude: 2.3522, accuracy: 35 } } as GeolocationPosition),
+ },
+ });
+ await mount();
+ act(() => buttonNamed("New place").click());
+ act(() => buttonNamed("Save place").click());
+ expect(root.textContent).toContain("Give the place a name.");
+
+ type(inputLabelled("Name"), " Office ");
+ type(inputLabelled("Radius"), "20");
+ expect(root.textContent).toContain("Enter a whole number from 50 to 2000.");
+ type(inputLabelled("Radius"), "300");
+ act(() => buttonNamed("Use my current location").click());
+ await settle();
+ expect(root.textContent).toContain("accurate to about 35 m");
+ act(() => buttonNamed("Save place").click());
+ await settle();
+
+ expect((await schedulesStore.get()).places).toEqual([{ id: expect.any(String), name: "Office", latitude: 48.8566, longitude: 2.3522, radiusMeters: 300 }]);
+ });
+
+ test("a fix looser than the radius warns, and a refusal says why", async () => {
+ let answer: "loose" | "denied" = "loose";
+ Object.defineProperty(navigator, "geolocation", {
+ configurable: true,
+ value: {
+ getCurrentPosition: (ok: PositionCallback, fail: PositionErrorCallback) =>
+ answer === "loose"
+ ? ok({ coords: { latitude: 1, longitude: 2, accuracy: 900 } } as GeolocationPosition)
+ : fail({ message: "User denied Geolocation" } as GeolocationPositionError),
+ },
+ });
+ await mount();
+ act(() => buttonNamed("New place").click());
+ act(() => buttonNamed("Use my current location").click());
+ await settle();
+ expect(root.textContent).toContain("looser than the radius");
+ answer = "denied";
+ act(() => buttonNamed("Locate again").click());
+ await settle();
+ expect(root.textContent).toContain("Location unavailable: User denied Geolocation.");
+ });
+
+ test("a place a schedule uses cannot be deleted until the schedule is", async () => {
+ await schedulesStore.set({ schedules: [atWork], places: [work] });
+ await mount();
+ expect(root.textContent).toContain("used by 1 schedule");
+ const deletePlace = () => [...root.querySelectorAll("button")].filter((b) => b.textContent === "Delete").at(-1)!.click();
+ act(deletePlace);
+ await settle();
+ expect((await schedulesStore.get()).places).toHaveLength(1);
+ expect(document.body.textContent).toContain('"Work" is in use');
+
+ act(() => buttonNamed("Delete").click());
+ await settle();
+ act(deletePlace);
+ await settle();
+ expect(await schedulesStore.get()).toEqual({ schedules: [], places: [] });
+ });
+
+ test("shows the runner's status and a location problem", async () => {
+ await scheduleState.set({ inCharge: "a", status: "Audio on by schedule until 18:00", locationError: "Location too imprecise to tell places apart" });
+ await schedulesStore.set({ schedules: [weekdays], places: [] });
+ await mount();
+ expect(root.querySelector("[role='status']")?.textContent).toBe("Audio on by schedule until 18:00");
+ expect(root.textContent).toContain("Location unavailable: Location too imprecise to tell places apart.");
+ expect(root.textContent).toContain("In charge");
+ });
+});
diff --git a/web-ext/app/options/components/Schedules.tsx b/web-ext/app/options/components/Schedules.tsx
new file mode 100644
index 00000000..a957a8b8
--- /dev/null
+++ b/web-ext/app/options/components/Schedules.tsx
@@ -0,0 +1,412 @@
+import { useId, useState } from "preact/hooks";
+import Button from "@/ui/primitives/Button";
+import IconButton from "@/ui/primitives/IconButton";
+import Icon from "@/ui/primitives/Icon";
+import Section from "@/ui/layout/Section";
+import Card from "@/ui/layout/Card";
+import { Toggle } from "@/ui/form/Switch";
+import Select from "@/ui/form/Select";
+import Field from "@/ui/form/Field";
+import { useToast } from "@/ui/feedback/ToastProvider";
+import { useSettings } from "@/kernel/useSettings";
+import { readPosition, type Position } from "@/lib/location";
+import { schedulesStore, type Place, type Schedule, type Weekday } from "@/features/schedules/settings";
+import { scheduleState } from "@/features/schedules/state";
+import type { Profile } from "@/features/profiles/profiles";
+
+type RuleKind = Schedule["rule"]["kind"];
+
+interface ScheduleEditorProps {
+ profiles: Profile[];
+ places: Place[];
+ onAdd: (schedule: Schedule) => void;
+ onCancel: () => void;
+}
+
+interface ScheduleRowProps {
+ summary: string;
+ enabled: boolean;
+ inCharge: boolean;
+ onToggle: () => void;
+ onMoveUp?: () => void;
+ onMoveDown?: () => void;
+ onDelete: () => void;
+}
+
+const WEEK: { day: Weekday; label: string }[] = [
+ { day: 1, label: "Mon" },
+ { day: 2, label: "Tue" },
+ { day: 3, label: "Wed" },
+ { day: 4, label: "Thu" },
+ { day: 5, label: "Fri" },
+ { day: 6, label: "Sat" },
+ { day: 0, label: "Sun" },
+];
+const CLOCK_TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
+const RADIUS_MIN = 50;
+const RADIUS_MAX = 2000;
+const INPUT_CLASS =
+ "tw-h-9 tw-px-3 tw-rounded-lg tw-bg-surface-hover tw-text-fg tw-text-sm tw-border tw-border-transparent focus:tw-outline-none focus:tw-border-accent tw-transition-colors";
+const BADGE_CLASS = "tw-text-[10px] tw-font-medium tw-uppercase tw-tracking-wider tw-text-accent tw-bg-info-bg tw-rounded tw-px-1.5 tw-py-0.5";
+
+function describeDays(days: readonly Weekday[]): string {
+ if (days.length === 7) return "Every day";
+ const runs: string[][] = [[]];
+ for (const { day, label } of WEEK) {
+ if (days.includes(day)) runs[runs.length - 1].push(label);
+ else if (runs[runs.length - 1].length) runs.push([]);
+ }
+ return runs
+ .filter((run) => run.length)
+ .map((run) => (run.length > 2 ? `${run[0]}–${run[run.length - 1]}` : run.join(", ")))
+ .join(", ");
+}
+
+function describeSchedule(schedule: Schedule, profiles: readonly Profile[], places: readonly Place[]): string {
+ const profile = profiles.find((p) => p.id === schedule.profileId)?.name ?? "Deleted profile";
+ const { rule } = schedule;
+ if (rule.kind === "place") return `${profile} · while at ${places.find((place) => place.id === rule.placeId)?.name ?? "a deleted place"}`;
+ return `${profile} · ${describeDays(rule.days)} ${rule.start}–${rule.end}${rule.end <= rule.start ? " overnight" : ""}`;
+}
+
+const usedBy = (schedules: readonly Schedule[], place: Place) =>
+ schedules.filter((schedule) => schedule.rule.kind === "place" && schedule.rule.placeId === place.id).length;
+
+function DayChips({ days, onChange }: { days: Weekday[]; onChange: (days: Weekday[]) => void }) {
+ return (
+
+ {WEEK.map(({ day, label }) => {
+ const on = days.includes(day);
+ return (
+ onChange(on ? days.filter((d) => d !== day) : [...days, day].sort((a, b) => a - b))}
+ class={`tw-h-8 tw-w-11 tw-rounded-lg tw-text-[13px] tw-font-medium tw-transition-colors focus-visible:tw-outline-none focus-visible:tw-ring-2 focus-visible:tw-ring-accent ${on ? "tw-bg-fg tw-text-bg" : "tw-bg-surface-hover tw-text-fg hover:tw-bg-border-default"}`}
+ >
+ {label}
+
+ );
+ })}
+
+ );
+}
+
+function ScheduleEditor({ profiles, places, onAdd, onCancel }: ScheduleEditorProps) {
+ const startId = useId();
+ const endId = useId();
+ const [profileId, setProfileId] = useState("");
+ const [kind, setKind] = useState("time");
+ const [days, setDays] = useState([1, 2, 3, 4, 5]);
+ const [start, setStart] = useState("09:00");
+ const [end, setEnd] = useState("17:00");
+ const [placeId, setPlaceId] = useState("");
+
+ const chosenProfile = profileId || profiles[0]?.id || "";
+ const chosenPlace = placeId || places[0]?.id || "";
+ const daysError = days.length === 0 ? "Pick at least one day." : undefined;
+ const startError = CLOCK_TIME.test(start) ? undefined : "Enter a start time.";
+ const endError = !CLOCK_TIME.test(end) ? "Enter an end time." : end === start ? "End at a different time from the start." : undefined;
+ const invalid = !chosenProfile || (kind === "time" ? Boolean(daysError || startError || endError) : !chosenPlace);
+
+ const add = () => {
+ if (invalid) return;
+ onAdd({
+ id: crypto.randomUUID(),
+ profileId: chosenProfile,
+ rule: kind === "time" ? { kind, days, start, end } : { kind, placeId: chosenPlace },
+ enabled: true,
+ });
+ };
+
+ return (
+
+ );
+}
+
+function ScheduleRow({ summary, enabled, inCharge, onToggle, onMoveUp, onMoveDown, onDelete }: ScheduleRowProps) {
+ return (
+
+
+ {summary}
+ {inCharge && In charge }
+
+
+
+
+
+
+
+
+ Delete
+
+
+
+
+
+ );
+}
+
+function PlaceEditor({ onAdd, onCancel }: { onAdd: (place: Place) => void; onCancel: () => void }) {
+ const nameId = useId();
+ const radiusId = useId();
+ const [name, setName] = useState("");
+ const [radius, setRadius] = useState("200");
+ const [position, setPosition] = useState(null);
+ const [locating, setLocating] = useState(false);
+ const [locationError, setLocationError] = useState(null);
+ const [attempted, setAttempted] = useState(false);
+
+ const radiusMeters = Number(radius);
+ const radiusError =
+ radius.trim() !== "" && Number.isInteger(radiusMeters) && radiusMeters >= RADIUS_MIN && radiusMeters <= RADIUS_MAX
+ ? undefined
+ : `Enter a whole number from ${RADIUS_MIN} to ${RADIUS_MAX}.`;
+ const nameError = attempted && !name.trim() ? "Give the place a name." : undefined;
+ const positionError = locationError
+ ? `Location unavailable: ${locationError}. Allow location for Toppings when your browser asks.`
+ : attempted && !position
+ ? "Use your current location while you are there."
+ : undefined;
+
+ const locate = async () => {
+ setLocating(true);
+ setLocationError(null);
+ try {
+ setPosition(await readPosition());
+ } catch (error) {
+ setLocationError(error instanceof Error ? error.message : String(error));
+ }
+ setLocating(false);
+ };
+
+ const save = () => {
+ setAttempted(true);
+ if (!name.trim() || radiusError || !position) return;
+ onAdd({ id: crypto.randomUUID(), name: name.trim(), latitude: position.latitude, longitude: position.longitude, radiusMeters });
+ };
+
+ return (
+
+
+ setName(e.currentTarget.value)}
+ class={`tw-w-44 placeholder:tw-text-fg-subtle ${INPUT_CLASS}`}
+ />
+
+
+
+ setRadius(e.currentTarget.value)}
+ class={`tw-w-24 ${INPUT_CLASS}`}
+ />
+ m
+
+
+
+ void locate()} disabled={locating}>
+ {locating ? "Locating…" : position ? "Locate again" : "Use my current location"}
+
+
+ {position && !radiusError && position.accuracyMeters > radiusMeters && (
+
+ That fix is looser than the radius, so the place may match from farther away or not at all. Locate again, or widen the radius.
+
+ )}
+
+ Cancel
+ Save place
+
+
+ );
+}
+
+export default function Schedules({ profiles }: { profiles: Profile[] }) {
+ const toast = useToast();
+ const store = useSettings(schedulesStore);
+ const { value: state } = useSettings(scheduleState);
+ const [addingSchedule, setAddingSchedule] = useState(false);
+ const [addingPlace, setAddingPlace] = useState(false);
+ const { schedules, places } = store.value;
+
+ const setSchedules = (next: Schedule[]) => store.update({ schedules: next });
+ const move = (index: number, by: number) => {
+ const next = [...schedules];
+ [next[index], next[index + by]] = [next[index + by], next[index]];
+ setSchedules(next);
+ };
+
+ const deletePlace = (place: Place) => {
+ const users = usedBy(schedules, place);
+ if (users) {
+ toast.error(`"${place.name}" is in use`, `Delete the ${users === 1 ? "schedule that uses" : `${users} schedules that use`} it first.`);
+ return;
+ }
+ store.update({ places: places.filter((p) => p.id !== place.id) });
+ toast.success(`"${place.name}" deleted`);
+ };
+
+ return (
+ <>
+ setAddingSchedule(true)}>New schedule}
+ >
+ {(state.status || state.locationError) && (
+
+ {state.status && (
+
+
+ {state.status}
+
+ )}
+ {state.locationError && (
+
+ Location unavailable: {state.locationError}. Allow location for Toppings when your browser asks, from "Use my current location" under Places.
+
+ )}
+
+ )}
+
+ {addingSchedule && (
+ {
+ setSchedules([...schedules, schedule]);
+ setAddingSchedule(false);
+ toast.success("Schedule added");
+ }}
+ onCancel={() => setAddingSchedule(false)}
+ />
+ )}
+ {schedules.length === 0 && !addingSchedule ? (
+ No schedules yet. Add one with the button above.
+ ) : (
+ schedules.map((schedule, index) => (
+ setSchedules(schedules.map((s) => (s.id === schedule.id ? { ...s, enabled: !s.enabled } : s)))}
+ onMoveUp={index > 0 ? () => move(index, -1) : undefined}
+ onMoveDown={index < schedules.length - 1 ? () => move(index, 1) : undefined}
+ onDelete={() => setSchedules(schedules.filter((s) => s.id !== schedule.id))}
+ />
+ ))
+ )}
+
+
+ While a schedule is in charge, the toolbar badge shows its profile's initial, and it counts down the minutes before a change.
+
+
+
+ setAddingPlace(true)}>New place}
+ >
+
+ {addingPlace && (
+ {
+ store.update({ places: [...places, place] });
+ setAddingPlace(false);
+ toast.success(`"${place.name}" added`);
+ }}
+ onCancel={() => setAddingPlace(false)}
+ />
+ )}
+ {places.length === 0 && !addingPlace ? (
+ No places yet. Add one with the button above.
+ ) : (
+ places.map((place) => {
+ const users = usedBy(schedules, place);
+ const details = [
+ `Within ${place.radiusMeters} m`,
+ users ? `used by ${users} ${users === 1 ? "schedule" : "schedules"}` : "",
+ ].filter(Boolean);
+ return (
+
+
+
+ {place.name}
+ {state.atPlaces.includes(place.id) && Here }
+
+
{details.join(" · ")}
+
+
deletePlace(place)}>Delete
+
+ );
+ })
+ )}
+
+
+ >
+ );
+}
diff --git a/web-ext/app/options/data.ts b/web-ext/app/options/data.ts
new file mode 100644
index 00000000..15166642
--- /dev/null
+++ b/web-ext/app/options/data.ts
@@ -0,0 +1,42 @@
+import type { IconName } from "@/ui/primitives/Icon";
+import type { SectionNavItem } from "@/ui/layout/SectionNav";
+
+export const OPTIONS_HTML = "options/index.html";
+export const OPTIONS_ICON_SRC = "/assets/icons/icon48.png";
+
+export interface OptionsPage {
+ // The hash segment; "" is the landing page.
+ segment: string;
+ label: string;
+ icon: IconName;
+ sections?: readonly SectionNavItem[];
+}
+
+export const OPTIONS_PAGES: readonly OptionsPage[] = [
+ { segment: "", label: "General", icon: "general" },
+ {
+ segment: "watch",
+ label: "Watch",
+ icon: "watch",
+ sections: [
+ { id: "playback-rate", label: "Playback rate" },
+ { id: "seek", label: "Seek" },
+ { id: "segments", label: "Segments" },
+ { id: "feature-availability", label: "Availability" },
+ ],
+ },
+ { segment: "shorts", label: "Shorts", icon: "shorts" },
+ { segment: "playlist", label: "Playlist", icon: "playlist" },
+ { segment: "profiles", label: "Profiles", icon: "profiles" },
+ {
+ segment: "shortcuts",
+ label: "Shortcuts",
+ icon: "keyboard",
+ sections: [
+ { id: "playback", label: "Playback" },
+ { id: "segments", label: "Segments" },
+ { id: "shorts", label: "Shorts" },
+ { id: "profiles", label: "Profiles" },
+ ],
+ },
+];
diff --git a/web-ext/app/options/index.css b/web-ext/app/options/index.css
new file mode 100644
index 00000000..4f8dfa79
--- /dev/null
+++ b/web-ext/app/options/index.css
@@ -0,0 +1,22 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@import "../../ui/base.css";
+
+.tppng-search-highlight {
+ background: var(--color-accent);
+ color: var(--color-accent-fg);
+ border-radius: 2px;
+ padding: 0 1px;
+}
+
+@keyframes tppng-section-flash {
+ 0% { background: transparent; }
+ 20% { background: color-mix(in srgb, var(--color-accent) 20%, transparent); border-radius: 8px; }
+ 100% { background: transparent; }
+}
+
+.tppng-section-flash {
+ animation: tppng-section-flash 900ms ease-out forwards;
+}
diff --git a/web-ext/src/options/index.html b/web-ext/app/options/index.html
similarity index 89%
rename from web-ext/src/options/index.html
rename to web-ext/app/options/index.html
index 2f7c577e..859e3b35 100644
--- a/web-ext/src/options/index.html
+++ b/web-ext/app/options/index.html
@@ -5,6 +5,7 @@
Toppings: Preferences
+
You need to enable JavaScript to run this app.
diff --git a/web-ext/app/options/index.tsx b/web-ext/app/options/index.tsx
new file mode 100644
index 00000000..a4eaf2a2
--- /dev/null
+++ b/web-ext/app/options/index.tsx
@@ -0,0 +1,4 @@
+import { render } from "preact";
+import App from "./App";
+
+render( , document.getElementById("root")!);
diff --git a/web-ext/app/options/layout/AppLayout.tsx b/web-ext/app/options/layout/AppLayout.tsx
new file mode 100644
index 00000000..9e571962
--- /dev/null
+++ b/web-ext/app/options/layout/AppLayout.tsx
@@ -0,0 +1,25 @@
+import type { ComponentChildren } from "preact";
+import Sidebar from "./Sidebar";
+
+interface AppLayoutProps {
+ children: ComponentChildren;
+ rightRail?: ComponentChildren;
+}
+
+export default function AppLayout({ children, rightRail }: AppLayoutProps) {
+ return (
+
+
+
+
+
{children}
+ {rightRail && (
+
+ {rightRail}
+
+ )}
+
+
+
+ );
+}
diff --git a/web-ext/app/options/layout/Sidebar.tsx b/web-ext/app/options/layout/Sidebar.tsx
new file mode 100644
index 00000000..00474967
--- /dev/null
+++ b/web-ext/app/options/layout/Sidebar.tsx
@@ -0,0 +1,69 @@
+import Icon from "@/ui/primitives/Icon";
+import IconButton from "@/ui/primitives/IconButton";
+import Tooltip from "@/ui/primitives/Tooltip";
+import { useChromeStorageLocal } from "@/lib/useChromeStorageLocal";
+import { BRAND_METADATA } from "@/lib/brand";
+import { CHROME_STORAGE_LOCAL_KEY } from "@/lib/storageKeys";
+import { EXTENSION_VERSION } from "@/lib/version";
+import { useTheme } from "@/ui/useTheme";
+import { OPTIONS_ICON_SRC, OPTIONS_PAGES } from "../data";
+import { hrefFor, useRoute } from "../router";
+import SidebarThemeToggle from "./SidebarThemeToggle";
+import OptionsSearch from "../search/OptionsSearch";
+
+export default function Sidebar() {
+ const [collapsed, setCollapsed] = useChromeStorageLocal(CHROME_STORAGE_LOCAL_KEY.OPTIONS_SIDEBAR_COLLAPSED, false);
+ const { theme, setTheme } = useTheme();
+ const active = useRoute();
+ const collapseLabel = collapsed ? "Expand sidebar" : "Collapse sidebar";
+
+ return (
+
+ );
+}
diff --git a/web-ext/app/options/layout/SidebarThemeToggle.tsx b/web-ext/app/options/layout/SidebarThemeToggle.tsx
new file mode 100644
index 00000000..a0eb6972
--- /dev/null
+++ b/web-ext/app/options/layout/SidebarThemeToggle.tsx
@@ -0,0 +1,26 @@
+import type { ThemePreference } from "@/ui/useTheme";
+
+const OPTIONS: { id: ThemePreference; label: string }[] = [
+ { id: "system", label: "System" },
+ { id: "dark", label: "Dark" },
+ { id: "light", label: "Light" },
+];
+
+export default function SidebarThemeToggle({ value, onChange }: { value: ThemePreference; onChange: (next: ThemePreference) => void }) {
+ return (
+
+ {OPTIONS.map((opt) => (
+ onChange(opt.id)}
+ class={`tw-flex-1 tw-h-7 tw-text-xs tw-font-medium tw-rounded-full tw-transition-colors ${value === opt.id ? "tw-bg-fg tw-text-bg" : "tw-text-fg-muted hover:tw-text-fg"}`}
+ >
+ {opt.label}
+
+ ))}
+
+ );
+}
diff --git a/web-ext/app/options/router.ts b/web-ext/app/options/router.ts
new file mode 100644
index 00000000..7c09060d
--- /dev/null
+++ b/web-ext/app/options/router.ts
@@ -0,0 +1,21 @@
+import { useEffect, useState } from "preact/hooks";
+
+// Pages are hash segments ("#watch"), so a page can be linked and the
+// browser's back button works, with nothing beyond the platform's own history.
+const current = () => location.hash.replace(/^#\/?/, "");
+
+export function useRoute(): string {
+ const [segment, setSegment] = useState(current);
+ useEffect(() => {
+ const onChange = () => setSegment(current());
+ window.addEventListener("hashchange", onChange);
+ return () => window.removeEventListener("hashchange", onChange);
+ }, []);
+ return segment;
+}
+
+export function navigate(segment: string): void {
+ location.hash = segment;
+}
+
+export const hrefFor = (segment: string) => `#${segment}`;
diff --git a/web-ext/app/options/routes/General.test.ts b/web-ext/app/options/routes/General.test.ts
new file mode 100644
index 00000000..6f3d841f
--- /dev/null
+++ b/web-ext/app/options/routes/General.test.ts
@@ -0,0 +1,33 @@
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import { h, render } from "preact";
+import { act } from "preact/test-utils";
+import { ToastProvider } from "@/ui/feedback/ToastProvider";
+import { profilesSettings } from "@/features/profiles/settings";
+import General from "./General";
+
+const root = document.createElement("div");
+document.body.append(root);
+
+const settle = () => act(() => new Promise((r) => setTimeout(r, 20)));
+
+beforeEach(async () => {
+ await chrome.storage.sync.clear();
+});
+afterEach(() => {
+ act(() => render(null, root));
+});
+
+describe("General page", () => {
+ test("the audio button switch writes the profiles surfaces slice", async () => {
+ act(() => {
+ render(h(ToastProvider, null, h(General, null)), root);
+ });
+ await settle();
+ const label = [...root.querySelectorAll("label")].find((el) => el.textContent === "Audio button in the player")!;
+ const toggle = root.querySelector(`[id="${label.htmlFor}"]`)!;
+ expect(toggle.getAttribute("aria-checked")).toBe("true");
+ act(() => toggle.click());
+ await settle();
+ expect((await profilesSettings.get()).audioButton).toBe(false);
+ });
+});
diff --git a/web-ext/app/options/routes/General.tsx b/web-ext/app/options/routes/General.tsx
new file mode 100644
index 00000000..8e2f60e9
--- /dev/null
+++ b/web-ext/app/options/routes/General.tsx
@@ -0,0 +1,99 @@
+import { useState } from "preact/hooks";
+import PageHeader from "@/ui/layout/PageHeader";
+import Section from "@/ui/layout/Section";
+import Card from "@/ui/layout/Card";
+import Switch from "@/ui/form/Switch";
+import Select from "@/ui/form/Select";
+import Button from "@/ui/primitives/Button";
+import { useToast } from "@/ui/feedback/ToastProvider";
+import { useSettings } from "@/kernel/useSettings";
+import { useCapabilityCache } from "@/kernel/dom/useCapabilities";
+import { appSettings, type ThemePreference } from "@/app/settings";
+import { profilesSettings } from "@/features/profiles/settings";
+
+export default function General() {
+ const app = useSettings(appSettings);
+ const surfaces = useSettings(profilesSettings);
+ const toast = useToast();
+ const { rescan } = useCapabilityCache();
+ const [rescanning, setRescanning] = useState(false);
+
+ return (
+ <>
+
+
+
+
+
+ {
+ app.update({ enabled });
+ toast.success(enabled ? "Toppings enabled" : "Toppings disabled");
+ }}
+ />
+
+
+
+
+
+
+ label="Appearance"
+ description="For the popup and this page. YouTube itself is untouched."
+ value={app.value.theme}
+ options={[
+ { value: "system", label: "Use device theme" },
+ { value: "dark", label: "Dark theme" },
+ { value: "light", label: "Light theme" },
+ ]}
+ onChange={(theme) => app.update({ theme })}
+ />
+
+
+
+
+
+ surfaces.update({ gearMenu })}
+ />
+ surfaces.update({ audioButton })}
+ />
+
+
+
+
+
+
+
+ Re-scan features
+ Features are re-checked the next time you open YouTube.
+
+
{
+ setRescanning(true);
+ await rescan();
+ setRescanning(false);
+ toast.success("Cache cleared", "Open YouTube to re-check features.");
+ }}
+ >
+ {rescanning ? "Clearing…" : "Re-scan"}
+
+
+
+
+
+ >
+ );
+}
diff --git a/web-ext/app/options/routes/Playlist.tsx b/web-ext/app/options/routes/Playlist.tsx
new file mode 100644
index 00000000..6700a174
--- /dev/null
+++ b/web-ext/app/options/routes/Playlist.tsx
@@ -0,0 +1,32 @@
+import { useSettings } from "@/kernel/useSettings";
+import { playlistRuntimeSettings } from "@/features/playlist-runtime/settings";
+import PageHeader from "@/ui/layout/PageHeader";
+import Section from "@/ui/layout/Section";
+import Card from "@/ui/layout/Card";
+import Switch from "@/ui/form/Switch";
+
+export default function Playlist() {
+ const { value, update } = useSettings(playlistRuntimeSettings);
+
+ return (
+ <>
+
+
+
+
+
+ update({ enabled })}
+ />
+
+
+
+ >
+ );
+}
diff --git a/web-ext/app/options/routes/Profiles.test.ts b/web-ext/app/options/routes/Profiles.test.ts
new file mode 100644
index 00000000..96b2dcbf
--- /dev/null
+++ b/web-ext/app/options/routes/Profiles.test.ts
@@ -0,0 +1,97 @@
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import { h, render } from "preact";
+import { act } from "preact/test-utils";
+import { ToastProvider } from "@/ui/feedback/ToastProvider";
+import { createProfile, getCustomProfiles, setActiveProfileId } from "@/features/profiles/store";
+import { CHROME_STORAGE_LOCAL_KEY } from "@/lib/storageKeys";
+import Profiles from "./Profiles";
+
+const root = document.createElement("div");
+document.body.append(root);
+
+const settle = () => act(() => new Promise((r) => setTimeout(r, 20)));
+
+const mount = async () => {
+ act(() => {
+ render(h(ToastProvider, null, h(Profiles, null)), root);
+ });
+ await settle();
+};
+
+const buttonNamed = (text: string) => [...root.querySelectorAll("button")].find((b) => b.textContent === text)!;
+
+const cardOf = (name: string) =>
+ [...root.querySelectorAll(".tw-py-3")].find((el) => el.textContent?.startsWith(name))!;
+
+beforeEach(async () => {
+ await chrome.storage.local.clear();
+});
+afterEach(() => {
+ act(() => render(null, root));
+ document.body.replaceChildren(root);
+});
+
+describe("Profiles page", () => {
+ test("importing a file creates the profile and says so", async () => {
+ await mount();
+ const input = root.querySelector("input[type='file']")!;
+ const file = new File([JSON.stringify({ name: "Study", primitives: { "watch.sidebar": { visible: false } } })], "study.json", { type: "application/json" });
+ Object.defineProperty(input, "files", { value: [file], configurable: true });
+ act(() => {
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+ });
+ await settle();
+ expect((await getCustomProfiles()).map((p) => p.name)).toEqual(["Study"]);
+ expect(document.body.textContent).toContain('"Study" imported');
+ });
+
+ test("the Import button opens the file picker", async () => {
+ await mount();
+ const input = root.querySelector("input[type='file']")!;
+ let opened = 0;
+ input.addEventListener("click", () => opened++);
+ const button = [...root.querySelectorAll("button")].find((b) => b.textContent === "Import")!;
+ act(() => button.click());
+ expect(opened).toBe(1);
+ });
+
+ test("follows a profile switched from elsewhere", async () => {
+ await mount();
+ expect(cardOf("Focus").textContent).toContain("Activate");
+ await setActiveProfileId("preset:focus");
+ await settle();
+ expect(cardOf("Focus").textContent).toContain("Deactivate");
+ });
+
+ test("the Audio preset shows its screen by the option's name", async () => {
+ await mount();
+ expect(cardOf("Audio").textContent).toContain("Video screen: Visualizer");
+ });
+
+ test("a custom screen picks, previews and clears the device's image", async () => {
+ await createProfile({ name: "Night", primitives: { "watch.visuals": { value: "custom" } } });
+ await mount();
+ act(() => buttonNamed("Edit").click());
+ const input = root.querySelector("input[type='file'][accept='image/*']")!;
+ let opened = 0;
+ input.addEventListener("click", () => opened++);
+ act(() => buttonNamed("Choose image").click());
+ expect(opened).toBe(1);
+
+ const file = new File([new Uint8Array([137, 80, 78, 71])], "cover.png", { type: "image/png" });
+ Object.defineProperty(input, "files", { value: [file], configurable: true });
+ act(() => {
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+ });
+ await act(() => new Promise((r) => setTimeout(r, 100)));
+ const key = CHROME_STORAGE_LOCAL_KEY.VISUALS_IMAGE;
+ const stored = (await chrome.storage.local.get(key))[key];
+ expect(stored).toStartWith("data:image/png;base64,");
+ expect(root.querySelector("img")?.getAttribute("src")).toBe(stored);
+
+ act(() => buttonNamed("Clear").click());
+ await settle();
+ expect(await chrome.storage.local.get(key)).toEqual({});
+ expect(root.querySelector("img")).toBeNull();
+ });
+});
diff --git a/web-ext/app/options/routes/Profiles.tsx b/web-ext/app/options/routes/Profiles.tsx
new file mode 100644
index 00000000..5a470262
--- /dev/null
+++ b/web-ext/app/options/routes/Profiles.tsx
@@ -0,0 +1,610 @@
+import { useEffect, useId, useRef, useState } from "preact/hooks";
+import Button from "@/ui/primitives/Button";
+import PageHeader from "@/ui/layout/PageHeader";
+import Section from "@/ui/layout/Section";
+import Card from "@/ui/layout/Card";
+import Switch from "@/ui/form/Switch";
+import Select, { type SelectOption } from "@/ui/form/Select";
+import Field from "@/ui/form/Field";
+import { useToast } from "@/ui/feedback/ToastProvider";
+import Schedules from "@/app/options/components/Schedules";
+import { useCapabilityCache } from "@/kernel/dom/useCapabilities";
+import { useChromeStorageLocal } from "@/lib/useChromeStorageLocal";
+import { CHROME_STORAGE_LOCAL_KEY } from "@/lib/storageKeys";
+import {
+ getAllProfiles,
+ getActiveProfile,
+ setActiveProfileId,
+ createProfile,
+ updateProfile,
+ deleteProfile,
+ subscribeProfiles,
+} from "@/features/profiles/store";
+import {
+ exportProfile,
+ importProfileFromFile,
+} from "@/features/profiles/importExport";
+import {
+ type Profile,
+ type ProfilePrimitiveConfig,
+ type ThumbnailMode,
+} from "@/features/profiles/profiles";
+import type { PlayerLayout, VisualsMode } from "@/features/profiles/profiles";
+
+const VISUALS_OPTIONS: SelectOption[] = [
+ { value: "video", label: "Real video", description: "The video as YouTube shows it" },
+ { value: "black", label: "Black screen", description: "A plain black screen" },
+ { value: "visualizer", label: "Visualizer", description: "A waveform that reacts to the audio" },
+ { value: "custom", label: "Custom image", description: "An image of your choice" },
+];
+
+function blankPrimitives(): ProfilePrimitiveConfig {
+ return {
+ "watch.sidebar": { visible: true },
+ "watch.comments": { visible: true },
+ "watch.endCards": { visible: true },
+ "watch.layout": { value: "default" },
+ };
+}
+
+function VisualsImagePicker() {
+ const toast = useToast();
+ const [image] = useChromeStorageLocal(CHROME_STORAGE_LOCAL_KEY.VISUALS_IMAGE, null);
+ const fileInput = useRef(null);
+
+ const store = (file: File) => {
+ const reader = new FileReader();
+ reader.onload = async () => {
+ if (typeof reader.result !== "string") return;
+ // Local storage is capped without unlimitedStorage, and a photo's data
+ // URL can pass the cap, so a failed write is expected, not exceptional.
+ try {
+ await chrome.storage.local.set({ [CHROME_STORAGE_LOCAL_KEY.VISUALS_IMAGE]: reader.result });
+ } catch {
+ toast.error("Image not saved", "It is too large; try a smaller one.");
+ }
+ };
+ reader.onerror = () => toast.error("Could not read that image");
+ reader.readAsDataURL(file);
+ };
+
+ return (
+
+
+ {image &&
}
+ {image &&
void chrome.storage.local.remove(CHROME_STORAGE_LOCAL_KEY.VISUALS_IMAGE)}>Clear }
+
fileInput.current?.click()}>{image ? "Change image" : "Choose image"}
+
{
+ const input = e.currentTarget;
+ const file = input.files?.[0];
+ input.value = "";
+ if (file) store(file);
+ }}
+ />
+
+
+ );
+}
+
+interface ProfileEditorProps {
+ initial: Partial;
+ onSave: (name: string, primitives: ProfilePrimitiveConfig) => Promise;
+ onCancel: () => void;
+ getStatus: (id: string) => "supported" | "unsupported" | "untested";
+}
+
+function ProfileEditor({
+ initial,
+ onSave,
+ onCancel,
+ getStatus,
+}: ProfileEditorProps) {
+ const nameId = useId();
+ const [name, setName] = useState(initial.name ?? "");
+ const [primitives, setPrimitives] = useState(
+ initial.primitives ?? blankPrimitives(),
+ );
+ const [isSaving, setIsSaving] = useState(false);
+
+ const layout = primitives["watch.layout"]?.value ?? "default";
+ const visuals = primitives["watch.visuals"]?.value ?? "video";
+
+ const set = (
+ key: K,
+ value: ProfilePrimitiveConfig[K],
+ ) => setPrimitives((prev) => ({ ...prev, [key]: value }));
+
+ const handleSave = async () => {
+ if (!name.trim()) return;
+ setIsSaving(true);
+ await onSave(name.trim(), primitives);
+ setIsSaving(false);
+ };
+
+ const isUnsupported = (id: string) => getStatus(id) === "unsupported";
+
+ return (
+
+
+
+ Profile name
+
+ setName(e.currentTarget.value)}
+ placeholder="e.g. Study session"
+ maxLength={40}
+ class="tw-w-full tw-h-9 tw-rounded-lg tw-bg-surface-hover tw-px-3 tw-text-sm tw-text-fg placeholder:tw-text-fg-subtle tw-border tw-border-transparent focus:tw-outline-none focus:tw-border-accent"
+ />
+
+
+
+
+ Watch page
+
+
+
+
+
+ label="Player Layout"
+ description={
+ isUnsupported("watch.layout")
+ ? "Not available on your YouTube"
+ : "How the video player is arranged on the page."
+ }
+ value={layout}
+ options={[
+ { value: "default", label: "Default", description: "YouTube's standard layout" },
+ { value: "no-video", label: "No Video", description: "Hide the player area entirely" },
+ ]}
+ onChange={(v) => set("watch.layout", { value: v })}
+ />
+
+
+
+
+ label="Video screen"
+ description={
+ isUnsupported("watch.visuals")
+ ? "Not available on your YouTube"
+ : "Covers the video but keeps the player's controls, so you can listen without the picture showing."
+ }
+ value={visuals}
+ options={VISUALS_OPTIONS}
+ onChange={(v) => set("watch.visuals", { value: v })}
+ />
+ {visuals === "custom" && }
+
+
+
+ set("watch.sidebar", { visible: v })}
+ />
+
+
+
+ set("watch.comments", { visible: v })}
+ />
+
+
+
+ set("watch.endCards", { visible: v })}
+ />
+
+
+
+
+
+
+ Home page
+
+
+
+
+ label="Feed Thumbnails"
+ description="Show, hide, or blur thumbnail images in the home feed."
+ value={primitives["home.thumbnails"]?.mode ?? "show"}
+ options={[
+ { value: "show", label: "Show", description: "Normal thumbnails" },
+ { value: "blur", label: "Blur", description: "Blurred — layout preserved" },
+ { value: "hide", label: "Hide", description: "Invisible thumbnails" },
+ ]}
+ onChange={(v) => set("home.thumbnails", { mode: v })}
+ />
+
+
+ set("home.feed", { visible: v })}
+ />
+
+
+ set("home.shorts", { visible: v })}
+ />
+
+
+
+
+
+
+ Search
+
+
+
+
+ label="Result Thumbnails"
+ description="Show, hide, or blur thumbnails in search results."
+ value={primitives["search.thumbnails"]?.mode ?? "show"}
+ options={[
+ { value: "show", label: "Show", description: "Normal thumbnails" },
+ { value: "blur", label: "Blur", description: "Blurred — layout preserved" },
+ { value: "hide", label: "Hide", description: "Plain text list" },
+ ]}
+ onChange={(v) => set("search.thumbnails", { mode: v })}
+ />
+
+
+ set("search.metadata", { visible: v })}
+ />
+
+
+ set("search.shorts", { visible: v })}
+ />
+
+
+
+
+
+
+ Shorts
+
+
+
+ set("shorts.shelf", { visible: v })}
+ />
+
+
+
+
+
+ Cancel
+
+ {isSaving ? "Saving…" : "Save profile"}
+
+
+
+ );
+}
+
+interface PresetCardProps {
+ profile: Profile;
+ isActive: boolean;
+ onActivate: () => void;
+}
+
+function PresetCard({ profile, isActive, onActivate }: PresetCardProps) {
+ const primitiveLabels: Record = {
+ "watch.layout": "Layout",
+ "watch.visuals": "Video screen",
+ "watch.sidebar": "Sidebar",
+ "watch.comments": "Comments",
+ "watch.endCards": "End Cards",
+ "home.thumbnails": "Home Thumbnails",
+ "home.feed": "Home Feed",
+ "home.shorts": "Home Shorts",
+ "search.thumbnails": "Search Thumbnails",
+ "search.metadata": "Search Metadata",
+ "search.shorts": "Search Shorts",
+ "shorts.shelf": "Shorts Shelf",
+ };
+
+ const summaryParts = Object.entries(profile.primitives)
+ .map(([key, val]) => {
+ const label = primitiveLabels[key] ?? key;
+ if ("visible" in val) return `${label}: ${val.visible ? "on" : "off"}`;
+ if ("mode" in val) return `${label}: ${val.mode}`;
+ if (key === "watch.visuals" && "value" in val) {
+ return `${label}: ${VISUALS_OPTIONS.find((o) => o.value === val.value)?.label ?? val.value}`;
+ }
+ if ("value" in val) return `${label}: ${val.value}`;
+ return label;
+ })
+ .join(" · ");
+
+ return (
+
+
+
+
+ {profile.name}
+
+
+ Built-in
+
+ {isActive && (
+
+ Active
+
+ )}
+
+
{summaryParts}
+
+
+ {isActive ? "Deactivate" : "Activate"}
+
+
+ );
+}
+
+interface CustomProfileCardProps {
+ profile: Profile;
+ isActive: boolean;
+ onActivate: () => void;
+ onEdit: () => void;
+ onDelete: () => void;
+}
+
+function CustomProfileCard({
+ profile,
+ isActive,
+ onActivate,
+ onEdit,
+ onDelete,
+}: CustomProfileCardProps) {
+ return (
+
+
+
+
+ {profile.name}
+
+ {isActive && (
+
+ Active
+
+ )}
+
+
+ {Object.keys(profile.primitives).length} primitive
+ {Object.keys(profile.primitives).length !== 1 ? "s" : ""} configured
+
+
+
+ exportProfile(profile)}>Export
+ Edit
+ Delete
+
+ {isActive ? "Deactivate" : "Activate"}
+
+
+
+ );
+}
+
+type EditorMode =
+ | { type: "closed" }
+ | { type: "create" }
+ | { type: "edit"; profile: Profile };
+
+export default function Profiles() {
+ const toast = useToast();
+ const { getStatus } = useCapabilityCache();
+
+ const [profiles, setProfiles] = useState([]);
+ const [activeId, setActiveId] = useState(null);
+ const [editor, setEditor] = useState({ type: "closed" });
+ const importInput = useRef(null);
+
+ const load = async () => {
+ const [all, active] = await Promise.all([
+ getAllProfiles(),
+ getActiveProfile(),
+ ]);
+ setProfiles(all);
+ setActiveId(active?.id ?? null);
+ };
+
+ useEffect(() => {
+ void load();
+ return subscribeProfiles(() => void load());
+ }, []);
+
+ const handleActivate = async (id: string) => {
+ const next = activeId === id ? null : id;
+ await setActiveProfileId(next);
+ setActiveId(next);
+ const name = profiles.find((p) => p.id === id)?.name ?? "Profile";
+ toast.success(next ? `${name} activated` : "Profile deactivated");
+ };
+
+ const handleCreate = async (
+ name: string,
+ primitives: ProfilePrimitiveConfig,
+ ) => {
+ await createProfile({ name, primitives });
+ await load();
+ setEditor({ type: "closed" });
+ toast.success(`"${name}" created`);
+ };
+
+ const handleUpdate = async (
+ id: string,
+ name: string,
+ primitives: ProfilePrimitiveConfig,
+ ) => {
+ await updateProfile(id, { name, primitives });
+ await load();
+ setEditor({ type: "closed" });
+ toast.success(`"${name}" saved`);
+ };
+
+ const handleDelete = async (profile: Profile) => {
+ await deleteProfile(profile.id);
+ await load();
+ toast.success(`"${profile.name}" deleted`);
+ };
+
+ const presets = profiles.filter((p) => p.isPreset);
+ const custom = profiles.filter((p) => !p.isPreset);
+
+ return (
+ <>
+
+
+
+
+
+ {presets.map((p) => (
+ handleActivate(p.id)}
+ />
+ ))}
+
+
+
+
+ ) : null
+ }
+ >
+
+ {editor.type === "create" && (
+ setEditor({ type: "closed" })}
+ getStatus={getStatus}
+ />
+ )}
+
+ {custom.length === 0 && editor.type !== "create" ? (
+
+ No custom profiles yet. Create one with the button above.
+
+ ) : (
+ custom.map((p) =>
+ editor.type === "edit" && editor.profile.id === p.id ? (
+
+ handleUpdate(p.id, name, primitives)
+ }
+ onCancel={() => setEditor({ type: "closed" })}
+ getStatus={getStatus}
+ />
+ ) : (
+ handleActivate(p.id)}
+ onEdit={() => setEditor({ type: "edit", profile: p })}
+ onDelete={() => handleDelete(p)}
+ />
+ ),
+ )
+ )}
+
+
+
+
+
+ >
+ );
+}
diff --git a/web-ext/app/options/routes/Shortcuts.test.ts b/web-ext/app/options/routes/Shortcuts.test.ts
new file mode 100644
index 00000000..869dc3a4
--- /dev/null
+++ b/web-ext/app/options/routes/Shortcuts.test.ts
@@ -0,0 +1,59 @@
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import { h, render } from "preact";
+import { act } from "preact/test-utils";
+import { keybindings } from "@/kernel/keys";
+import { createProfile } from "@/features/profiles/store";
+import Shortcuts from "./Shortcuts";
+
+const root = document.createElement("div");
+document.body.append(root);
+
+const settle = () => act(() => new Promise((r) => setTimeout(r, 20)));
+
+const mount = async () => {
+ act(() => {
+ render(h(Shortcuts, null), root);
+ });
+ await settle();
+};
+
+const profileRows = () => [...root.querySelectorAll("section#profiles label")].map((el) => el.textContent);
+
+const inputOf = (label: string) => {
+ const labelEl = [...root.querySelectorAll("section#profiles label")].find((el) => el.textContent === label)!;
+ return root.querySelector(`[id="${labelEl.htmlFor}"]`)!;
+};
+
+beforeEach(async () => {
+ await chrome.storage.local.clear();
+ await chrome.storage.sync.clear();
+});
+afterEach(() => {
+ act(() => render(null, root));
+});
+
+describe("Shortcuts page", () => {
+ test("lists cycle and a shortcut per profile in the Profiles group", async () => {
+ await mount();
+ expect(profileRows()).toEqual(["Cycle profiles", "Audio", "Focus"]);
+ expect(inputOf("Audio").value).toBe("B");
+ });
+
+ test("a profile's shortcut is stored under its profile id", async () => {
+ await mount();
+ const input = inputOf("Focus");
+ act(() => input.focus());
+ act(() => {
+ input.dispatchEvent(new KeyboardEvent("keydown", { key: "f", bubbles: true, cancelable: true }));
+ });
+ await settle();
+ expect(await keybindings.get()).toEqual({ "profiles.preset:focus": "F" });
+ });
+
+ test("a profile created elsewhere gets a row", async () => {
+ await mount();
+ await createProfile({ name: "Study", primitives: {} });
+ await settle();
+ expect(profileRows()).toContain("Study");
+ });
+});
diff --git a/web-ext/app/options/routes/Shortcuts.tsx b/web-ext/app/options/routes/Shortcuts.tsx
new file mode 100644
index 00000000..3468f637
--- /dev/null
+++ b/web-ext/app/options/routes/Shortcuts.tsx
@@ -0,0 +1,67 @@
+import { useEffect, useState } from "preact/hooks";
+import PageHeader from "@/ui/layout/PageHeader";
+import Section from "@/ui/layout/Section";
+import Card from "@/ui/layout/Card";
+import Keybinding from "@/ui/form/Keybinding";
+import { useSettings } from "@/kernel/useSettings";
+import { actionId, bindingOf, keybindings, type KeyGroup } from "@/kernel/keys";
+import { playbackKeys } from "@/features/playback/keys";
+import { profilesKeys, profileToggleKeys } from "@/features/profiles/keys";
+import { getAllProfiles, subscribeProfiles } from "@/features/profiles/store";
+import { segmentsKeys } from "@/features/segments/keys";
+import { shortsKeys } from "@/features/shorts/keys";
+
+const GROUPS: { group: KeyGroup; description: string }[] = [
+ { group: playbackKeys, description: "On the watch page." },
+ { group: segmentsKeys, description: "On the watch page, while segments are loaded where it applies." },
+ { group: shortsKeys, description: "On the Shorts player." },
+];
+
+export default function Shortcuts() {
+ const { value: stored, update } = useSettings(keybindings);
+ const [profilesGroup, setProfilesGroup] = useState(profilesKeys);
+
+ useEffect(() => {
+ const load = async () => {
+ const toggles = profileToggleKeys(await getAllProfiles());
+ setProfilesGroup({ ...profilesKeys, keys: { ...profilesKeys.keys, ...toggles.keys } });
+ };
+ void load();
+ return subscribeProfiles(() => void load());
+ }, []);
+
+ const groups = [
+ ...GROUPS,
+ { group: profilesGroup, description: "Anywhere on YouTube. A profile's shortcut switches to it; press again to go back." },
+ ];
+
+ return (
+ <>
+
+
+
+ {groups.map(({ group, description }) => (
+
+
+ {Object.entries(group.keys).map(([key, definition]) => {
+ const id = actionId(group, key);
+ return (
+ update({ [id]: binding })}
+ // The slice merges on write, so an override is dropped by
+ // writing undefined, which bindingOf reads as the default.
+ onReset={stored[id] == null ? undefined : () => update({ [id]: undefined })}
+ />
+ );
+ })}
+
+
+ ))}
+
+ >
+ );
+}
diff --git a/web-ext/app/options/routes/Shorts.tsx b/web-ext/app/options/routes/Shorts.tsx
new file mode 100644
index 00000000..2370d9f3
--- /dev/null
+++ b/web-ext/app/options/routes/Shorts.tsx
@@ -0,0 +1,73 @@
+import { useSettings } from "@/kernel/useSettings";
+import { shortsSettings } from "@/features/shorts/settings";
+import PageHeader from "@/ui/layout/PageHeader";
+import Section from "@/ui/layout/Section";
+import Card from "@/ui/layout/Card";
+import Switch from "@/ui/form/Switch";
+import Input from "@/ui/form/Input";
+import { isSeconds } from "@/app/options/validators";
+
+const isRate = (v: string) => Number.isFinite(Number(v)) && Number(v) >= 0.0625 && Number(v) <= 16;
+
+export default function Shorts() {
+ const { value, update } = useSettings(shortsSettings);
+
+ return (
+ <>
+
+
+
+
+
+ update({ enabled })}
+ />
+ update({ autoScroll })}
+ />
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/web-ext/app/options/routes/Watch.tsx b/web-ext/app/options/routes/Watch.tsx
new file mode 100644
index 00000000..7e2151bd
--- /dev/null
+++ b/web-ext/app/options/routes/Watch.tsx
@@ -0,0 +1,222 @@
+import { useEffect, useState } from "preact/hooks";
+import Button from "@/ui/primitives/Button";
+import PageHeader from "@/ui/layout/PageHeader";
+import Section from "@/ui/layout/Section";
+import Card from "@/ui/layout/Card";
+import Input from "@/ui/form/Input";
+import Select from "@/ui/form/Select";
+import Switch from "@/ui/form/Switch";
+import CapabilityStatusRow from "@/app/options/components/CapabilityStatusRow";
+import { isNudgeMax, isNudgeStep, isSeconds } from "@/app/options/validators";
+import { useSettings } from "@/kernel/useSettings";
+import { useCapabilityCache } from "@/kernel/dom/useCapabilities";
+import { MAX_RATE, MIN_RATE, parseRates, playbackSettings } from "@/features/playback/settings";
+import { segmentsSettings } from "@/features/segments/settings";
+import type { AutoLoad } from "@/features/segments/store";
+import {
+ getUndismissedRecovered,
+ dismissRecovered,
+ type RecoveredFeature,
+} from "@/kernel/dom/featureReports";
+
+// Adding a watch-page primitive means appending an entry here; nothing
+// discovers them automatically.
+const WATCH_PRIMITIVES: { id: string; label: string }[] = [
+ { id: "watch.player", label: "Video Player" },
+ { id: "watch.rightControls", label: "Player Controls Bar" },
+ { id: "watch.progressBar", label: "Progress Bar" },
+ { id: "watch.moviePlayer", label: "Movie Player Container" },
+ { id: "watch.settingsButton",label: "Settings Button" },
+ { id: "watch.speedRow", label: "Playback Speed Menu Row" },
+ { id: "watch.ratePanel", label: "Playback Speed Panel" },
+ { id: "watch.sidebar", label: "Recommendations Sidebar" },
+ { id: "watch.comments", label: "Comments Section" },
+ { id: "watch.endCards", label: "End Screen Cards" },
+];
+
+const isRate = (v: string) => Number.isFinite(Number(v)) && Number(v) >= MIN_RATE && Number(v) <= MAX_RATE;
+const isStep = (v: string) => Number.isFinite(Number(v)) && Number(v) > 0 && Number(v) <= MAX_RATE;
+const isRateList = (v: string) => parseRates(v) !== undefined;
+
+function useRecoveredFeatures() {
+ const [recovered, setRecovered] = useState([]);
+
+ useEffect(() => {
+ getUndismissedRecovered().then(setRecovered);
+ }, []);
+
+ const dismiss = async (primitiveId: string) => {
+ await dismissRecovered(primitiveId);
+ setRecovered((prev) => prev.filter((r) => r.primitiveId !== primitiveId));
+ };
+
+ return { recovered, dismiss };
+}
+
+export default function Watch() {
+ const { value, update } = useSettings(playbackSettings);
+ const segments = useSettings(segmentsSettings);
+ const { getStatus, isLoading } = useCapabilityCache();
+ const { recovered, dismiss } = useRecoveredFeatures();
+
+ return (
+ <>
+
+
+ {recovered.map((r) => (
+
+
+ A feature you reported works again
+
+ {r.primitiveId} now resolves on your YouTube.
+
+
+
void dismiss(r.primitiveId)}>Dismiss
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ {isLoading ? (
+
+ Checking features…
+
+ ) : (
+ WATCH_PRIMITIVES.map(({ id, label }) => (
+
+ ))
+ )}
+
+
+
+ >
+ );
+}
diff --git a/web-ext/app/options/search/OptionsSearch.test.ts b/web-ext/app/options/search/OptionsSearch.test.ts
new file mode 100644
index 00000000..6b8d6682
--- /dev/null
+++ b/web-ext/app/options/search/OptionsSearch.test.ts
@@ -0,0 +1,56 @@
+import { afterEach, describe, expect, test } from "bun:test";
+import { h, render } from "preact";
+import { act } from "preact/test-utils";
+import Shortcuts from "@/app/options/routes/Shortcuts";
+import Profiles from "@/app/options/routes/Profiles";
+import General from "@/app/options/routes/General";
+import { ToastProvider } from "@/ui/feedback/ToastProvider";
+import OptionsSearch from "./OptionsSearch";
+import { SEARCH_INDEX } from "./searchIndex";
+
+const root = document.createElement("div");
+document.body.append(root);
+
+const wait = (ms: number) => act(() => new Promise((r) => setTimeout(r, ms)));
+
+afterEach(() => {
+ act(() => render(null, root));
+});
+
+describe("options search", () => {
+ test("a Shorts shortcut result flashes the Shorts row, not the Playback one with the same label", async () => {
+ act(() => {
+ render(h("div", null, h(OptionsSearch, null), h(Shortcuts, null)), root);
+ });
+ const search = root.querySelector("input[aria-label='Search settings']")!;
+ act(() => {
+ search.value = "Seek forward";
+ search.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+ const result = [...root.querySelectorAll("button")].find((b) => b.textContent === "Seek forwardShortcuts › Shorts")!;
+ act(() => result.click());
+ await wait(200);
+ const flashed = root.querySelector(".tppng-section-flash");
+ expect(flashed?.closest("section")?.id).toBe("shorts");
+ });
+
+ test("Profiles section entries match the rendered headings", async () => {
+ act(() => {
+ render(h(ToastProvider, null, h(Profiles, null)), root);
+ });
+ await wait(20);
+ const headings = [...root.querySelectorAll("h2")].map((el) => el.textContent);
+ const sections = SEARCH_INDEX.filter((e) => e.page === "Profiles" && e.label === e.section).map((e) => e.label);
+ expect(sections.length).toBeGreaterThan(0);
+ for (const label of sections) expect(headings).toContain(label);
+ });
+
+ test("every General page entry matches a rendered label", async () => {
+ act(() => {
+ render(h(ToastProvider, null, h(General, null)), root);
+ });
+ await wait(20);
+ const labels = [...root.querySelectorAll("label, h2")].map((el) => el.textContent?.trim());
+ for (const entry of SEARCH_INDEX.filter((e) => e.page === "General")) expect(labels).toContain(entry.label);
+ });
+});
diff --git a/web-ext/app/options/search/OptionsSearch.tsx b/web-ext/app/options/search/OptionsSearch.tsx
new file mode 100644
index 00000000..5e989971
--- /dev/null
+++ b/web-ext/app/options/search/OptionsSearch.tsx
@@ -0,0 +1,112 @@
+import { useEffect, useRef, useState } from "preact/hooks";
+import Icon from "@/ui/primitives/Icon";
+import { navigate } from "../router";
+import { fuzzySearch, type SearchEntry, type SearchResult } from "./searchIndex";
+
+function Highlighted({ text, indices }: { text: string; indices: number[] }) {
+ const marks = new Set(indices);
+ return <>{[...text].map((ch, i) => (marks.has(i) ? {ch} : ch))}>;
+}
+
+// Results are located by their rendered label: a field's first, then
+// a section heading, so a row wins over the section that contains it. The
+// search stays inside the entry's section, because the Shortcuts page repeats
+// labels across groups.
+function findByLabel(label: string, scope: ParentNode): HTMLElement | null {
+ for (const el of scope.querySelectorAll("label, h2")) {
+ if (el.textContent?.trim() !== label) continue;
+ return el.closest("section, [class*='tw-py-3']") ?? el;
+ }
+ return null;
+}
+
+function flash(el: HTMLElement): void {
+ el.scrollIntoView({ behavior: "smooth", block: "nearest" });
+ el.classList.remove("tppng-section-flash");
+ void el.offsetWidth;
+ el.classList.add("tppng-section-flash");
+ setTimeout(() => el.classList.remove("tppng-section-flash"), 1000);
+}
+
+export default function OptionsSearch() {
+ const [query, setQuery] = useState("");
+ const [open, setOpen] = useState(false);
+ const [active, setActive] = useState(0);
+ const root = useRef(null);
+ const input = useRef(null);
+ const results = fuzzySearch(query);
+
+ const go = ({ entry }: SearchResult) => {
+ setQuery("");
+ setOpen(false);
+ navigate(entry.segment);
+ // The row exists only once the page has rendered.
+ setTimeout(() => {
+ const section = entry.sectionId ? document.getElementById(entry.sectionId) : null;
+ const target = findByLabel(entry.label, section ?? document) ?? section;
+ if (target) flash(target);
+ }, 150);
+ };
+
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape") return (setOpen(false), input.current?.blur());
+ if (!open || !results.length) return;
+ if (e.key === "ArrowDown") (e.preventDefault(), setActive((i) => (i + 1) % results.length));
+ else if (e.key === "ArrowUp") (e.preventDefault(), setActive((i) => (i - 1 + results.length) % results.length));
+ else if (e.key === "Enter") (e.preventDefault(), go(results[active]));
+ };
+
+ useEffect(() => {
+ const onDown = (e: MouseEvent) => {
+ if (!root.current?.contains(e.target as Node)) setOpen(false);
+ };
+ document.addEventListener("mousedown", onDown);
+ return () => document.removeEventListener("mousedown", onDown);
+ }, []);
+ useEffect(() => setActive(0), [query]);
+
+ const crumb = (entry: SearchEntry) => `${entry.page}${entry.section ? ` › ${entry.section}` : ""}`;
+
+ return (
+
+
+
+ (setQuery(e.currentTarget.value), setOpen(true))}
+ onFocus={() => setOpen(true)}
+ onKeyDown={onKeyDown}
+ class="tw-w-full tw-h-9 tw-bg-surface-hover tw-rounded-full tw-pl-9 tw-pr-8 tw-text-sm tw-text-fg placeholder:tw-text-fg-subtle focus:tw-outline-none focus:tw-ring-2 focus:tw-ring-accent"
+ />
+ {query && (
+ (setQuery(""), input.current?.focus())} class="tw-absolute tw-right-2.5 tw-text-fg-subtle hover:tw-text-fg">
+
+
+ )}
+
+ {open && query && (
+
+ {results.length === 0 &&
No results for “{query}”
}
+ {results.map((result, i) => (
+
setActive(i)}
+ onClick={() => go(result)}
+ class={`tw-w-full tw-text-left tw-px-3 tw-py-2 tw-transition-colors ${i === active ? "tw-bg-surface-hover" : ""}`}
+ >
+
+
+
+ {crumb(result.entry)}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/web-ext/app/options/search/searchIndex.ts b/web-ext/app/options/search/searchIndex.ts
new file mode 100644
index 00000000..9b01ee46
--- /dev/null
+++ b/web-ext/app/options/search/searchIndex.ts
@@ -0,0 +1,102 @@
+import { playbackKeys } from "@/features/playback/keys";
+import { profilesKeys } from "@/features/profiles/keys";
+import { segmentsKeys } from "@/features/segments/keys";
+import { shortsKeys } from "@/features/shorts/keys";
+
+export interface SearchEntry {
+ // Must match the rendered label character for character: a result is
+ // located in the DOM by this text, so a copy edit in the page must land
+ // here too.
+ label: string;
+ description?: string;
+ segment: string;
+ sectionId?: string;
+ page: string;
+ section?: string;
+}
+
+export interface SearchResult {
+ entry: SearchEntry;
+ score: number;
+ matchedIndices: number[];
+}
+
+const shortcutEntries = (group: { id: string; title: string; keys: Record }): SearchEntry[] =>
+ Object.values(group.keys).map((key) => ({ label: key.label, description: key.description, segment: "shortcuts", sectionId: group.id, page: "Shortcuts", section: group.title }));
+
+export const SEARCH_INDEX: SearchEntry[] = [
+ { label: "Enable Toppings", description: "When off, nothing runs on YouTube.", segment: "", page: "General", section: "Extension" },
+ { label: "Appearance", description: "System, dark, or light, for the popup and this page.", segment: "", page: "General", section: "Appearance" },
+ { label: "Player gear menu", description: "A Profile row in the player\'s settings menu.", segment: "", page: "General", section: "Profile surfaces" },
+ { label: "Audio button in the player", description: "A headphones button that turns the Audio profile on and off.", segment: "", page: "General", section: "Profile surfaces" },
+ { label: "Re-scan features", description: "Clears the cached feature compatibility check.", segment: "", page: "General", section: "Diagnostics" },
+ { label: "Playback controls", description: "Default rate, rate shortcuts and seek shortcuts on the watch page.", segment: "watch", sectionId: "playback-rate", page: "Watch", section: "Playback rate" },
+ { label: "Default playback rate", description: "Rate applied to every video on load.", segment: "watch", sectionId: "playback-rate", page: "Watch", section: "Playback rate" },
+ { label: "Custom playback rates", description: "Rates offered in the player's speed panel.", segment: "watch", sectionId: "playback-rate", page: "Watch", section: "Playback rate" },
+ { label: "Toggle playback rate", description: "Rate to switch to with the toggle shortcut.", segment: "watch", sectionId: "playback-rate", page: "Watch", section: "Playback rate" },
+ { label: "Playback rate step", description: "Amount the rate changes on the increase and decrease shortcuts.", segment: "watch", sectionId: "playback-rate", page: "Watch", section: "Playback rate" },
+ { label: "Seek backward", description: "Seconds to seek backward.", segment: "watch", sectionId: "seek", page: "Watch", section: "Seek" },
+ { label: "Seek forward", description: "Seconds to seek forward.", segment: "watch", sectionId: "seek", page: "Watch", section: "Seek" },
+ { label: "Segments", description: "The segments button, panel and markers, and their shortcuts.", segment: "watch", sectionId: "segments", page: "Watch", section: "Segments" },
+ { label: "Auto-load on page open", description: "Restore segments when you open a video.", segment: "watch", sectionId: "segments", page: "Watch", section: "Segments" },
+ { label: "Nudge step", description: "Seconds a segment edge moves on the first nudge.", segment: "watch", sectionId: "segments", page: "Watch", section: "Segments" },
+ { label: "Nudge multiplier", description: "How much the step grows on repeated nudges.", segment: "watch", sectionId: "segments", page: "Watch", section: "Segments" },
+ { label: "Nudge maximum", description: "Largest step a repeated nudge can reach.", segment: "watch", sectionId: "segments", page: "Watch", section: "Segments" },
+ { label: "Feature availability", description: "Which features work on your YouTube.", segment: "watch", sectionId: "feature-availability", page: "Watch", section: "Feature availability" },
+ { label: "Shorts features", description: "Auto-scroll, seek and rate controls on Shorts.", segment: "shorts", page: "Shorts", section: "Behavior" },
+ { label: "Auto-scroll", description: "Continue to the next Short when one ends.", segment: "shorts", page: "Shorts", section: "Behavior" },
+ { label: "Toggle playback rate", description: "Rate to switch to on Shorts.", segment: "shorts", page: "Shorts", section: "Playback rate" },
+ { label: "Seek backward", description: "Seconds to seek backward on Shorts.", segment: "shorts", page: "Shorts", section: "Seek" },
+ { label: "Seek forward", description: "Seconds to seek forward on Shorts.", segment: "shorts", page: "Shorts", section: "Seek" },
+ { label: "Runtime statistics", description: "Total and average runtime at the top of playlist pages.", segment: "playlist", page: "Playlist", section: "Runtime statistics" },
+ { label: "Built-in presets", description: "Audio and Focus, ready to activate.", segment: "profiles", page: "Profiles", section: "Built-in presets" },
+ { label: "Audio", description: "Built-in preset: hides the video, keeps the sound.", segment: "profiles", page: "Profiles", section: "Built-in presets" },
+ { label: "Focus", description: "Built-in preset: hides sidebar, comments, and end cards.", segment: "profiles", page: "Profiles", section: "Built-in presets" },
+ { label: "My profiles", description: "Your own mix of page tweaks.", segment: "profiles", page: "Profiles", section: "My profiles" },
+ { label: "Video screen", description: "Real video, black screen, visualizer, or your own image, in a profile.", segment: "profiles", page: "Profiles", section: "My profiles" },
+ { label: "Schedules", description: "Switch a profile on by time of day or by place.", segment: "profiles", page: "Profiles", section: "Schedules" },
+ { label: "Places", description: "Saved places for schedules, kept on this device.", segment: "profiles", page: "Profiles", section: "Places" },
+ ...shortcutEntries(playbackKeys),
+ ...shortcutEntries(segmentsKeys),
+ ...shortcutEntries(shortsKeys),
+ ...shortcutEntries(profilesKeys),
+];
+
+// Subsequence match: query characters must appear in order but need not be
+// adjacent. Bonuses pull contiguous and word-start matches above scattered ones.
+function fuzzyMatch(text: string, query: string): { score: number; indices: number[] } {
+ const t = text.toLowerCase();
+ const q = query.toLowerCase();
+ const indices: number[] = [];
+ let score = 0;
+ let run = 0;
+ let qi = 0;
+ for (let ti = 0; ti < t.length && qi < q.length; ti++) {
+ if (t[ti] !== q[qi]) continue;
+ run = indices.length && indices[indices.length - 1] === ti - 1 ? run + 1 : 0;
+ score += 1 + run * 2 + (ti === 0 || t[ti - 1] === " " ? 3 : 0);
+ indices.push(ti);
+ qi++;
+ }
+ if (qi < q.length) return { score: 0, indices: [] };
+ if (t.includes(q)) score += 15;
+ if (t.startsWith(q)) score += 25;
+ if (t === q) score += 50;
+ return { score, indices };
+}
+
+export function fuzzySearch(query: string): SearchResult[] {
+ const q = query.trim();
+ if (!q) return [];
+ const results: SearchResult[] = [];
+ for (const entry of SEARCH_INDEX) {
+ const label = fuzzyMatch(entry.label, q);
+ const score =
+ label.score * 3 +
+ (entry.section ? fuzzyMatch(entry.section, q).score * 1.5 : 0) +
+ fuzzyMatch(entry.page, q).score +
+ (entry.description ? fuzzyMatch(entry.description, q).score * 0.5 : 0);
+ if (score > 0) results.push({ entry, score, matchedIndices: label.indices });
+ }
+ return results.sort((a, b) => b.score - a.score).slice(0, 10);
+}
diff --git a/web-ext/app/options/validators.test.ts b/web-ext/app/options/validators.test.ts
new file mode 100644
index 00000000..952f3dd5
--- /dev/null
+++ b/web-ext/app/options/validators.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, test } from "bun:test";
+import { isNudgeMax, isNudgeStep, isSeconds } from "./validators";
+
+describe("options validators", () => {
+ test("seconds must be a positive number, not a blank field", () => {
+ expect(isSeconds("5")).toBe(true);
+ expect(isSeconds("0.5")).toBe(true);
+ expect(isSeconds("")).toBe(false);
+ expect(isSeconds(" ")).toBe(false);
+ expect(isSeconds("0")).toBe(false);
+ expect(isSeconds("-1")).toBe(false);
+ expect(isSeconds("abc")).toBe(false);
+ expect(isSeconds("Infinity")).toBe(false);
+ });
+
+ test("the nudge step stays at or under the maximum, and the maximum at or over the step", () => {
+ expect(isNudgeStep("2", 16)).toBe(true);
+ expect(isNudgeStep("16", 16)).toBe(true);
+ expect(isNudgeStep("20", 16)).toBe(false);
+ expect(isNudgeStep("", 16)).toBe(false);
+ expect(isNudgeMax("16", 1)).toBe(true);
+ expect(isNudgeMax("1", 1)).toBe(true);
+ expect(isNudgeMax("0.5", 1)).toBe(false);
+ expect(isNudgeMax(" ", 1)).toBe(false);
+ });
+});
diff --git a/web-ext/app/options/validators.ts b/web-ext/app/options/validators.ts
new file mode 100644
index 00000000..3d4f8af0
--- /dev/null
+++ b/web-ext/app/options/validators.ts
@@ -0,0 +1,7 @@
+// Number("") and Number(" ") are 0, so a cleared field would otherwise pass
+// as a real value.
+export const isSeconds = (v: string) => v.trim() !== "" && Number.isFinite(Number(v)) && Number(v) > 0;
+
+export const isNudgeStep = (v: string, maxStep: number) => isSeconds(v) && Number(v) <= maxStep;
+
+export const isNudgeMax = (v: string, baseStep: number) => isSeconds(v) && Number(v) >= baseStep;
diff --git a/web-ext/app/popup/App.test.ts b/web-ext/app/popup/App.test.ts
new file mode 100644
index 00000000..92ce913f
--- /dev/null
+++ b/web-ext/app/popup/App.test.ts
@@ -0,0 +1,63 @@
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import { h, render } from "preact";
+import { act } from "preact/test-utils";
+import { scheduleState } from "@/features/schedules/state";
+import { skipSchedule } from "@/features/schedules/messages";
+import App from "./App";
+
+const root = document.createElement("div");
+document.body.append(root);
+const originalSendMessage = chrome.runtime.sendMessage;
+let sent: unknown[] = [];
+
+const settle = () => act(() => new Promise((r) => setTimeout(r, 20)));
+
+const mount = async () => {
+ act(() => {
+ render(h(App, null), root);
+ });
+ await settle();
+};
+
+const statusLine = () => root.querySelector("[role='status']");
+const skipButton = () => [...root.querySelectorAll("button")].find((b) => b.textContent?.trim() === "Skip");
+
+beforeEach(async () => {
+ await chrome.storage.local.clear();
+ sent = [];
+ Object.assign(chrome, { tabs: { query: async () => [] } });
+ Object.assign(chrome.runtime, {
+ sendMessage: async (message: unknown) => {
+ sent.push(message);
+ return { response: undefined };
+ },
+ });
+});
+afterEach(() => {
+ act(() => render(null, root));
+ Object.assign(chrome.runtime, { sendMessage: originalSendMessage });
+ Reflect.deleteProperty(chrome, "tabs");
+});
+
+describe("popup schedule line", () => {
+ test("shows nothing while no schedule has anything to say", async () => {
+ await mount();
+ expect(statusLine()).toBeNull();
+ });
+
+ test("an upcoming change is shown without a Skip", async () => {
+ await scheduleState.set({ status: "Audio turns on at 09:00", inCharge: null });
+ await mount();
+ expect(statusLine()?.textContent).toBe("Audio turns on at 09:00");
+ expect(skipButton()).toBeUndefined();
+ });
+
+ test("Skip asks the background to skip the schedule in charge", async () => {
+ await scheduleState.set({ status: "Audio on by schedule until 18:00", inCharge: "work" });
+ await mount();
+ expect(statusLine()?.textContent).toBe("Audio on by schedule until 18:00");
+ act(() => skipButton()!.click());
+ await settle();
+ expect(sent).toEqual([{ kind: skipSchedule.kind, request: undefined }]);
+ });
+});
diff --git a/web-ext/app/popup/App.tsx b/web-ext/app/popup/App.tsx
new file mode 100644
index 00000000..bcb8834f
--- /dev/null
+++ b/web-ext/app/popup/App.tsx
@@ -0,0 +1,153 @@
+import { useEffect, useState } from "preact/hooks";
+import { useTheme } from "@/ui/useTheme";
+import { Toggle } from "@/ui/form/Switch";
+import Icon, { type IconName } from "@/ui/primitives/Icon";
+import { useSettings } from "@/kernel/useSettings";
+import { appSettings } from "@/app/settings";
+import { OPTIONS_HTML } from "@/app/options/data";
+import { playbackSettings } from "@/features/playback/settings";
+import { shortsSettings } from "@/features/shorts/settings";
+import { getActiveProfile, getAllProfiles, setActiveProfileId, subscribeProfiles } from "@/features/profiles/store";
+import type { Profile } from "@/features/profiles/profiles";
+import { scheduleState } from "@/features/schedules/state";
+import { skipSchedule } from "@/features/schedules/messages";
+import { routeFor, type Route } from "@/youtube/route";
+import { URLS } from "@/lib/urls";
+import { EXTENSION_VERSION } from "@/lib/version";
+import { YOUTUBE_HOSTNAME_SUFFIX } from "@/youtube/urls";
+
+const WIDTH = 340;
+
+const ROUTE_LABEL: Record = {
+ watch: "Watch page",
+ shorts: "Shorts",
+ playlist: "Playlist",
+ home: "Home",
+ search: "Search",
+ other: "YouTube",
+};
+
+function useActiveTab() {
+ const [tab, setTab] = useState<{ host: string; route: Route | null }>({ host: "", route: null });
+ useEffect(() => {
+ void chrome.tabs.query({ active: true, currentWindow: true }).then(([active]) => {
+ if (!active?.url) return;
+ const url = new URL(active.url);
+ setTab({ host: url.hostname, route: url.hostname.endsWith(YOUTUBE_HOSTNAME_SUFFIX) ? routeFor(url) : null });
+ });
+ }, []);
+ return tab;
+}
+
+function useProfiles() {
+ const [profiles, setProfiles] = useState([]);
+ const [activeId, setActiveId] = useState(null);
+ useEffect(() => {
+ const load = async () => {
+ setProfiles(await getAllProfiles());
+ setActiveId((await getActiveProfile())?.id ?? null);
+ };
+ void load();
+ return subscribeProfiles(() => void load());
+ }, []);
+ return { profiles, activeId, activate: (id: string | null) => void setActiveProfileId(id) };
+}
+
+function Row({ title, subtitle, on, onToggle, disabled }: { title: string; subtitle: string; on: boolean; onToggle: () => void; disabled?: boolean }) {
+ return (
+
+ );
+}
+
+function NavButton({ icon, label, onClick }: { icon: IconName; label: string; onClick: () => void }) {
+ return (
+
+
+
+ );
+}
+
+export default function App() {
+ useTheme();
+ const app = useSettings(appSettings);
+ const playback = useSettings(playbackSettings);
+ const shorts = useSettings(shortsSettings);
+ const schedule = useSettings(scheduleState).value;
+ const tab = useActiveTab();
+ const { profiles, activeId, activate } = useProfiles();
+ const onYouTube = tab.route !== null;
+
+ return (
+
+
+
+ Toppings
+ v{EXTENSION_VERSION}
+
+
+
+
+
+
{tab.route ? `${ROUTE_LABEL[tab.route.name]} · ${app.value.enabled ? "active" : "off"}` : "Open a YouTube page"}
+
{tab.host || "—"}
+
+
+
+
+ app.update({ enabled: !app.value.enabled })} />
+ playback.update({ enabled: !playback.value.enabled })} disabled={!app.value.enabled} />
+ shorts.update({ autoScroll: !shorts.value.autoScroll })} disabled={!app.value.enabled} />
+
+
+
+
+ Profile
+ {activeId !== null && (
+ activate(null)}>
+ Clear
+
+ )}
+
+
+ {profiles.map((p) => {
+ const active = p.id === activeId;
+ return (
+ activate(active ? null : p.id)}
+ class={`tw-h-8 tw-px-3 tw-rounded-lg tw-text-[13px] tw-font-medium tw-transition-colors ${active ? "tw-bg-fg tw-text-bg" : "tw-bg-surface-hover tw-text-fg hover:tw-bg-border-default"}`}
+ >
+ {p.name}
+
+ );
+ })}
+
+ {app.value.enabled && schedule.status && (
+
+
+ {schedule.status}
+ {schedule.inCharge !== null && (
+ void skipSchedule.send()}>
+ Skip
+
+ )}
+
+ )}
+
+
+
+ window.open(URLS.GITHUB_ISSUES, "_blank")} />
+ window.open(URLS.SPONSOR_ME, "_blank")} />
+ window.open(chrome.runtime.getURL(OPTIONS_HTML), "_blank")} />
+
+
+ );
+}
diff --git a/web-ext/app/popup/index.css b/web-ext/app/popup/index.css
new file mode 100644
index 00000000..2fbd45d1
--- /dev/null
+++ b/web-ext/app/popup/index.css
@@ -0,0 +1,9 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@import "../../ui/base.css";
+
+body {
+ background: transparent;
+}
diff --git a/web-ext/src/popup/index.html b/web-ext/app/popup/index.html
similarity index 88%
rename from web-ext/src/popup/index.html
rename to web-ext/app/popup/index.html
index 27246fc0..b8d6bb43 100644
--- a/web-ext/src/popup/index.html
+++ b/web-ext/app/popup/index.html
@@ -4,6 +4,7 @@
Toppings: Quick Access
+
You need to enable JavaScript to run this app.
diff --git a/web-ext/app/popup/index.tsx b/web-ext/app/popup/index.tsx
new file mode 100644
index 00000000..a4eaf2a2
--- /dev/null
+++ b/web-ext/app/popup/index.tsx
@@ -0,0 +1,4 @@
+import { render } from "preact";
+import App from "./App";
+
+render( , document.getElementById("root")!);
diff --git a/web-ext/app/settings.ts b/web-ext/app/settings.ts
new file mode 100644
index 00000000..af7ed72f
--- /dev/null
+++ b/web-ext/app/settings.ts
@@ -0,0 +1,20 @@
+import { defineSettings } from "@/kernel/settings";
+
+export type ThemePreference = "system" | "dark" | "light";
+
+export interface AppSettings {
+ // The master switch: off means no feature mounts anywhere.
+ enabled: boolean;
+ theme: ThemePreference;
+}
+
+export const appSettings = defineSettings(
+ "app",
+ { enabled: true, theme: "system" },
+ {
+ legacy: (store) => ({
+ enabled: store.isExtensionEnabled as boolean | undefined,
+ theme: (store.ui as { theme?: ThemePreference } | undefined)?.theme,
+ }),
+ },
+);
diff --git a/web-ext/assets/fonts/OFL-Gloock.txt b/web-ext/assets/fonts/OFL-Gloock.txt
new file mode 100644
index 00000000..3a6f194d
--- /dev/null
+++ b/web-ext/assets/fonts/OFL-Gloock.txt
@@ -0,0 +1,93 @@
+Copyright 2022 The Gloock Project Authors (https://github.com/duartp/gloock)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/web-ext/assets/fonts/OFL-InstrumentSans.txt b/web-ext/assets/fonts/OFL-InstrumentSans.txt
new file mode 100644
index 00000000..26bd2f95
--- /dev/null
+++ b/web-ext/assets/fonts/OFL-InstrumentSans.txt
@@ -0,0 +1,93 @@
+Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/web-ext/assets/fonts/gloock-400.woff2 b/web-ext/assets/fonts/gloock-400.woff2
new file mode 100644
index 00000000..f479c152
Binary files /dev/null and b/web-ext/assets/fonts/gloock-400.woff2 differ
diff --git a/web-ext/assets/fonts/instrument-sans.woff2 b/web-ext/assets/fonts/instrument-sans.woff2
new file mode 100644
index 00000000..665fa65c
Binary files /dev/null and b/web-ext/assets/fonts/instrument-sans.woff2 differ
diff --git a/web-ext/src/assets/icons/disabled_icon128.png b/web-ext/assets/icons/disabled_icon128.png
similarity index 100%
rename from web-ext/src/assets/icons/disabled_icon128.png
rename to web-ext/assets/icons/disabled_icon128.png
diff --git a/web-ext/src/assets/icons/disabled_icon16.png b/web-ext/assets/icons/disabled_icon16.png
similarity index 100%
rename from web-ext/src/assets/icons/disabled_icon16.png
rename to web-ext/assets/icons/disabled_icon16.png
diff --git a/web-ext/src/assets/icons/disabled_icon32.png b/web-ext/assets/icons/disabled_icon32.png
similarity index 100%
rename from web-ext/src/assets/icons/disabled_icon32.png
rename to web-ext/assets/icons/disabled_icon32.png
diff --git a/web-ext/src/assets/icons/disabled_icon48.png b/web-ext/assets/icons/disabled_icon48.png
similarity index 100%
rename from web-ext/src/assets/icons/disabled_icon48.png
rename to web-ext/assets/icons/disabled_icon48.png
diff --git a/web-ext/src/assets/icons/icon128.png b/web-ext/assets/icons/icon128.png
similarity index 100%
rename from web-ext/src/assets/icons/icon128.png
rename to web-ext/assets/icons/icon128.png
diff --git a/web-ext/src/assets/icons/icon16.png b/web-ext/assets/icons/icon16.png
similarity index 100%
rename from web-ext/src/assets/icons/icon16.png
rename to web-ext/assets/icons/icon16.png
diff --git a/web-ext/src/assets/icons/icon32.png b/web-ext/assets/icons/icon32.png
similarity index 100%
rename from web-ext/src/assets/icons/icon32.png
rename to web-ext/assets/icons/icon32.png
diff --git a/web-ext/src/assets/icons/icon48.png b/web-ext/assets/icons/icon48.png
similarity index 100%
rename from web-ext/src/assets/icons/icon48.png
rename to web-ext/assets/icons/icon48.png
diff --git a/web-ext/bun.lock b/web-ext/bun.lock
new file mode 100644
index 00000000..330b6625
--- /dev/null
+++ b/web-ext/bun.lock
@@ -0,0 +1,218 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "web-ext",
+ "dependencies": {
+ "preact": "^10.29.8",
+ },
+ "devDependencies": {
+ "@happy-dom/global-registrator": "^20.14.5",
+ "@types/bun": "^1.3.14",
+ "@types/node": "^20.8.9",
+ "autoprefixer": "^10.4.19",
+ "chrome-types": "^0.1.231",
+ "happy-dom": "^20.14.5",
+ "postcss": "^8.4.38",
+ "tailwindcss": "^3.4.4",
+ "typescript": "^5",
+ },
+ },
+ },
+ "packages": {
+ "@alloc/quick-lru": ["@alloc/quick-lru@5.3.0", "", {}, "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA=="],
+
+ "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.14.5", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.14.5" } }, "sha512-B05ID9DhSwLs6mlm1fzlkAtTIvB3duCvjJjfr19LBrlTK7VZtRjDqoTRIVv13GuYuNdhByu8LSZgThwV3Rkj7g=="],
+
+ "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
+
+ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
+
+ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
+
+ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+
+ "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
+
+ "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
+
+ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
+
+ "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="],
+
+ "@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="],
+
+ "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
+
+ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
+
+ "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
+
+ "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
+
+ "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
+
+ "autoprefixer": ["autoprefixer@10.6.1", "", { "dependencies": { "browserslist": "^4.28.9", "caniuse-lite": "^1.0.30001810", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-cL1Qz6ADZhcEbny/8HPfe99J6HhNoYtpX2LFLIbhgGE7Q1hlQVkYFdetDN7Id3KiQxhDrHwzlHr/YQCnZ8+xSA=="],
+
+ "baseline-browser-mapping": ["baseline-browser-mapping@2.11.26", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ=="],
+
+ "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
+
+ "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
+
+ "browserslist": ["browserslist@4.29.1", "", { "dependencies": { "baseline-browser-mapping": "^2.11.25", "caniuse-lite": "^1.0.30001810", "electron-to-chromium": "^1.5.438", "node-releases": "^2.0.57", "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-AUdjuRyCNGUYtqpqfTmWyM4fXay8yIQhmLnvYe/THMGfT9B/34X7xQd3ifKxwyNPPpowVBjLb+64BN9Rn1mizw=="],
+
+ "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="],
+
+ "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
+
+ "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
+
+ "caniuse-lite": ["caniuse-lite@1.0.30001812", "", {}, "sha512-qN+QNNBr93TCmFrmte0bBCjSDMuRvt78VlHT99qIGPszm4QsqCX8lnyWUFkHi8B7ZBAPq8SH+UqyXNy/odMdng=="],
+
+ "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
+
+ "chrome-types": ["chrome-types@0.1.450", "", {}, "sha512-ixMJSqLzFkLNd08+Pr59zIBO34XssCn7DhPH5jAOY4ph7lAvDxm38YLpJd7QJXfZwcIIw9zjFlIYzG4MXdZEpw=="],
+
+ "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
+
+ "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
+
+ "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
+
+ "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
+
+ "electron-to-chromium": ["electron-to-chromium@1.5.439", "", {}, "sha512-qu6QIPXhsb+CRcAiTMNjR4A1y/7tCYKkKjr5CZXRVih6qkDf79peZ2BpEU3qoDBNCRrcy3Mra3X9nG5oruuA7Q=="],
+
+ "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
+
+ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
+
+ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
+
+ "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
+
+ "fastq": ["fastq@1.20.3", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw=="],
+
+ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
+
+ "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
+
+ "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
+
+ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
+
+ "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
+
+ "happy-dom": ["happy-dom@20.14.5", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ=="],
+
+ "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
+
+ "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
+
+ "is-core-module": ["is-core-module@2.17.0", "", { "dependencies": { "hasown": "^2.0.4" } }, "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA=="],
+
+ "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
+
+ "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
+
+ "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
+
+ "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
+
+ "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
+
+ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
+
+ "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
+
+ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
+
+ "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
+
+ "nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="],
+
+ "node-releases": ["node-releases@2.0.57", "", {}, "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw=="],
+
+ "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
+
+ "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
+
+ "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
+
+ "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
+
+ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
+
+ "picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
+
+ "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
+
+ "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
+
+ "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
+
+ "postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
+
+ "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
+
+ "postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
+
+ "postcss-selector-parser": ["postcss-selector-parser@6.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ=="],
+
+ "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
+
+ "preact": ["preact@10.29.8", "", { "peerDependencies": { "preact-render-to-string": ">=5" }, "optionalPeers": ["preact-render-to-string"] }, "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q=="],
+
+ "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
+
+ "read-cache": ["read-cache@1.0.2", "", {}, "sha512-/peqiBB/n07gQGLsWaHho3WfvUyRscw0gYTsEFMhrIe/nWLkYaf5SbKYjGYqtRV3aPwykJgF2VEMo1ac4bnsGA=="],
+
+ "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
+
+ "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
+
+ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
+
+ "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
+
+ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+
+ "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
+
+ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
+
+ "tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
+
+ "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
+
+ "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
+
+ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
+
+ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
+
+ "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
+
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
+
+ "update-browserslist-db": ["update-browserslist-db@1.3.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ=="],
+
+ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
+
+ "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
+
+ "ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="],
+
+ "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
+
+ "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
+
+ "tinyglobby/picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
+ }
+}
diff --git a/web-ext/bun.lockb b/web-ext/bun.lockb
deleted file mode 100755
index b52920bb..00000000
Binary files a/web-ext/bun.lockb and /dev/null differ
diff --git a/web-ext/bunfig.toml b/web-ext/bunfig.toml
new file mode 100644
index 00000000..8755352e
--- /dev/null
+++ b/web-ext/bunfig.toml
@@ -0,0 +1,2 @@
+[test]
+preload = ["./test/setup.ts"]
diff --git a/web-ext/features/playback/RateChips.tsx b/web-ext/features/playback/RateChips.tsx
new file mode 100644
index 00000000..cbc8beb6
--- /dev/null
+++ b/web-ext/features/playback/RateChips.tsx
@@ -0,0 +1,29 @@
+
+export interface RateChipsProps {
+ rates: number[];
+ current: number;
+ onPick: (rate: number) => void;
+}
+
+const chipLabel = (rate: number) => (Number.isInteger(rate) ? rate.toFixed(1) : String(rate));
+
+// Rendered inside YouTube's own speed panel with its class names, so the
+// chips take YouTube's styling and stay in step with its theme.
+export function RateChips({ rates, current, onPick }: RateChipsProps) {
+ return (
+ <>
+ {rates.map((rate) => (
+
+ ))}
+ >
+ );
+}
diff --git a/web-ext/features/playback/SeekFlash.tsx b/web-ext/features/playback/SeekFlash.tsx
new file mode 100644
index 00000000..261eca0e
--- /dev/null
+++ b/web-ext/features/playback/SeekFlash.tsx
@@ -0,0 +1,32 @@
+
+export interface SeekFlashProps {
+ seconds: number;
+ side: "back" | "forward";
+ visible: boolean;
+}
+
+const style = `
+ :host { position: absolute; inset: 0; pointer-events: none; z-index: 60; }
+ .pill {
+ position: absolute; top: 50%; transform: translateY(-50%);
+ padding: 10px 18px; border-radius: 999px;
+ background: rgba(0, 0, 0, .55); color: #fff;
+ font: 500 15px/1 Roboto, Arial, sans-serif; letter-spacing: .01em;
+ opacity: 0; transition: opacity .15s ease;
+ }
+ .pill[data-visible] { opacity: 1; }
+ .back { left: 12%; }
+ .forward { right: 12%; }
+`;
+
+export function SeekFlash({ seconds, side, visible }: SeekFlashProps) {
+ const sign = side === "back" ? "−" : "+";
+ return (
+ <>
+
+
+ {sign}{seconds}s
+
+ >
+ );
+}
diff --git a/web-ext/features/playback/index.test.ts b/web-ext/features/playback/index.test.ts
new file mode 100644
index 00000000..592bfb04
--- /dev/null
+++ b/web-ext/features/playback/index.test.ts
@@ -0,0 +1,206 @@
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import { playback } from "./index";
+import { playbackSettings } from "./settings";
+
+const SPEED_ICON = "M12 1c1.44 0 2.87.28 4.21.83a11 11 0 0 1 3.45 2.27";
+const page = (label = "Playback speed", normal = "Normal") => `
+
+
+
+
+
`;
+
+const route = { name: "watch", videoId: "v1", playlistId: null } as const;
+const press = (key: string) => document.body.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true }));
+const video = () => document.querySelector("video") as HTMLVideoElement;
+const tick = () => new Promise((r) => setTimeout(r, 5));
+const flashPill = () => document.getElementById("tppng-seek-flash")?.shadowRoot?.querySelector(".pill");
+const chips = () => [...document.querySelectorAll(".ytp-variable-speed-panel-chips button")];
+
+let unmount: (() => void) | undefined | void;
+const mountPlayback = async () => {
+ unmount = await playback.mount({ signal: new AbortController().signal, route });
+};
+
+async function openSpeedPanel() {
+ document.querySelector(".ytp-settings-button")!.click();
+ await tick();
+ document.querySelector(".ytp-menuitem-label")!.parentElement!.click();
+ await tick();
+}
+
+beforeEach(async () => {
+ await chrome.storage.sync.clear();
+ document.body.innerHTML = page();
+ video().currentTime = 60;
+});
+afterEach(() => {
+ unmount?.();
+ unmount = undefined;
+ document.body.innerHTML = "";
+});
+
+describe("playback", () => {
+ test("applies the default rate on mount", async () => {
+ await playbackSettings.set({ defaultRate: 1.25 });
+ await mountPlayback();
+ await tick();
+ expect(video().playbackRate).toBe(1.25);
+ expect(document.querySelector(".ytp-menuitem-content")!.textContent).toBe("1.25");
+ });
+
+ test("rate keys toggle, step and clamp", async () => {
+ await playbackSettings.set({ toggleRate: 2, rateStep: 0.5 });
+ await mountPlayback();
+ press("x");
+ expect(video().playbackRate).toBe(2);
+ press("w");
+ expect(video().playbackRate).toBe(2.5);
+ press("x");
+ expect(video().playbackRate).toBe(1);
+ for (let i = 0; i < 5; i++) press("s");
+ expect(video().playbackRate).toBe(0.0625);
+ });
+
+ test("sixteenth steps stay exact", async () => {
+ await playbackSettings.set({ rateStep: 0.0625 });
+ await mountPlayback();
+ press("w");
+ press("w");
+ await tick();
+ expect(video().playbackRate).toBe(1.125);
+ expect(document.querySelector(".ytp-menuitem-content")!.textContent).toBe("1.125");
+ });
+
+ test("seek keys move the playhead and flash the amount", async () => {
+ await playbackSettings.set({ seekForward: 10, seekBackward: 30 });
+ await mountPlayback();
+ press("d");
+ expect(video().currentTime).toBe(70);
+ expect(flashPill()?.textContent).toBe("+10s");
+ expect(flashPill()?.hasAttribute("data-visible")).toBe(true);
+ press("a");
+ expect(video().currentTime).toBe(40);
+ expect(flashPill()?.textContent).toBe("−30s");
+ unmount?.();
+ unmount = undefined;
+ expect(document.getElementById("tppng-seek-flash")).toBeNull();
+ });
+
+ test("custom rates replace YouTube's speed chips", async () => {
+ await playbackSettings.set({ customRates: [1, 1.5, 3] });
+ await mountPlayback();
+ await openSpeedPanel();
+ expect(chips().map((b) => b.textContent)).toEqual(["1.0", "1.5", "3.0"]);
+ expect(chips().map((b) => b.getAttribute("aria-pressed"))).toEqual(["true", "false", "false"]);
+
+ (chips()[2] as HTMLElement).click();
+ await tick();
+ expect(video().playbackRate).toBe(3);
+ expect(chips()[2].getAttribute("aria-pressed")).toBe("true");
+ expect(document.querySelector(".ytp-variable-speed-panel-display span")!.textContent).toBe("3.00x");
+ expect(document.querySelector(".ytp-speedslider")!.value).toBe("3");
+ expect(document.querySelector(".ytp-speedslider")!.style.getPropertyValue("--yt-slider-shape-gradient-percent")).toBe("100%");
+ });
+
+ test("the panel reflects a rate set by keyboard", async () => {
+ await playbackSettings.set({ customRates: [1, 2], toggleRate: 2 });
+ await mountPlayback();
+ press("x");
+ await openSpeedPanel();
+ expect(document.querySelector(".ytp-variable-speed-panel-display span")!.textContent).toBe("2.00x");
+ expect(chips()[1].getAttribute("aria-pressed")).toBe("true");
+ });
+
+ test("an open speed panel follows a rate changed by shortcut", async () => {
+ await playbackSettings.set({ customRates: [1, 2], toggleRate: 2 });
+ await mountPlayback();
+ await openSpeedPanel();
+ press("x");
+ await tick();
+ expect(document.querySelector(".ytp-variable-speed-panel-display span")!.textContent).toBe("2.00x");
+ expect(chips()[1].getAttribute("aria-pressed")).toBe("true");
+ expect(document.querySelector(".ytp-speedslider")!.style.getPropertyValue("--yt-slider-shape-gradient-percent")).toBe("100%");
+ });
+
+ test("the slider's filled track sits where the rate is, YouTube's way", async () => {
+ await playbackSettings.set({ customRates: [1, 1.5] });
+ await mountPlayback();
+ await openSpeedPanel();
+ (chips()[1] as HTMLElement).click();
+ await tick();
+ const fill = document.querySelector(".ytp-speedslider")!.style.getPropertyValue("--yt-slider-shape-gradient-percent");
+ expect(parseFloat(fill)).toBeCloseTo(71.43, 1);
+ });
+
+ test("the speed row is current when YouTube puts the main list back after a submenu", async () => {
+ await playbackSettings.set({ toggleRate: 2 });
+ await mountPlayback();
+ await openSpeedPanel();
+ const mainList = document.querySelector(".ytp-panel-menu")!;
+ const parent = mainList.parentElement!;
+ mainList.remove();
+ press("x");
+ await tick();
+ parent.prepend(mainList);
+ expect(mainList.querySelector(".ytp-menuitem-content")!.textContent).toBe("2");
+ });
+
+ test("without custom rates the panel is left to YouTube", async () => {
+ await mountPlayback();
+ await openSpeedPanel();
+ expect(chips().map((b) => b.textContent)).toEqual(["1.0"]);
+ });
+
+ test("the speed row follows a rate set while the menu is closed", async () => {
+ document.querySelector(".ytp-settings-menu")!.style.display = "none";
+ await playbackSettings.set({ toggleRate: 2 });
+ await mountPlayback();
+ press("x");
+ await tick();
+ expect(document.querySelector(".ytp-menuitem-content")!.textContent).toBe("2");
+ });
+
+ test("the speed row keeps YouTube's own word for 1x in any language", async () => {
+ document.body.innerHTML = page("Wiedergabegeschwindigkeit", "Standard");
+ await playbackSettings.set({ defaultRate: 1.25, toggleRate: 2 });
+ await mountPlayback();
+ const value = () => document.querySelector(".ytp-menuitem-content")!.textContent;
+ await tick();
+ expect(value()).toBe("1.25");
+ press("x");
+ await tick();
+ expect(value()).toBe("Standard");
+ press("x");
+ await tick();
+ expect(value()).toBe("2");
+ press("x");
+ await tick();
+ expect(value()).toBe("Standard");
+ });
+
+ test("custom rates reach the speed panel in any language", async () => {
+ document.body.innerHTML = page("Wiedergabegeschwindigkeit", "Standard");
+ await playbackSettings.set({ customRates: [1, 1.5, 3] });
+ await mountPlayback();
+ await openSpeedPanel();
+ expect(chips().map((b) => b.textContent)).toEqual(["1.0", "1.5", "3.0"]);
+ });
+
+ test("keys stop working after unmount", async () => {
+ await mountPlayback();
+ unmount?.();
+ unmount = undefined;
+ press("x");
+ expect(video().playbackRate).toBe(1);
+ });
+});
diff --git a/web-ext/features/playback/index.tsx b/web-ext/features/playback/index.tsx
new file mode 100644
index 00000000..b0cdf7f6
--- /dev/null
+++ b/web-ext/features/playback/index.tsx
@@ -0,0 +1,152 @@
+import { render } from "preact";
+import type { Feature } from "@/kernel/features";
+import { bindKeys } from "@/kernel/keys";
+import { mount } from "@/kernel/dom/mount";
+import { setCapabilityStatus } from "@/kernel/dom/capabilities";
+import {
+ findSpeedRow,
+ ratePanelChips,
+ resolveMoviePlayer,
+ resolveRatePanel,
+ resolveSettingsButton,
+ resolveVideo,
+ showRateInSpeedRow,
+ syncRatePanel,
+} from "@/youtube/player";
+import { playbackKeys } from "./keys";
+import { RateChips } from "./RateChips";
+import { SeekFlash } from "./SeekFlash";
+import { MAX_RATE, MIN_RATE, playbackSettings } from "./settings";
+
+// Four places keep 1/16 steps exact while dropping float drift like 1.1 + 0.1.
+const clamp = (rate: number) => Math.min(MAX_RATE, Math.max(MIN_RATE, Number(rate.toFixed(4))));
+
+export const playback: Feature = {
+ id: "playback",
+ routes: ["watch"],
+ async mount() {
+ const settings = await playbackSettings.get();
+ if (!settings.enabled) return;
+
+ const player = await resolveVideo();
+ void setCapabilityStatus("watch.player", "watch", player);
+ if (!player.resolved) return;
+ const video = player.element as HTMLVideoElement;
+
+ const setRate = (rate: number) => {
+ video.playbackRate = clamp(rate);
+ };
+ setRate(settings.defaultRate);
+
+ const flash = await mountSeekFlash();
+ const seek = (side: "back" | "forward", seconds: number) => {
+ video.currentTime += side === "back" ? -seconds : seconds;
+ flash?.show(side, seconds);
+ };
+
+ const unbindKeys = bindKeys(playbackKeys, {
+ toggleRate: () => setRate(video.playbackRate === 1 ? settings.toggleRate : 1),
+ increaseRate: () => setRate(video.playbackRate + settings.rateStep),
+ decreaseRate: () => setRate(video.playbackRate - settings.rateStep),
+ seekBackward: () => seek("back", settings.seekBackward),
+ seekForward: () => seek("forward", settings.seekForward),
+ });
+
+ const unhookMenu = await hookSettingsMenu(settings.customRates, video, setRate);
+
+ return () => {
+ unbindKeys();
+ unhookMenu?.();
+ flash?.unmount();
+ };
+ },
+};
+
+async function mountSeekFlash() {
+ const host = await resolveMoviePlayer();
+ void setCapabilityStatus("watch.moviePlayer", "watch", host);
+ if (!host.resolved) return;
+ let timer: ReturnType | undefined;
+ const ui = mount("tppng-seek-flash", host.element, );
+ return {
+ show(side: "back" | "forward", seconds: number) {
+ ui.update( );
+ clearTimeout(timer);
+ timer = setTimeout(() => ui.update( ), 600);
+ },
+ unmount() {
+ clearTimeout(timer);
+ ui.unmount();
+ },
+ };
+}
+
+// YouTube builds the settings menu lazily on first open and the speed panel
+// on each entry, so both are hooked by click rather than resolved up front.
+// It only redraws them from its own state, which a rate set straight on the
+// never reaches, so they follow the video's ratechange instead.
+async function hookSettingsMenu(rates: number[], video: HTMLVideoElement, setRate: (rate: number) => void) {
+ const button = await resolveSettingsButton();
+ void setCapabilityStatus("watch.settingsButton", "watch", button);
+ if (!button.resolved) return;
+
+ let redrawPanel: (() => void) | null = null;
+ const onSpeedRow = async () => {
+ const panel = await resolveRatePanel();
+ void setCapabilityStatus("watch.ratePanel", "watch", panel);
+ if (!panel.resolved) return;
+ const chips = rates.length ? ratePanelChips(panel.element) : null;
+ const draw = () => {
+ if (!panel.element.isConnected) return void (redrawPanel = null);
+ syncRatePanel(panel.element, video.playbackRate);
+ if (chips) render( , chips);
+ };
+ if (chips) {
+ chips.replaceChildren();
+ chips.style.flexWrap = "wrap";
+ }
+ redrawPanel = draw;
+ draw();
+ };
+
+ // Kept rather than looked up: while a submenu is open YouTube takes the main
+ // list out of the page, and the row must still be current when it returns.
+ let speedRow: Element | null = null;
+ const onRateChange = () => {
+ const row = speedRow ?? findSpeedRow(video).element;
+ if (row) showRateInSpeedRow(row, video.playbackRate);
+ redrawPanel?.();
+ };
+ video.addEventListener("ratechange", onRateChange);
+
+ let speedRowReported = false;
+ let pending: ReturnType | undefined;
+ const onSettings = () => {
+ clearTimeout(pending);
+ // The row exists only once the menu has rendered, which happens on this same
+ // click, and the menu can be opened before YouTube has built every row.
+ pending = setTimeout(() => {
+ const found = findSpeedRow(video);
+ if (!speedRowReported) {
+ void setCapabilityStatus("watch.speedRow", "watch", found);
+ speedRowReported = found.resolved;
+ }
+ if (!found.resolved) return;
+ const row = found.element;
+ showRateInSpeedRow(row, video.playbackRate);
+ if (row !== speedRow) {
+ speedRow?.removeEventListener("click", onSpeedRow);
+ row.addEventListener("click", onSpeedRow);
+ speedRow = row;
+ }
+ }, 0);
+ };
+ button.element.addEventListener("click", onSettings);
+
+ return () => {
+ clearTimeout(pending);
+ video.removeEventListener("ratechange", onRateChange);
+ button.element.removeEventListener("click", onSettings);
+ speedRow?.removeEventListener("click", onSpeedRow);
+ };
+}
diff --git a/web-ext/features/playback/keys.ts b/web-ext/features/playback/keys.ts
new file mode 100644
index 00000000..71b3365e
--- /dev/null
+++ b/web-ext/features/playback/keys.ts
@@ -0,0 +1,13 @@
+import { defineKeys } from "@/kernel/keys";
+
+export const playbackKeys = defineKeys({
+ id: "playback",
+ title: "Playback",
+ keys: {
+ toggleRate: { label: "Toggle playback rate", description: "Between 1× and your toggle rate.", defaultBinding: "X" },
+ increaseRate: { label: "Increase playback rate", defaultBinding: "W" },
+ decreaseRate: { label: "Decrease playback rate", defaultBinding: "S" },
+ seekBackward: { label: "Seek backward", defaultBinding: "A" },
+ seekForward: { label: "Seek forward", defaultBinding: "D" },
+ },
+});
diff --git a/web-ext/features/playback/settings.test.ts b/web-ext/features/playback/settings.test.ts
new file mode 100644
index 00000000..0481f732
--- /dev/null
+++ b/web-ext/features/playback/settings.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, test } from "bun:test";
+import { parseRates } from "./settings";
+
+describe("parseRates", () => {
+ test("empty means YouTube's own menu", () => {
+ expect(parseRates(" ")).toEqual([]);
+ });
+ test("sorts, dedupes and requires 1", () => {
+ expect(parseRates("2, 1, 1.5,2")).toEqual([1, 1.5, 2]);
+ expect(parseRates("1.5, 2")).toBeUndefined();
+ });
+ test("rejects out-of-range and junk", () => {
+ expect(parseRates("1, 17")).toBeUndefined();
+ expect(parseRates("1, fast")).toBeUndefined();
+ });
+});
diff --git a/web-ext/features/playback/settings.ts b/web-ext/features/playback/settings.ts
new file mode 100644
index 00000000..80f272a3
--- /dev/null
+++ b/web-ext/features/playback/settings.ts
@@ -0,0 +1,54 @@
+import { defineSettings } from "@/kernel/settings";
+
+export interface PlaybackSettings {
+ enabled: boolean;
+ defaultRate: number;
+ toggleRate: number;
+ rateStep: number;
+ seekBackward: number;
+ seekForward: number;
+ customRates: number[];
+}
+
+interface LegacyWatch {
+ isEnabled?: boolean;
+ defaultPlaybackRate?: { value?: string };
+ togglePlaybackRate?: { value?: string };
+ increasePlaybackRate?: { value?: string };
+ seekBackward?: { value?: string };
+ seekForward?: { value?: string };
+ customPlaybackRates?: string[];
+}
+
+export const MIN_RATE = 0.0625;
+export const MAX_RATE = 16;
+
+const number = (value: string | undefined) => (value === undefined ? undefined : Number(value));
+
+export const playbackSettings = defineSettings(
+ "playback",
+ { enabled: true, defaultRate: 1, toggleRate: 1.5, rateStep: 0.25, seekBackward: 15, seekForward: 15, customRates: [] },
+ {
+ legacy: (store) => {
+ const legacy = (store.preferences as { watch?: LegacyWatch } | undefined)?.watch;
+ if (!legacy) return undefined;
+ return {
+ enabled: legacy.isEnabled,
+ defaultRate: number(legacy.defaultPlaybackRate?.value),
+ toggleRate: number(legacy.togglePlaybackRate?.value),
+ rateStep: number(legacy.increasePlaybackRate?.value),
+ seekBackward: number(legacy.seekBackward?.value),
+ seekForward: number(legacy.seekForward?.value),
+ customRates: legacy.customPlaybackRates?.map(Number),
+ };
+ },
+ },
+);
+
+// A rate list must keep 1 so the user can always return to normal speed.
+export function parseRates(text: string): number[] | undefined {
+ if (text.trim() === "") return [];
+ const rates = text.split(",").map((part) => Number(part.trim()));
+ const valid = rates.every((rate) => Number.isFinite(rate) && rate >= MIN_RATE && rate <= MAX_RATE);
+ return valid && rates.includes(1) ? [...new Set(rates)].sort((a, b) => a - b) : undefined;
+}
diff --git a/web-ext/features/playlist-runtime/RuntimeLine.tsx b/web-ext/features/playlist-runtime/RuntimeLine.tsx
new file mode 100644
index 00000000..b415ac60
--- /dev/null
+++ b/web-ext/features/playlist-runtime/RuntimeLine.tsx
@@ -0,0 +1,49 @@
+import { credit } from "@/kernel/dom/credit";
+import { formatClock } from "@/lib/duration";
+import type { PlaylistRuntime } from "./messages";
+import { skeletonStyle } from "./skeleton";
+
+export interface RuntimeLineProps {
+ // null while the runtime is on its way.
+ runtime: PlaylistRuntime | null;
+ // YouTube's separator in the row above, or null where it spaces items instead.
+ separator: string | null;
+ onRefresh?: () => void;
+}
+
+// Font and colour are inherited from the host, which copies them from
+// YouTube's own metadata text beside it.
+const styles = `
+ :host { display: block; }
+ .line { display: flex; align-items: center; min-height: 18px; }
+ .sep { margin: 0 4px; }
+ .spaced { margin-left: 8px; }
+ .refresh {
+ display: grid; place-items: center; width: 18px; height: 18px; margin-left: 6px; padding: 0;
+ border: 0; border-radius: 50%; background: transparent; color: inherit; font-size: 12px; line-height: 1; cursor: pointer;
+ }
+ .refresh:hover { background: rgba(127, 127, 127, .2); }
+ ${skeletonStyle}
+`;
+
+export function RuntimeLine({ runtime, separator, onRefresh }: RuntimeLineProps) {
+ return (
+
+
+ {runtime ? (
+ <>
+ {formatClock(runtime.totalRuntime)} total
+ {separator ? {separator} : null}
+ {formatClock(runtime.averageRuntime)} avg
+ {onRefresh && (
+
+ ↻
+
+ )}
+ >
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/web-ext/features/playlist-runtime/background.ts b/web-ext/features/playlist-runtime/background.ts
new file mode 100644
index 00000000..b83079c3
--- /dev/null
+++ b/web-ext/features/playlist-runtime/background.ts
@@ -0,0 +1,64 @@
+import { CHROME_STORAGE_LOCAL_KEY } from "@/lib/storageKeys";
+import { getPlaylistRuntime, type PlaylistRuntime } from "./messages";
+
+// Set at build time; `TOPPINGS_API=http://127.0.0.1:8787/api bun run dev`
+// points a build at a local Worker.
+const API_BASE = process.env.TOPPINGS_API;
+
+// A playlist's runtime changes only when videos are added or removed, so a
+// day-old answer is nearly always right; the refresh button bypasses it.
+const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
+
+// YouTube announces a hard load more than once, so the same playlist is often
+// asked for again before its first answer is back.
+const inFlight = new Map>();
+
+interface CacheEntry {
+ data: PlaylistRuntime;
+ cachedAt: number;
+}
+
+function cacheKey(playlistId: string): string {
+ return `${CHROME_STORAGE_LOCAL_KEY.PLAYLIST_CACHE_PREFIX}${playlistId}`;
+}
+
+async function readCache(playlistId: string): Promise {
+ const key = cacheKey(playlistId);
+ const entry = (await chrome.storage.local.get(key))[key] as CacheEntry | undefined;
+ if (!entry) return null;
+ if (Date.now() - entry.cachedAt > CACHE_TTL_MS) {
+ await chrome.storage.local.remove(key);
+ return null;
+ }
+ return entry.data;
+}
+
+async function fetchRuntime(playlistId: string): Promise {
+ const response = await fetch(`${API_BASE}/v1/playlist/${encodeURIComponent(playlistId)}`, { headers: { Accept: "application/json" } });
+ if (!response.ok) return null;
+ const body = (await response.json()) as { payload: PlaylistRuntime };
+ return body.payload;
+}
+
+async function fetchAndCache(playlistId: string): Promise {
+ const data = await fetchRuntime(playlistId);
+ // An empty answer means the API could not see the list, not that it is empty.
+ if (!data || data.totalVideos === 0) return null;
+ await chrome.storage.local.set({ [cacheKey(playlistId)]: { data, cachedAt: Date.now() } satisfies CacheEntry });
+ return data;
+}
+
+export function servePlaylistRuntime(): void {
+ getPlaylistRuntime.handle(async ({ playlistId, refresh }) => {
+ if (!refresh) {
+ const cached = await readCache(playlistId);
+ if (cached) return cached;
+ }
+ let pending = inFlight.get(playlistId);
+ if (!pending) {
+ pending = fetchAndCache(playlistId).finally(() => inFlight.delete(playlistId));
+ inFlight.set(playlistId, pending);
+ }
+ return pending;
+ });
+}
diff --git a/web-ext/features/playlist-runtime/index.test.ts b/web-ext/features/playlist-runtime/index.test.ts
new file mode 100644
index 00000000..d8041323
--- /dev/null
+++ b/web-ext/features/playlist-runtime/index.test.ts
@@ -0,0 +1,166 @@
+import { afterEach, describe, expect, test } from "bun:test";
+import { createPlaylistRuntime } from "./index";
+
+// The playlist header layouts YouTube serves, reduced to what the feature
+// resolves against.
+const layouts = {
+ "header card": ` `,
+ "page header": ` `,
+ sidebar: `10 videos
`,
+};
+
+const runtime = { playlistId: "PL1", totalVideos: 10, totalRuntime: 13054, averageRuntime: 1305 };
+
+function feature(overrides: Partial[0]> = {}) {
+ return createPlaylistRuntime({
+ getRuntime: async () => runtime,
+ isEnabled: async () => true,
+ ...overrides,
+ });
+}
+
+const playlistRoute = { name: "playlist", playlistId: "PL1", system: false } as const;
+const settle = () => new Promise((r) => setTimeout(r, 0));
+
+afterEach(() => {
+ document.body.innerHTML = "";
+});
+
+describe("playlist runtime", () => {
+ for (const [name, html] of Object.entries(layouts)) {
+ test(`renders the runtimes into the ${name} layout`, async () => {
+ document.body.innerHTML = html;
+ const unmount = await feature().mount({ signal: new AbortController().signal, route: playlistRoute });
+ await settle();
+
+ const host = document.getElementById("tppng-playlist-runtime");
+ expect(host).not.toBeNull();
+ const text = host!.shadowRoot!.textContent;
+ expect(text).toMatch(/3:37:34 total•?21:45 avg/);
+
+ unmount?.();
+ expect(document.getElementById("tppng-playlist-runtime")).toBeNull();
+ });
+ }
+
+ test("lands under the byline, not in a hidden channel page left behind by navigation", async () => {
+ document.body.innerHTML = ` ${layouts["header card"]}`;
+ const unmount = await feature().mount({ signal: new AbortController().signal, route: playlistRoute });
+ const host = document.getElementById("tppng-playlist-runtime")!;
+ expect(host.parentElement!.className).toBe("metadata-text-wrapper");
+ expect(host.previousElementSibling!.tagName.toLowerCase()).toBe("ytd-playlist-byline-renderer");
+ unmount?.();
+ });
+
+ test("adds a badge to the playlist panel on a watch page", async () => {
+ document.body.innerHTML = ` `;
+ const unmount = await feature().mount({ signal: new AbortController().signal, route: { name: "watch", videoId: "v1", playlistId: "PL1" } });
+ await settle();
+ const badge = document.getElementById("tppng-watch-playlist-runtime");
+ expect(badge!.parentElement!.id).toBe("header-description");
+ expect(badge!.shadowRoot!.textContent).toContain("3:37:34 total21:45 avg");
+ unmount?.();
+ expect(document.getElementById("tppng-watch-playlist-runtime")).toBeNull();
+ });
+
+ test("takes YouTube's separator and text colour in the page-header layout", async () => {
+ document.body.innerHTML = layouts["page header"];
+ const unmount = await feature().mount({ signal: new AbortController().signal, route: playlistRoute });
+ await settle();
+ const host = document.getElementById("tppng-playlist-runtime")!;
+ expect(host.shadowRoot!.textContent).toContain("3:37:34 total•21:45 avg");
+ expect(host.style.color).toBe("rgb(170, 170, 170)");
+ unmount?.();
+ });
+
+ test("mounts without waiting for the runtime, holding the space with a skeleton", async () => {
+ document.body.innerHTML = layouts["header card"];
+ let answer!: (r: typeof runtime) => void;
+ const unmount = await feature({ getRuntime: () => new Promise((r) => (answer = r)) }).mount({ signal: new AbortController().signal, route: playlistRoute });
+ const root = document.getElementById("tppng-playlist-runtime")!.shadowRoot!;
+ expect(root.querySelector(".bone")).not.toBeNull();
+ answer(runtime);
+ await settle();
+ expect(root.querySelector(".bone")).toBeNull();
+ expect(root.textContent).toContain("3:37:34 total");
+ unmount?.();
+ });
+
+ test("removes the skeleton when the runtime cannot be fetched", async () => {
+ document.body.innerHTML = layouts["header card"];
+ await feature({ getRuntime: async () => null }).mount({ signal: new AbortController().signal, route: playlistRoute });
+ await settle();
+ expect(document.getElementById("tppng-playlist-runtime")).toBeNull();
+ });
+
+ test("removes the skeleton when asking for the runtime throws", async () => {
+ document.body.innerHTML = layouts["header card"];
+ await feature({ getRuntime: () => Promise.reject(new Error("offline")) }).mount({ signal: new AbortController().signal, route: playlistRoute });
+ await settle();
+ expect(document.getElementById("tppng-playlist-runtime")).toBeNull();
+ });
+
+ test("draws nothing when the runtime arrives after navigating away", async () => {
+ document.body.innerHTML = layouts["header card"];
+ let answer!: (r: typeof runtime) => void;
+ const unmount = await feature({ getRuntime: () => new Promise((r) => (answer = r)) }).mount({ signal: new AbortController().signal, route: playlistRoute });
+ unmount?.();
+ answer(runtime);
+ await settle();
+ expect(document.getElementById("tppng-playlist-runtime")).toBeNull();
+ });
+
+ test("skips mixes in the watch page's playlist panel", async () => {
+ document.body.innerHTML = ` `;
+ await feature().mount({ signal: new AbortController().signal, route: { name: "watch", videoId: "v1", playlistId: "RDv1" } });
+ expect(document.getElementById("tppng-watch-playlist-runtime")).toBeNull();
+ });
+
+ test("does nothing when disabled", async () => {
+ document.body.innerHTML = layouts.sidebar;
+ await feature({ isEnabled: async () => false }).mount({ signal: new AbortController().signal, route: playlistRoute });
+ expect(document.getElementById("tppng-playlist-runtime")).toBeNull();
+ });
+
+ test("skips Watch Later and Liked", async () => {
+ document.body.innerHTML = layouts.sidebar;
+ await feature().mount({ signal: new AbortController().signal, route: { name: "playlist", playlistId: "WL", system: true } });
+ expect(document.getElementById("tppng-playlist-runtime")).toBeNull();
+ });
+
+ test("refresh asks for uncached data and shows it", async () => {
+ document.body.innerHTML = layouts.sidebar;
+ const calls: boolean[] = [];
+ await feature({
+ getRuntime: async (_, refresh) => {
+ calls.push(refresh);
+ return refresh ? { ...runtime, totalRuntime: 60, averageRuntime: 6 } : runtime;
+ },
+ }).mount({ signal: new AbortController().signal, route: playlistRoute });
+ await settle();
+
+ const root = document.getElementById("tppng-playlist-runtime")!.shadowRoot!;
+ root.querySelector("button")!.click();
+ expect(root.querySelector(".bone")).not.toBeNull();
+ await new Promise((r) => setTimeout(r, 0));
+
+ expect(calls).toEqual([false, true]);
+ expect(root.querySelector(".bone")).toBeNull();
+ expect(root.textContent).toContain("1:00 total");
+ });
+
+ test("a refresh that fails keeps showing the last runtime", async () => {
+ document.body.innerHTML = layouts.sidebar;
+ await feature({ getRuntime: async (_, refresh) => (refresh ? Promise.reject(new Error("offline")) : runtime) }).mount({ signal: new AbortController().signal, route: playlistRoute });
+ await settle();
+ const root = document.getElementById("tppng-playlist-runtime")!.shadowRoot!;
+ root.querySelector("button")!.click();
+ await settle();
+ expect(root.querySelector(".bone")).toBeNull();
+ expect(root.textContent).toContain("3:37:34 total");
+ });
+});
diff --git a/web-ext/features/playlist-runtime/index.tsx b/web-ext/features/playlist-runtime/index.tsx
new file mode 100644
index 00000000..f10dfdfc
--- /dev/null
+++ b/web-ext/features/playlist-runtime/index.tsx
@@ -0,0 +1,71 @@
+import type { Feature, Unmount } from "@/kernel/features";
+import { mount } from "@/kernel/dom/mount";
+import { setCapabilityStatus } from "@/kernel/dom/capabilities";
+import { adoptTextStyle } from "@/kernel/dom/adoptTextStyle";
+import { metadataTextIn, resolvePlaylistHeader, resolveWatchPlaylistPanel } from "@/youtube/playlist";
+import { isSystemPlaylist } from "@/youtube/route";
+import { getPlaylistRuntime, type PlaylistRuntime } from "./messages";
+import { RuntimeLine } from "./RuntimeLine";
+import { playlistRuntimeSettings } from "./settings";
+
+export interface PlaylistRuntimeDeps {
+ getRuntime(playlistId: string, refresh: boolean): Promise;
+ isEnabled(): Promise;
+}
+
+export function createPlaylistRuntime(deps: PlaylistRuntimeDeps): Feature {
+ return {
+ id: "playlist-runtime",
+ routes: ["playlist", "watch"],
+ async mount({ route }) {
+ if (route.name !== "playlist" && route.name !== "watch") return;
+ const { playlistId } = route;
+ if (!playlistId || isSystemPlaylist(playlistId) || !(await deps.isEnabled())) return;
+
+ if (route.name === "watch") {
+ const panel = await resolveWatchPlaylistPanel();
+ if (!panel.resolved) return;
+ return showRuntime("tppng-watch-playlist-runtime", panel.element, playlistId, false);
+ }
+ const header = await resolvePlaylistHeader();
+ void setCapabilityStatus("playlist.runtime", "playlist", header);
+ if (!header.resolved) return;
+ return showRuntime("tppng-playlist-runtime", header.element, playlistId, true);
+ },
+ };
+
+ // Returns as soon as the skeleton is up: features mount one after another,
+ // so waiting here would hold every later feature behind the network.
+ function showRuntime(id: string, container: Element, playlistId: string, refreshable: boolean): Unmount {
+ const { sample, separator } = metadataTextIn(container);
+ let mounted = true;
+ let shown: PlaylistRuntime | null = null;
+
+ const view = (runtime: PlaylistRuntime | null) => (
+ void load(true) : undefined} />
+ );
+ const line = mount(id, container, view(null));
+ adoptTextStyle(line.host, sample);
+ const remove = () => {
+ if (!mounted) return;
+ mounted = false;
+ line.unmount();
+ };
+
+ const load = async (refresh: boolean) => {
+ line.update(view(null));
+ const runtime = await deps.getRuntime(playlistId, refresh).catch(() => null);
+ if (!mounted) return;
+ shown = runtime ?? shown;
+ if (shown) line.update(view(shown));
+ else remove();
+ };
+ void load(false);
+ return remove;
+ }
+}
+
+export const playlistRuntime = createPlaylistRuntime({
+ getRuntime: (playlistId, refresh) => getPlaylistRuntime.send({ playlistId, refresh }),
+ isEnabled: async () => (await playlistRuntimeSettings.get()).enabled,
+});
diff --git a/web-ext/features/playlist-runtime/messages.ts b/web-ext/features/playlist-runtime/messages.ts
new file mode 100644
index 00000000..5a1865b1
--- /dev/null
+++ b/web-ext/features/playlist-runtime/messages.ts
@@ -0,0 +1,14 @@
+import { defineMessage } from "@/kernel/messaging";
+
+export interface PlaylistRuntime {
+ playlistId: string;
+ totalVideos: number;
+ totalRuntime: number;
+ averageRuntime: number;
+}
+
+// Answered by the background, which is the only surface allowed to call the
+// Toppings API from a YouTube page. `refresh` bypasses the cache.
+export const getPlaylistRuntime = defineMessage<{ playlistId: string; refresh?: boolean }, PlaylistRuntime | null>(
+ "playlist-runtime",
+);
diff --git a/web-ext/features/playlist-runtime/settings.ts b/web-ext/features/playlist-runtime/settings.ts
new file mode 100644
index 00000000..2c3411fb
--- /dev/null
+++ b/web-ext/features/playlist-runtime/settings.ts
@@ -0,0 +1,12 @@
+import { defineSettings } from "@/kernel/settings";
+
+export const playlistRuntimeSettings = defineSettings(
+ "playlist-runtime",
+ { enabled: true },
+ {
+ legacy: (store) => {
+ const legacy = (store.preferences as { playlist?: { isEnabled?: boolean } } | undefined)?.playlist;
+ return legacy?.isEnabled === undefined ? undefined : { enabled: legacy.isEnabled };
+ },
+ },
+);
diff --git a/web-ext/features/playlist-runtime/skeleton.ts b/web-ext/features/playlist-runtime/skeleton.ts
new file mode 100644
index 00000000..1dd6ea1c
--- /dev/null
+++ b/web-ext/features/playlist-runtime/skeleton.ts
@@ -0,0 +1,11 @@
+// A shimmering stand-in sized like the text it replaces, so nothing shifts
+// when the runtime arrives. Colours come from the surrounding text.
+export const skeletonStyle = `
+ .bone {
+ display: inline-block; height: 10px; border-radius: 5px; vertical-align: middle;
+ background: linear-gradient(90deg, currentColor 0%, currentColor 40%, transparent 50%, currentColor 60%, currentColor 100%);
+ background-size: 300% 100%; opacity: .18; animation: shimmer 1.4s ease-in-out infinite;
+ }
+ @keyframes shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } }
+ @media (prefers-reduced-motion: reduce) { .bone { animation: none; } }
+`;
diff --git a/web-ext/features/profiles/AudioButton.tsx b/web-ext/features/profiles/AudioButton.tsx
new file mode 100644
index 00000000..5a11447f
--- /dev/null
+++ b/web-ext/features/profiles/AudioButton.tsx
@@ -0,0 +1,30 @@
+import { credit } from "@/kernel/dom/credit";
+
+// Filled while Audio is on, outlined while off, like the segments button and
+// YouTube's own toggles.
+export function AudioButton({ active }: { active: boolean }) {
+ return (
+
+
+
+ );
+}
+
+export function audioButtonHost(): HTMLButtonElement {
+ const host = document.createElement("button");
+ host.id = "tppng-audio-button";
+ host.className = "ytp-button";
+ host.setAttribute("aria-label", "Audio mode");
+ return host;
+}
+
+export function setAudioButtonState(host: HTMLButtonElement, active: boolean): void {
+ host.setAttribute("aria-pressed", String(active));
+ host.title = credit(active ? "Audio mode on" : "Audio mode");
+}
diff --git a/web-ext/features/profiles/gearMenu.tsx b/web-ext/features/profiles/gearMenu.tsx
new file mode 100644
index 00000000..648a7594
--- /dev/null
+++ b/web-ext/features/profiles/gearMenu.tsx
@@ -0,0 +1,217 @@
+import { render } from "preact";
+import { playerOf, resolveSettingsButton, settingsMenu } from "@/youtube/player";
+import type { Profile } from "./profiles";
+import { getActiveProfile, getAllProfiles, setActiveProfileId } from "./store";
+
+const ROW_ID = "tppng-profile-row";
+const PANEL_ID = "tppng-profile-panel";
+// Room left for the player's control bar below an open menu.
+const CONTROLS_CLEARANCE = 120;
+const LAYERS_ICON = "M12 16.54l-7.37-5.73L3 12.07l9 7 9-7-1.63-1.27zM12 14l7.36-5.73L21 7l-9-7-9 7 1.63 1.27L12 14zm0-11.47L17.74 7 12 11.47 6.26 7 12 2.53z";
+
+export interface GearMenu {
+ refresh(): void;
+ unmount(): void;
+}
+
+// A Profile row in YouTube's settings menu, built from YouTube's own markup:
+// icon, label, and the current value, with YouTube drawing the chevron for
+// a row that opens a submenu.
+function ProfileRow({ name }: { name: string }) {
+ return (
+ <>
+
+
+
+ >
+ );
+}
+
+// The submenu, shaped like YouTube's Quality list: its header with a back
+// button, then radio rows YouTube draws the checkmark on. Clicks stop here:
+// YouTube handles its menu's classes itself, and since it never opened this
+// submenu, its "back" would close the whole menu.
+function ProfilePanel({ profiles, activeId, onPick, onBack }: { profiles: readonly Profile[]; activeId: string | null; onPick: (id: string | null) => void; onBack: () => void }) {
+ const option = (id: string | null, name: string) => (
+
+ );
+ return (
+ <>
+
+
+ >
+ );
+}
+
+// YouTube builds the menu on first open and may rebuild it, so the row is
+// (re)attached on each click of the gear button.
+export async function hookGearMenu(): Promise {
+ const button = await resolveSettingsButton();
+ if (!button.resolved) return;
+ const gear = button.element as HTMLElement;
+ let closeSubmenu: (() => void) | null = null;
+ let watchedMenu: HTMLElement | null = null;
+
+ // YouTube reopens its menu on whichever panel is in it, so the main one goes
+ // back as soon as the menu hides.
+ const hidden = new MutationObserver(() => {
+ if (watchedMenu && getComputedStyle(watchedMenu).display === "none") closeSubmenu?.();
+ });
+
+ const drawRow = async () => {
+ const parts = settingsMenu(gear);
+ if (!parts) return;
+ if (watchedMenu !== parts.menu) {
+ hidden.disconnect();
+ hidden.observe(parts.menu, { attributes: true, attributeFilter: ["style", "class", "aria-hidden"] });
+ watchedMenu = parts.menu;
+ }
+ let row = parts.mainList.querySelector(`#${ROW_ID}`);
+ if (!row) {
+ row = document.createElement("div");
+ row.id = ROW_ID;
+ row.className = "ytp-menuitem";
+ row.setAttribute("role", "menuitem");
+ row.setAttribute("aria-haspopup", "true");
+ row.tabIndex = 0;
+ row.addEventListener("click", (e) => {
+ e.stopPropagation();
+ void openSubmenu();
+ });
+ row.addEventListener("keydown", (e) => {
+ if (e.key !== "Enter" && e.key !== " ") return;
+ e.preventDefault();
+ void openSubmenu();
+ });
+ parts.mainList.append(row);
+ render( , row);
+ growToFit(parts.menu, parts.mainPanel, parts.mainList, gear);
+ return;
+ }
+ render( , row);
+ };
+
+ const openSubmenu = async () => {
+ const parts = settingsMenu(gear);
+ if (!parts || closeSubmenu) return;
+ const { menu, mainPanel } = parts;
+ const [profiles, active] = await Promise.all([getAllProfiles(), getActiveProfile()]);
+ const saved = { menu: menu.getAttribute("style"), panel: mainPanel.getAttribute("style"), list: parts.mainList.getAttribute("style") };
+
+ const panel = document.createElement("div");
+ panel.id = PANEL_ID;
+ panel.className = "ytp-panel";
+ const close = () => {
+ closeSubmenu = null;
+ render(null, panel);
+ panel.replaceWith(mainPanel);
+ restoreStyle(menu, saved.menu);
+ restoreStyle(mainPanel, saved.panel);
+ restoreStyle(parts.mainList, saved.list);
+ };
+ const pick = async (id: string | null) => {
+ await setActiveProfileId(id);
+ close();
+ await drawRow();
+ (mainPanel.querySelector(`#${ROW_ID}`) ?? mainPanel).focus();
+ };
+ render( void pick(id)} onBack={close} />, panel);
+ mainPanel.replaceWith(panel);
+ fitMenu(menu, panel, gear);
+ closeSubmenu = close;
+ panel.querySelector("[aria-checked=true]")?.focus();
+ };
+
+ const onGear = () => setTimeout(() => void drawRow(), 0);
+ gear.addEventListener("click", onGear);
+
+ return {
+ refresh: () => void drawRow(),
+ unmount() {
+ gear.removeEventListener("click", onGear);
+ hidden.disconnect();
+ closeSubmenu?.();
+ document.getElementById(ROW_ID)?.remove();
+ },
+ };
+}
+
+// YouTube sized the open menu before the row went in, so it would sit cut off
+// below the last row; the menu grows by what the row added, up to the room
+// the player leaves, as YouTube itself does when it opens with the row there.
+function growToFit(menu: HTMLElement, panel: HTMLElement, list: HTMLElement, gear: HTMLElement): void {
+ if (!list.style.height) return;
+ // The list is not clipped at its own height; the panel around it is.
+ const missing = list.scrollHeight - parseFloat(list.style.height);
+ if (missing <= 0) return;
+ const player = playerOf(gear) as HTMLElement;
+ const room = (player.clientHeight || window.innerHeight) - CONTROLS_CLEARANCE - menu.offsetHeight;
+ const extra = Math.min(missing, Math.max(room, 0));
+ for (const element of [list, panel, menu]) {
+ if (element.style.height) element.style.height = `${parseFloat(element.style.height) + extra}px`;
+ }
+}
+
+function restoreStyle(element: HTMLElement, style: string | null): void {
+ if (style === null) element.removeAttribute("style");
+ else element.setAttribute("style", style);
+}
+
+// YouTube sizes the menu to each panel with inline styles when it swaps
+// panels itself; a panel swapped in by hand has to do the same.
+function fitMenu(menu: HTMLElement, panel: HTMLElement, gear: HTMLElement): void {
+ const list = panel.querySelector(".ytp-panel-menu")!;
+ const header = panel.querySelector(".ytp-panel-header")!;
+ for (const element of [menu, panel, list]) {
+ element.style.width = "";
+ element.style.height = "";
+ }
+ menu.style.width = "max-content";
+ const width = Math.ceil(panel.getBoundingClientRect().width);
+ const player = playerOf(gear) as HTMLElement;
+ const room = (player.clientHeight || window.innerHeight) - CONTROLS_CLEARANCE - header.offsetHeight;
+ const listHeight = Math.min(list.scrollHeight, Math.max(room, 0));
+ list.style.height = `${listHeight}px`;
+ const height = header.offsetHeight + listHeight;
+ for (const element of [menu, panel]) {
+ element.style.width = `${width}px`;
+ element.style.height = `${height}px`;
+ }
+}
diff --git a/web-ext/features/profiles/importExport.test.ts b/web-ext/features/profiles/importExport.test.ts
new file mode 100644
index 00000000..0c6b2d53
--- /dev/null
+++ b/web-ext/features/profiles/importExport.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, spyOn, test } from "bun:test";
+import { exportProfile, parseProfileJson } from "./importExport";
+
+describe("parseProfileJson", () => {
+ test("accepts a bare name and primitives object", () => {
+ const result = parseProfileJson({ name: " Study ", primitives: { "watch.sidebar": { visible: false }, "home.thumbnails": { mode: "blur" } } });
+ expect(result).toEqual({ ok: true, name: "Study", primitives: { "watch.sidebar": { visible: false }, "home.thumbnails": { mode: "blur" } } });
+ });
+
+ test("skips unknown primitives and rejects bad values", () => {
+ expect(parseProfileJson({ name: "x", primitives: { "future.thing": { on: true } } })).toEqual({ ok: true, name: "x", primitives: {} });
+ expect(parseProfileJson({ name: "x", primitives: { "watch.layout": { value: "sideways" } } })).toMatchObject({ ok: false });
+ });
+
+ test("rejects missing name or malformed shapes", () => {
+ expect(parseProfileJson([])).toMatchObject({ ok: false });
+ expect(parseProfileJson({ primitives: {} })).toMatchObject({ ok: false });
+ expect(parseProfileJson({ name: "x", primitives: [] })).toMatchObject({ ok: false });
+ });
+});
+
+describe("exportProfile", () => {
+ test("keeps the download URL alive past the click", () => {
+ const create = spyOn(URL, "createObjectURL").mockReturnValue("blob:profile");
+ const revoke = spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
+ const click = spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
+ exportProfile({ id: "p", name: "Study", isPreset: false, createdAt: 0, primitives: {} });
+ expect(click).toHaveBeenCalledTimes(1);
+ expect(revoke).not.toHaveBeenCalled();
+ create.mockRestore();
+ revoke.mockRestore();
+ click.mockRestore();
+ });
+});
diff --git a/web-ext/features/profiles/importExport.ts b/web-ext/features/profiles/importExport.ts
new file mode 100644
index 00000000..ba340605
--- /dev/null
+++ b/web-ext/features/profiles/importExport.ts
@@ -0,0 +1,64 @@
+import { primitiveById } from "@/youtube/primitives";
+import type { Profile, ProfilePrimitiveConfig } from "./profiles";
+
+const SCHEMA = "https://toppings.enry.ch/profile-schema/v1.json";
+const MAX_NAME_LENGTH = 40;
+
+export function exportProfile(profile: Profile): void {
+ const json = JSON.stringify({ $schema: SCHEMA, name: profile.name, primitives: profile.primitives, exportedAt: new Date().toISOString() }, null, 2);
+ const url = URL.createObjectURL(new Blob([json], { type: "application/json" }));
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = `toppings-profile-${slugify(profile.name)}.json`;
+ link.click();
+ // Firefox starts the download after click() returns, and revoking the URL
+ // in the same task cancels it.
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+}
+
+export type ImportResult = { ok: true; name: string; primitives: ProfilePrimitiveConfig } | { ok: false; message: string };
+
+export async function importProfileFromFile(file: File): Promise {
+ if (!file.name.endsWith(".json") && file.type !== "application/json") return { ok: false, message: "File must be a .json file." };
+ let text: string;
+ try {
+ text = await file.text();
+ } catch {
+ return { ok: false, message: "Could not read the file." };
+ }
+ try {
+ return parseProfileJson(JSON.parse(text));
+ } catch {
+ return { ok: false, message: "File is not valid JSON." };
+ }
+}
+
+// Accepts a full export or a bare { name, primitives } object, so a
+// hand-written config imports without the $schema wrapper. Unknown primitive
+// ids are skipped rather than rejected: a file from a newer version must still
+// import on an older one, minus what it does not know.
+export function parseProfileJson(data: unknown): ImportResult {
+ if (typeof data !== "object" || data === null || Array.isArray(data)) return { ok: false, message: "Expected a JSON object at the top level." };
+ const { name, primitives } = data as Record;
+
+ if (typeof name !== "string" || !name.trim()) return { ok: false, message: 'Missing or empty "name" field.' };
+ if (name.trim().length > MAX_NAME_LENGTH) return { ok: false, message: `Profile name must be ${MAX_NAME_LENGTH} characters or fewer.` };
+ if (typeof primitives !== "object" || primitives === null || Array.isArray(primitives)) return { ok: false, message: '"primitives" must be an object.' };
+
+ const config: Record = {};
+ const errors: string[] = [];
+ for (const [id, value] of Object.entries(primitives)) {
+ const primitive = primitiveById(id);
+ if (!primitive) continue;
+ const parsed = primitive.parse(value);
+ if (parsed === undefined) errors.push(`"${id}" has an invalid value`);
+ else config[id] = parsed;
+ }
+ if (errors.length) return { ok: false, message: `Invalid primitive values:\n${errors.slice(0, 5).join("\n")}` };
+
+ return { ok: true, name: name.trim(), primitives: config as ProfilePrimitiveConfig };
+}
+
+function slugify(text: string): string {
+ return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, MAX_NAME_LENGTH);
+}
diff --git a/web-ext/features/profiles/index.test.ts b/web-ext/features/profiles/index.test.ts
new file mode 100644
index 00000000..bda5dfbe
--- /dev/null
+++ b/web-ext/features/profiles/index.test.ts
@@ -0,0 +1,196 @@
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import { profiles } from "./index";
+import { profilesSettings } from "./settings";
+import { createProfile, getActiveProfile, setActiveProfileId } from "./store";
+import { keybindings } from "@/kernel/keys";
+
+const watchPage = `
+
+
+
+
+
+
+
+
`;
+
+const watch = { name: "watch", videoId: "v", playlistId: null } as const;
+const home = { name: "home" } as const;
+const tick = () => new Promise((r) => setTimeout(r, 10));
+const sidebar = () => document.getElementById("secondary")!;
+const gearButton = () => document.querySelector(".ytp-settings-button")!;
+const popupContent = () => document.querySelector(".ytp-popup-content")!;
+const mainPanel = () => document.querySelector(".ytp-popup-content > .ytp-panel")!;
+const press = (key: string, init: KeyboardEventInit = {}) => document.body.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init }));
+
+let unmount: (() => void) | undefined | void;
+const mountOn = async (route: typeof watch | typeof home) => {
+ unmount = await profiles.mount({ signal: new AbortController().signal, route });
+};
+
+beforeEach(async () => {
+ await chrome.storage.sync.clear();
+ await chrome.storage.local.clear();
+ document.body.innerHTML = watchPage;
+});
+afterEach(() => {
+ unmount?.();
+ unmount = undefined;
+ document.body.innerHTML = "";
+});
+
+describe("profiles", () => {
+ test("applies the active preset on its route and restores on unmount", async () => {
+ await setActiveProfileId("preset:focus");
+ await mountOn(watch);
+ expect(sidebar().style.display).toBe("none");
+ expect(document.getElementById("comments")!.style.display).toBe("none");
+ unmount?.();
+ unmount = undefined;
+ expect(sidebar().style.display).toBe("");
+ });
+
+ test("follows profile changes while mounted", async () => {
+ await mountOn(watch);
+ expect(sidebar().style.display).toBe("");
+ await setActiveProfileId("preset:focus");
+ await tick();
+ expect(sidebar().style.display).toBe("none");
+ await setActiveProfileId(null);
+ await tick();
+ expect(sidebar().style.display).toBe("");
+ });
+
+ test("custom profiles apply too", async () => {
+ const mine = await createProfile({ name: "Mine", primitives: { "watch.sidebar": { visible: false } } });
+ await setActiveProfileId(mine.id);
+ await mountOn(watch);
+ expect(sidebar().style.display).toBe("none");
+ expect(document.getElementById("comments")!.style.display).toBe("");
+ });
+
+ test("B switches to Audio and back to the profile that was on before", async () => {
+ await setActiveProfileId("preset:focus");
+ await mountOn(watch);
+ press("b");
+ await tick();
+ expect((await getActiveProfile())?.id).toBe("preset:audio");
+ press("b");
+ await tick();
+ expect((await getActiveProfile())?.id).toBe("preset:focus");
+ });
+
+ test("a custom profile's shortcut toggles it from Default", async () => {
+ const mine = await createProfile({ name: "Work", primitives: { "watch.sidebar": { visible: false } } });
+ await keybindings.set({ [`profiles.${mine.id}`]: "Shift+W" });
+ await mountOn(watch);
+ press("W", { shiftKey: true });
+ await tick();
+ expect((await getActiveProfile())?.id).toBe(mine.id);
+ expect(sidebar().style.display).toBe("none");
+ press("W", { shiftKey: true });
+ await tick();
+ expect(await getActiveProfile()).toBeNull();
+ });
+
+ test("the cycle key walks default, presets, then custom profiles", async () => {
+ await chrome.storage.sync.set({ "settings:keybindings": { "profiles.cycle": "P" } });
+ await createProfile({ name: "Mine", primitives: {} });
+ await mountOn(watch);
+ await tick();
+ for (const expected of ["preset:audio", "preset:focus"]) {
+ press("p");
+ await tick();
+ expect((await getActiveProfile())?.id).toBe(expected);
+ }
+ press("p");
+ await tick();
+ expect((await getActiveProfile())?.name).toBe("Mine");
+ press("p");
+ await tick();
+ expect(await getActiveProfile()).toBeNull();
+ });
+
+ test("the gear menu gets a native Profile row showing the active profile", async () => {
+ await profilesSettings.set({ gearMenu: true });
+ await setActiveProfileId("preset:focus");
+ await mountOn(watch);
+ gearButton().click();
+ await tick();
+ const row = document.getElementById("tppng-profile-row")!;
+ expect(row.getAttribute("aria-haspopup")).toBe("true");
+ expect(row.querySelector(".ytp-menuitem-icon svg")).not.toBeNull();
+ expect(row.querySelector(".ytp-menuitem-label")!.textContent).toBe("Profile");
+ expect(row.querySelector(".ytp-menuitem-content")!.textContent).toBe("Focus");
+ });
+
+ test("the Profile submenu takes the main panel's place, and picking goes back with the new name", async () => {
+ await profilesSettings.set({ gearMenu: true });
+ await mountOn(watch);
+ gearButton().click();
+ await tick();
+ const main = mainPanel();
+ document.getElementById("tppng-profile-row")!.click();
+ await tick();
+ const panel = document.getElementById("tppng-profile-panel")!;
+ expect(panel.parentElement).toBe(popupContent());
+ expect(main.isConnected).toBe(false);
+ expect(panel.querySelector(".ytp-panel-title")!.textContent).toBe("Profile");
+ const options = [...panel.querySelectorAll("[role=menuitemradio]")];
+ expect(options.map((o) => o.textContent)).toEqual(["Default", "Audio", "Focus"]);
+ expect(options[0].getAttribute("aria-checked")).toBe("true");
+
+ options[1].click();
+ await tick();
+ expect((await getActiveProfile())?.id).toBe("preset:audio");
+ expect(main.parentElement).toBe(popupContent());
+ expect(document.getElementById("tppng-profile-panel")).toBeNull();
+ expect(document.querySelector("#tppng-profile-row .ytp-menuitem-content")!.textContent).toBe("Audio");
+ });
+
+ test("back puts YouTube's panel back at the size YouTube gave it", async () => {
+ await profilesSettings.set({ gearMenu: true });
+ await mountOn(watch);
+ gearButton().click();
+ await tick();
+ document.getElementById("tppng-profile-row")!.click();
+ await tick();
+ document.querySelector("#tppng-profile-panel .ytp-panel-back-button")!.click();
+ expect(mainPanel().getAttribute("style")).toBe("width: 303px; height: 305px;");
+ expect(document.querySelector(".ytp-settings-menu")!.getAttribute("style")).toBe("width: 303px; height: 305px;");
+ });
+
+ test("closing the menu from the submenu leaves YouTube's main panel in it for next time", async () => {
+ await profilesSettings.set({ gearMenu: true });
+ await mountOn(watch);
+ gearButton().click();
+ await tick();
+ const main = mainPanel();
+ document.getElementById("tppng-profile-row")!.click();
+ await tick();
+ document.querySelector(".ytp-settings-menu")!.style.display = "none";
+ await tick();
+ expect(main.parentElement).toBe(popupContent());
+ expect(document.getElementById("tppng-profile-panel")).toBeNull();
+ });
+
+ test("unmounting takes the row out and leaves YouTube's panel in place", async () => {
+ await profilesSettings.set({ gearMenu: true });
+ await mountOn(watch);
+ gearButton().click();
+ await tick();
+ const main = mainPanel();
+ document.getElementById("tppng-profile-row")!.click();
+ await tick();
+ unmount?.();
+ unmount = undefined;
+ expect(main.parentElement).toBe(popupContent());
+ expect(document.getElementById("tppng-profile-row")).toBeNull();
+ });
+
+ test("home route leaves watch primitives alone", async () => {
+ await setActiveProfileId("preset:focus");
+ await mountOn(home);
+ expect(sidebar().style.display).toBe("");
+ });
+});
diff --git a/web-ext/features/profiles/index.tsx b/web-ext/features/profiles/index.tsx
new file mode 100644
index 00000000..119b6b07
--- /dev/null
+++ b/web-ext/features/profiles/index.tsx
@@ -0,0 +1,109 @@
+import type { Feature } from "@/kernel/features";
+import { bindKeys } from "@/kernel/keys";
+import { mountInline } from "@/kernel/dom/mount";
+import { runPrimitives, type PrimitiveRun } from "@/kernel/primitives";
+import { PRIMITIVES } from "@/youtube/primitives";
+import { resolveRightControls } from "@/youtube/player";
+import { showToast } from "@/kernel/dom/toast";
+import { AudioButton, audioButtonHost, setAudioButtonState } from "./AudioButton";
+import { hookGearMenu, type GearMenu } from "./gearMenu";
+import { profileToggleKeys, profilesKeys } from "./keys";
+import { BUILT_IN_PRESETS, PRESET_AUDIO } from "./profiles";
+import { profilesSettings } from "./settings";
+import { getActiveProfile, getAllProfiles, getCustomProfiles, setActiveProfileId, subscribeProfiles, toggleProfile } from "./store";
+
+export const profiles: Feature = {
+ id: "profiles",
+ routes: ["watch", "playlist", "shorts", "home", "search", "other"],
+ async mount({ route }) {
+ const settings = await profilesSettings.get();
+
+ let run: PrimitiveRun | undefined;
+ let stopped = false;
+ let audio: AudioToggle | undefined;
+ let audioActive = false;
+ const start = async () => {
+ const active = await getActiveProfile();
+ if (stopped) return;
+ run?.stop();
+ run = runPrimitives(PRIMITIVES, { ...active?.primitives }, route.name);
+ audioActive = active?.id === PRESET_AUDIO.id;
+ audio?.show(audioActive);
+ };
+ await start();
+
+ let unbindToggles = () => {};
+ const bindToggles = async () => {
+ const all = await getAllProfiles();
+ if (stopped) return;
+ unbindToggles();
+ unbindToggles = bindKeys(profileToggleKeys(all), Object.fromEntries(all.map((profile) => [profile.id, () => void switchProfile(profile.id)])));
+ };
+ await bindToggles();
+ let gear: GearMenu | undefined;
+ const unsubscribe = subscribeProfiles(() => {
+ void start();
+ void bindToggles();
+ gear?.refresh();
+ });
+
+
+ const unbindKeys = bindKeys(profilesKeys, { cycle: () => void cycleProfile() });
+ // Not awaited: the control bar can render late, and nothing else waits on it.
+ if (route.name === "watch" && settings.audioButton) {
+ void mountAudioToggle().then((toggle) => {
+ if (stopped) return toggle?.unmount();
+ audio = toggle;
+ audio?.show(audioActive);
+ });
+ }
+ gear = route.name === "watch" && settings.gearMenu ? await hookGearMenu() : undefined;
+
+ return () => {
+ stopped = true;
+ unsubscribe();
+ unbindKeys();
+ unbindToggles();
+ audio?.unmount();
+ gear?.unmount();
+ run?.stop();
+ };
+ },
+};
+
+async function switchProfile(id: string): Promise {
+ const now = await toggleProfile(id);
+ showToast(`Profile: ${now?.name ?? "Default"}`);
+}
+
+interface AudioToggle {
+ show(active: boolean): void;
+ unmount(): void;
+}
+
+async function mountAudioToggle(): Promise {
+ const controls = await resolveRightControls();
+ if (!controls.resolved) return;
+ const host = audioButtonHost();
+ const button = mountInline(host, controls.element, , "prepend");
+ host.addEventListener("click", () => void switchProfile(PRESET_AUDIO.id));
+ return {
+ show(active) {
+ setAudioButtonState(host, active);
+ button.update( );
+ },
+ unmount: button.unmount,
+ };
+}
+
+async function cycleProfile(): Promise {
+ const cycle: Array<{ id: string | null; name: string }> = [
+ { id: null, name: "Default" },
+ ...BUILT_IN_PRESETS,
+ ...(await getCustomProfiles()),
+ ];
+ const current = (await getActiveProfile())?.id ?? null;
+ const next = cycle[(cycle.findIndex((p) => p.id === current) + 1) % cycle.length];
+ await setActiveProfileId(next.id);
+ showToast(`Profile: ${next.name}`);
+}
diff --git a/web-ext/features/profiles/keys.ts b/web-ext/features/profiles/keys.ts
new file mode 100644
index 00000000..43549f53
--- /dev/null
+++ b/web-ext/features/profiles/keys.ts
@@ -0,0 +1,25 @@
+import { defineKeys, type KeyGroup } from "@/kernel/keys";
+import { PRESET_AUDIO, type Profile } from "./profiles";
+
+export const profilesKeys = defineKeys({
+ id: "profiles",
+ title: "Profiles",
+ keys: {
+ cycle: { label: "Cycle profiles", description: "Default, then each preset, then your own profiles.", defaultBinding: "" },
+ },
+});
+
+// One toggle per profile, keyed by its id so a binding survives a rename.
+// Audio keeps the B it had as audio mode in 3.x.
+export function profileToggleKeys(profiles: readonly Profile[]): KeyGroup {
+ return defineKeys({
+ id: profilesKeys.id,
+ title: profilesKeys.title,
+ keys: Object.fromEntries(
+ profiles.map((profile) => [
+ profile.id,
+ { label: profile.name, description: `Switch to ${profile.name}; press again to go back.`, defaultBinding: profile.id === PRESET_AUDIO.id ? "B" : "" },
+ ]),
+ ),
+ });
+}
diff --git a/web-ext/features/profiles/profiles.ts b/web-ext/features/profiles/profiles.ts
new file mode 100644
index 00000000..20f0b40e
--- /dev/null
+++ b/web-ext/features/profiles/profiles.ts
@@ -0,0 +1,54 @@
+import type { Layout, Thumbnails, Visibility, Visuals } from "@/youtube/primitives";
+
+export type { PlayerLayout, ThumbnailMode, VisualsMode } from "@/youtube/primitives";
+
+// Keyed by primitive id. An absent key means "leave YouTube alone", which is
+// not the same as a default value and is what lets profiles stay small.
+export interface ProfilePrimitiveConfig {
+ "watch.layout"?: Layout;
+ "watch.visuals"?: Visuals;
+ "watch.sidebar"?: Visibility;
+ "watch.comments"?: Visibility;
+ "watch.endCards"?: Visibility;
+ "home.thumbnails"?: Thumbnails;
+ "home.feed"?: Visibility;
+ "home.shorts"?: Visibility;
+ "search.thumbnails"?: Thumbnails;
+ "search.metadata"?: Visibility;
+ "search.shorts"?: Visibility;
+ "shorts.shelf"?: Visibility;
+}
+
+export interface Profile {
+ id: string;
+ name: string;
+ isPreset: boolean;
+ createdAt: number;
+ primitives: ProfilePrimitiveConfig;
+}
+
+// Presets are constants, never stored, so editing one here reaches existing
+// users on update with no migration; that is also why the UI cannot edit them.
+export const PRESET_AUDIO: Profile = {
+ id: "preset:audio",
+ name: "Audio",
+ isPreset: true,
+ createdAt: 0,
+ primitives: {
+ "watch.visuals": { value: "visualizer" },
+ },
+};
+
+export const PRESET_FOCUS: Profile = {
+ id: "preset:focus",
+ name: "Focus",
+ isPreset: true,
+ createdAt: 0,
+ primitives: {
+ "watch.sidebar": { visible: false },
+ "watch.comments": { visible: false },
+ "watch.endCards": { visible: false },
+ },
+};
+
+export const BUILT_IN_PRESETS: readonly Profile[] = [PRESET_AUDIO, PRESET_FOCUS];
diff --git a/web-ext/features/profiles/settings.ts b/web-ext/features/profiles/settings.ts
new file mode 100644
index 00000000..5f9e8c51
--- /dev/null
+++ b/web-ext/features/profiles/settings.ts
@@ -0,0 +1,21 @@
+import { defineSettings } from "@/kernel/settings";
+
+export interface ProfilesSettings {
+ // Extra places to switch profiles from; the popup always has a switcher.
+ gearMenu: boolean;
+ // A player button that toggles the Audio profile.
+ audioButton: boolean;
+}
+
+export const profilesSettings = defineSettings(
+ "profiles-surfaces",
+ { gearMenu: false, audioButton: true },
+ {
+ legacy: (store) => {
+ const ui = store.ui as { gearMenuEnabled?: boolean } | undefined;
+ const audioMode = (store.preferences as { watch?: { audioMode?: { isEnabled?: boolean } } } | undefined)?.watch?.audioMode;
+ if (!ui && !audioMode) return undefined;
+ return { gearMenu: ui?.gearMenuEnabled, audioButton: audioMode?.isEnabled };
+ },
+ },
+);
diff --git a/web-ext/features/profiles/store.ts b/web-ext/features/profiles/store.ts
new file mode 100644
index 00000000..3a7741a1
--- /dev/null
+++ b/web-ext/features/profiles/store.ts
@@ -0,0 +1,77 @@
+import { defineSettings } from "@/kernel/settings";
+import { CHROME_STORAGE_LOCAL_KEY } from "@/lib/storageKeys";
+import { BUILT_IN_PRESETS, type Profile } from "./profiles";
+
+interface ProfileStore {
+ // null runs on individual preferences rather than on a default profile.
+ activeProfileId: string | null;
+ // Custom profiles only; presets are constants.
+ profiles: Profile[];
+ // What a profile's shortcut returns to when pressed again.
+ previousProfileId: string | null;
+}
+
+export const profileStore = defineSettings(
+ "profiles",
+ { activeProfileId: null, profiles: [], previousProfileId: null },
+ {
+ area: "local",
+ legacy: (store) => store[CHROME_STORAGE_LOCAL_KEY.PROFILE_STORE] as Partial | undefined,
+ },
+);
+
+const byCreation = (profiles: Profile[]) => [...profiles].sort((a, b) => a.createdAt - b.createdAt);
+
+export async function getCustomProfiles(): Promise {
+ return byCreation((await profileStore.get()).profiles);
+}
+
+export async function getAllProfiles(): Promise {
+ return [...BUILT_IN_PRESETS, ...(await getCustomProfiles())];
+}
+
+export async function getProfileById(id: string): Promise {
+ return BUILT_IN_PRESETS.find((p) => p.id === id) ?? (await getCustomProfiles()).find((p) => p.id === id);
+}
+
+export async function getActiveProfile(): Promise {
+ const { activeProfileId } = await profileStore.get();
+ return activeProfileId ? ((await getProfileById(activeProfileId)) ?? null) : null;
+}
+
+export function setActiveProfileId(activeProfileId: string | null): Promise {
+ return profileStore.set({ activeProfileId });
+}
+
+// A profile's shortcut switches to it, and pressing it again goes back to
+// whatever was active before, so Audio can be flipped on and off over Focus.
+export async function toggleProfile(id: string): Promise {
+ const { activeProfileId, previousProfileId } = await profileStore.get();
+ const next = activeProfileId === id ? previousProfileId : id;
+ await profileStore.set({ activeProfileId: next, previousProfileId: activeProfileId === id ? null : activeProfileId });
+ return next ? ((await getProfileById(next)) ?? null) : null;
+}
+
+export async function createProfile(data: Pick): Promise {
+ const profile: Profile = { ...data, id: crypto.randomUUID(), isPreset: false, createdAt: Date.now() };
+ const { profiles } = await profileStore.get();
+ await profileStore.set({ profiles: [...profiles, profile] });
+ return profile;
+}
+
+// A write targeting a preset is ignored rather than thrown, so the options
+// page can keep one edit path for every profile.
+export async function updateProfile(id: string, patch: Partial>): Promise {
+ const { profiles } = await profileStore.get();
+ await profileStore.set({ profiles: profiles.map((p) => (p.id === id ? { ...p, ...patch } : p)) });
+}
+
+export async function deleteProfile(id: string): Promise {
+ const { profiles, activeProfileId } = await profileStore.get();
+ await profileStore.set({
+ profiles: profiles.filter((p) => p.id !== id),
+ activeProfileId: activeProfileId === id ? null : activeProfileId,
+ });
+}
+
+export const subscribeProfiles = (listener: () => void) => profileStore.subscribe(listener);
diff --git a/web-ext/features/schedules/background.test.ts b/web-ext/features/schedules/background.test.ts
new file mode 100644
index 00000000..90ec25a9
--- /dev/null
+++ b/web-ext/features/schedules/background.test.ts
@@ -0,0 +1,94 @@
+import { beforeEach, describe, expect, test } from "bun:test";
+import { appSettings } from "@/app/settings";
+import { profileStore, setActiveProfileId } from "@/features/profiles/store";
+import { skipInCharge, tick } from "./background";
+import { schedulesStore, type Schedule } from "./settings";
+import { scheduleState } from "./state";
+
+const badge = { text: "", title: "" };
+const alarms = new Map();
+Object.assign(chrome, {
+ alarms: {
+ create: async (name: string, info: { when?: number; periodInMinutes?: number }) => void alarms.set(name, info),
+ get: async (name: string) => (alarms.has(name) ? { name, ...alarms.get(name) } : undefined),
+ },
+ action: {
+ setBadgeText: async ({ text }: { text: string }) => void (badge.text = text),
+ setBadgeBackgroundColor: async () => {},
+ setTitle: async ({ title }: { title: string }) => void (badge.title = title),
+ },
+});
+
+// 2026-09-28 is a Monday.
+const at = (hhmm: string, day = 28) => {
+ const [h, m] = hhmm.split(":").map(Number);
+ return new Date(2026, 8, day, h, m);
+};
+const work: Schedule = { id: "work", profileId: "preset:audio", enabled: true, rule: { kind: "time", days: [1, 2, 3, 4, 5], start: "09:00", end: "18:00" } };
+const active = async () => (await profileStore.get()).activeProfileId;
+
+beforeEach(async () => {
+ await chrome.storage.local.clear();
+ await chrome.storage.sync.clear();
+ await schedulesStore.set({ schedules: [work] });
+ await setActiveProfileId("preset:focus");
+});
+
+describe("schedule runner", () => {
+ test("switches on at the start and back to the earlier profile at the end", async () => {
+ await tick(at("09:00"));
+ expect(await active()).toBe("preset:audio");
+ expect((await scheduleState.get()).notice?.text).toBe("Schedule · Audio until 18:00");
+ expect(badge.text).toBe("A");
+ expect(badge.title).toBe("Toppings · Audio on by schedule until 18:00");
+ await tick(at("18:00"));
+ expect(await active()).toBe("preset:focus");
+ expect((await scheduleState.get()).notice?.text).toBe("Schedule ended · Focus");
+ expect(badge.text).toBe("");
+ });
+
+ test("a profile picked mid-window is left alone when the window ends", async () => {
+ await tick(at("09:00"));
+ await setActiveProfileId(null);
+ await tick(at("11:00"));
+ expect(await active()).toBeNull();
+ await tick(at("18:00"));
+ expect(await active()).toBeNull();
+ });
+
+ test("sets an alarm for the next change, so it lands on time rather than at the next minute's tick", async () => {
+ await tick(at("08:00"));
+ expect(alarms.get("schedules-change")?.when).toBe(at("08:55").getTime());
+ await tick(at("08:56"));
+ expect(alarms.get("schedules-change")?.when).toBe(at("09:00").getTime());
+ });
+
+ test("warns once ahead of a start, counting down on the badge", async () => {
+ await tick(at("08:57"));
+ const first = (await scheduleState.get()).notice;
+ expect(first?.text).toBe("Audio turns on at 09:00");
+ expect(badge.text).toBe("3m");
+ await tick(at("08:58"));
+ expect((await scheduleState.get()).notice).toEqual(first);
+ expect(badge.text).toBe("2m");
+ expect(await active()).toBe("preset:focus");
+ });
+
+ test("a skipped schedule hands back the profile and stays out until its window ends", async () => {
+ await tick(at("09:00"));
+ await skipInCharge(at("10:00"));
+ await tick(at("10:00"));
+ expect(await active()).toBe("preset:focus");
+ await tick(at("11:00"));
+ expect(await active()).toBe("preset:focus");
+ await tick(at("09:00", 29));
+ expect(await active()).toBe("preset:audio");
+ });
+
+ test("does nothing while Toppings is switched off", async () => {
+ await appSettings.set({ enabled: false });
+ await tick(at("09:00"));
+ expect(await active()).toBe("preset:focus");
+ expect(badge.text).toBe("");
+ });
+});
diff --git a/web-ext/features/schedules/background.ts b/web-ext/features/schedules/background.ts
new file mode 100644
index 00000000..662fa127
--- /dev/null
+++ b/web-ext/features/schedules/background.ts
@@ -0,0 +1,157 @@
+import { appSettings } from "@/app/settings";
+import { readPositionInBackground } from "@/lib/location";
+import { getProfileById, profileStore, setActiveProfileId } from "@/features/profiles/store";
+import { clock, currentEnd, isInWindow, nextChangeAt, placesHere, scheduleInCharge, upcomingChanges } from "./rules";
+import { schedulesStore, type Place, type Schedule } from "./settings";
+import { scheduleState, type ScheduleState } from "./state";
+import { skipSchedule } from "./messages";
+
+const TICK_ALARM = "schedules";
+const CHANGE_ALARM = "schedules-change";
+const LOCATION_ALARM = "schedules-location";
+const LOCATION_EVERY_MINUTES = 5;
+// A fix vaguer than this cannot tell one building from the next.
+const MAX_ACCURACY_METERS = 1000;
+const LEAD_MINUTES = 5;
+const BADGE_COLOR = "#c2531e";
+
+let ticking = Promise.resolve();
+
+export function runSchedules(): void {
+ // Created once: re-creating on every wake of the background would restart
+ // the countdown, and a background woken often would never see it fire.
+ void ensureAlarm(TICK_ALARM, 1);
+ void ensureAlarm(LOCATION_ALARM, LOCATION_EVERY_MINUTES);
+ chrome.alarms.onAlarm.addListener((alarm) => {
+ if (alarm.name === TICK_ALARM || alarm.name === CHANGE_ALARM) queueTick();
+ if (alarm.name === LOCATION_ALARM) void checkLocation();
+ });
+ schedulesStore.subscribe(() => {
+ queueTick();
+ void checkLocation();
+ });
+ appSettings.subscribe(queueTick);
+ skipSchedule.handle(async () => {
+ await skipInCharge(new Date());
+ await queueTick();
+ });
+ queueTick();
+ void checkLocation();
+}
+
+async function ensureAlarm(name: string, periodInMinutes: number): Promise {
+ if (!(await chrome.alarms.get(name))) await chrome.alarms.create(name, { periodInMinutes });
+}
+
+// Only while a place schedule exists, so nobody else is ever asked where they are.
+async function checkLocation(): Promise {
+ const { schedules, places } = await schedulesStore.get();
+ if (!schedules.some((schedule) => schedule.enabled && schedule.rule.kind === "place")) return;
+ try {
+ const here = await readPositionInBackground();
+ if (here.accuracyMeters > MAX_ACCURACY_METERS) throw new Error("Location too imprecise to tell places apart");
+ const { atPlaces } = await scheduleState.get();
+ await scheduleState.set({ atPlaces: placesHere(places, here, atPlaces), locationError: null });
+ } catch (error) {
+ await scheduleState.set({ locationError: error instanceof Error ? error.message : String(error) });
+ }
+ await queueTick();
+}
+
+// One tick at a time: each reads state, decides, then writes it back.
+function queueTick(): Promise {
+ ticking = ticking.then(() => tick(new Date())).catch((error) => console.error("[toppings] schedules", error));
+ return ticking;
+}
+
+export async function skipInCharge(now: Date): Promise {
+ const [{ schedules }, { inCharge }] = await Promise.all([schedulesStore.get(), scheduleState.get()]);
+ const schedule = schedules.find((s) => s.id === inCharge);
+ if (!schedule) return;
+ const until = schedule.rule.kind === "time" ? (currentEnd(schedule.rule, now)?.getTime() ?? null) : null;
+ await scheduleState.set({ skipped: { id: schedule.id, until } });
+}
+
+export async function tick(now: Date): Promise {
+ const [app, { schedules, places }, state, { activeProfileId }] = await Promise.all([appSettings.get(), schedulesStore.get(), scheduleState.get(), profileStore.get()]);
+ if (!app.enabled) {
+ if (state.status) await scheduleState.set({ status: "" });
+ return showBadge("", "");
+ }
+
+ const byId = (id: string | null) => schedules.find((schedule) => schedule.id === id) ?? null;
+ const skippedSchedule = byId(state.skipped?.id ?? null);
+ const skipHolds = skippedSchedule && (state.skipped?.until != null ? now.getTime() < state.skipped.until : isInWindow(skippedSchedule, now, state.atPlaces));
+ const skipped = skipHolds ? skippedSchedule.id : null;
+ const current = byId(state.inCharge);
+ const next = scheduleInCharge(schedules, now, state.atPlaces, skipped);
+ const patch: Partial = { skipped: skipped ? state.skipped : null };
+ let active = activeProfileId;
+ let profileBefore = state.profileBefore;
+ const say = (text: string) => void (patch.notice = { id: now.getTime(), text });
+
+ if (state.inCharge && state.inCharge !== next?.id) {
+ // A profile the user picked mid-window stays; only the schedule's own is undone.
+ if (current && active === current.profileId) {
+ active = profileBefore;
+ say(`Schedule ended · ${await nameOf(active)}`);
+ }
+ profileBefore = null;
+ }
+ if (next && next.id !== state.inCharge) {
+ profileBefore = active;
+ active = next.profileId;
+ say(`Schedule · ${await nameOf(active)} ${until(next, now, places)}`);
+ }
+ patch.inCharge = next?.id ?? null;
+ patch.profileBefore = profileBefore;
+ if (active !== activeProfileId) await setActiveProfileId(active);
+
+ const upcoming = upcomingChanges(schedules, now, LEAD_MINUTES, next);
+ const announced = state.announced.filter((key) => Number(key.split("@")[1]) > now.getTime());
+ for (const change of upcoming) {
+ const key = `${change.schedule.id}:${change.change}@${change.at.getTime()}`;
+ if (announced.includes(key)) continue;
+ announced.push(key);
+ say(`${await nameOf(change.schedule.profileId)} turns ${change.change === "start" ? "on" : "off"} at ${clock(change.at)}`);
+ }
+ patch.announced = announced;
+
+ const soon = upcoming[0];
+ const status = soon
+ ? `${await nameOf(soon.schedule.profileId)} turns ${soon.change === "start" ? "on" : "off"} at ${clock(soon.at)}`
+ : next
+ ? `${await nameOf(next.profileId)} on by schedule ${until(next, now, places)}`
+ : "";
+ patch.status = status;
+ await scheduleState.set(patch);
+
+ // The one-minute tick is a safety net; this lands on the change itself.
+ const change = nextChangeAt(schedules, now, LEAD_MINUTES);
+ if (change) await chrome.alarms.create(CHANGE_ALARM, { when: change.getTime() });
+
+ const badge = soon ? `${Math.max(1, Math.ceil((soon.at.getTime() - now.getTime()) / 60_000))}m` : next ? (await nameOf(next.profileId)).charAt(0).toUpperCase() : "";
+ await showBadge(badge, status);
+}
+
+function until(schedule: Schedule, now: Date, places: readonly Place[]): string {
+ if (schedule.rule.kind === "time") {
+ const end = currentEnd(schedule.rule, now);
+ return end ? `until ${clock(end)}` : "";
+ }
+ const placeId = schedule.rule.placeId;
+ return `while at ${places.find((place) => place.id === placeId)?.name ?? "this place"}`;
+}
+
+async function nameOf(profileId: string | null): Promise {
+ return profileId ? ((await getProfileById(profileId))?.name ?? "Default") : "Default";
+}
+
+async function showBadge(text: string, status: string): Promise {
+ // Firefox still ships MV2, where the toolbar button is browserAction.
+ // @ts-expect-error chrome-types only declares MV3
+ const action: typeof chrome.action = chrome.action ?? chrome.browserAction;
+ await action.setBadgeText({ text });
+ await action.setBadgeBackgroundColor({ color: BADGE_COLOR });
+ await action.setTitle({ title: status ? `Toppings · ${status}` : "Toppings" });
+}
diff --git a/web-ext/features/schedules/index.ts b/web-ext/features/schedules/index.ts
new file mode 100644
index 00000000..84e0deed
--- /dev/null
+++ b/web-ext/features/schedules/index.ts
@@ -0,0 +1,22 @@
+import type { Feature } from "@/kernel/features";
+import { showToast } from "@/kernel/dom/toast";
+import { scheduleState } from "./state";
+
+const NOTICE_MS = 4000;
+
+// Tells whoever is watching that a schedule switched, or is about to, so a
+// profile that changes on its own is never a mystery. Only notices that
+// arrive while mounted are shown: an old one would be news about the past.
+export const scheduleNotices: Feature = {
+ id: "schedule-notices",
+ routes: ["watch", "playlist", "shorts", "home", "search", "other"],
+ mount() {
+ let seen: number | null = null;
+ void scheduleState.get().then((state) => (seen ??= state.notice?.id ?? null));
+ return scheduleState.subscribe(({ notice }) => {
+ if (!notice || notice.id === seen) return;
+ seen = notice.id;
+ if (document.visibilityState === "visible") showToast(notice.text, NOTICE_MS);
+ });
+ },
+};
diff --git a/web-ext/features/schedules/messages.ts b/web-ext/features/schedules/messages.ts
new file mode 100644
index 00000000..204df3b9
--- /dev/null
+++ b/web-ext/features/schedules/messages.ts
@@ -0,0 +1,4 @@
+import { defineMessage } from "@/kernel/messaging";
+
+// Only the background changes schedule state, so the popup asks it to.
+export const skipSchedule = defineMessage("skip-schedule");
diff --git a/web-ext/features/schedules/rules.test.ts b/web-ext/features/schedules/rules.test.ts
new file mode 100644
index 00000000..4d3bbfb1
--- /dev/null
+++ b/web-ext/features/schedules/rules.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, test } from "bun:test";
+import { clock, currentEnd, isInWindow, nextChangeAt, nextStart, placesHere, scheduleInCharge, upcomingChanges } from "./rules";
+import type { Place, Schedule, TimeRule } from "./settings";
+
+// 2026-09-28 is a Monday; dates are local, like the rules.
+const on = (day: number, hhmm: string) => {
+ const [h, m] = hhmm.split(":").map(Number);
+ return new Date(2026, 8, day, h, m);
+};
+const weekdays: TimeRule = { kind: "time", days: [1, 2, 3, 4, 5], start: "09:00", end: "18:00" };
+const nights: TimeRule = { kind: "time", days: [5], start: "22:00", end: "02:00" };
+const schedule = (id: string, rule: Schedule["rule"], enabled = true): Schedule => ({ id, profileId: `profile-${id}`, rule, enabled });
+
+describe("time rules", () => {
+ test("a weekday window covers its hours on its days only", () => {
+ expect(isInWindow(schedule("w", weekdays), on(28, "09:00"), [])).toBe(true);
+ expect(isInWindow(schedule("w", weekdays), on(28, "17:59"), [])).toBe(true);
+ expect(isInWindow(schedule("w", weekdays), on(28, "18:00"), [])).toBe(false);
+ expect(isInWindow(schedule("w", weekdays), on(27, "12:00"), [])).toBe(false);
+ });
+
+ test("an overnight window belongs to the day it starts", () => {
+ expect(isInWindow(schedule("n", nights), on(25, "23:00"), [])).toBe(true);
+ expect(isInWindow(schedule("n", nights), on(26, "01:30"), [])).toBe(true);
+ expect(isInWindow(schedule("n", nights), on(26, "23:00"), [])).toBe(false);
+ expect(currentEnd(nights, on(25, "23:00"))).toEqual(on(26, "02:00"));
+ });
+
+ test("the next start skips to the next listed day", () => {
+ expect(nextStart(weekdays, on(26, "12:00"))).toEqual(on(28, "09:00"));
+ expect(nextStart(weekdays, on(28, "08:00"))).toEqual(on(28, "09:00"));
+ expect(nextStart(weekdays, on(28, "10:00"))).toEqual(on(29, "09:00"));
+ });
+
+ test("a disabled schedule is never in window", () => {
+ expect(isInWindow(schedule("w", weekdays, false), on(28, "10:00"), [])).toBe(false);
+ });
+});
+
+describe("which schedule is in charge", () => {
+ test("list order is priority, and a skipped schedule gives way until its window ends", () => {
+ const first = schedule("a", weekdays);
+ const second = schedule("b", { ...weekdays, start: "08:00" });
+ expect(scheduleInCharge([first, second], on(28, "10:00"), [], null)?.id).toBe("a");
+ expect(scheduleInCharge([first, second], on(28, "10:00"), [], "a")?.id).toBe("b");
+ });
+
+ test("a place schedule is in charge while there", () => {
+ const work = schedule("p", { kind: "place", placeId: "work" });
+ expect(scheduleInCharge([work], on(28, "10:00"), ["work"], null)?.id).toBe("p");
+ expect(scheduleInCharge([work], on(28, "10:00"), [], null)).toBeNull();
+ });
+});
+
+describe("heads-up", () => {
+ test("a start or end within the lead is announced", () => {
+ const work = schedule("w", weekdays);
+ expect(upcomingChanges([work], on(28, "08:56"), 5, null).map((c) => [c.change, clock(c.at)])).toEqual([["start", "09:00"]]);
+ expect(upcomingChanges([work], on(28, "08:50"), 5, null)).toEqual([]);
+ expect(upcomingChanges([work], on(28, "17:57"), 5, work).map((c) => [c.change, clock(c.at)])).toEqual([["end", "18:00"]]);
+ });
+});
+
+describe("next change", () => {
+ test("is the next heads-up, start or end, whichever comes first", () => {
+ const work = schedule("w", weekdays);
+ expect(nextChangeAt([work], on(28, "08:00"), 5)).toEqual(on(28, "08:55"));
+ expect(nextChangeAt([work], on(28, "08:56"), 5)).toEqual(on(28, "09:00"));
+ expect(nextChangeAt([work], on(28, "12:00"), 5)).toEqual(on(28, "17:55"));
+ expect(nextChangeAt([], on(28, "12:00"), 5)).toBeNull();
+ });
+});
+
+describe("places", () => {
+ const office: Place = { id: "work", name: "Work", latitude: 52.52, longitude: 13.405, radiusMeters: 200 };
+ test("inside the radius counts as there; leaving needs a margin past it", () => {
+ const near = { latitude: 52.5215, longitude: 13.405 };
+ const edge = { latitude: 52.5222, longitude: 13.405 };
+ expect(placesHere([office], near, [])).toEqual(["work"]);
+ expect(placesHere([office], edge, [])).toEqual([]);
+ expect(placesHere([office], edge, ["work"])).toEqual(["work"]);
+ });
+});
diff --git a/web-ext/features/schedules/rules.ts b/web-ext/features/schedules/rules.ts
new file mode 100644
index 00000000..ea58a057
--- /dev/null
+++ b/web-ext/features/schedules/rules.ts
@@ -0,0 +1,114 @@
+import type { Place, Schedule, TimeRule } from "./settings";
+
+const MINUTE = 60_000;
+const DAY = 24 * 60 * MINUTE;
+
+const minutesOf = (hhmm: string) => {
+ const [h, m] = hhmm.split(":").map(Number);
+ return h * 60 + m;
+};
+
+function at(day: Date, hhmm: string): Date {
+ const date = new Date(day);
+ date.setHours(0, minutesOf(hhmm), 0, 0);
+ return date;
+}
+
+const overnight = (rule: TimeRule) => minutesOf(rule.end) <= minutesOf(rule.start);
+
+// The window that contains now, if any, as [start, end).
+function windowAround(rule: TimeRule, now: Date): { start: Date; end: Date } | null {
+ for (const back of [0, 1]) {
+ const day = new Date(now.getTime() - back * DAY);
+ if (!rule.days.includes(day.getDay() as TimeRule["days"][number])) continue;
+ const start = at(day, rule.start);
+ const end = at(new Date(day.getTime() + (overnight(rule) ? DAY : 0)), rule.end);
+ if (now >= start && now < end) return { start, end };
+ }
+ return null;
+}
+
+export function nextStart(rule: TimeRule, now: Date): Date | null {
+ for (let ahead = 0; ahead <= 7; ahead++) {
+ const day = new Date(now.getTime() + ahead * DAY);
+ if (!rule.days.includes(day.getDay() as TimeRule["days"][number])) continue;
+ const start = at(day, rule.start);
+ if (start > now) return start;
+ }
+ return null;
+}
+
+export function currentEnd(rule: TimeRule, now: Date): Date | null {
+ return windowAround(rule, now)?.end ?? null;
+}
+
+export function distanceMeters(a: { latitude: number; longitude: number }, b: { latitude: number; longitude: number }): number {
+ const rad = (deg: number) => (deg * Math.PI) / 180;
+ const dLat = rad(b.latitude - a.latitude);
+ const dLon = rad(b.longitude - a.longitude);
+ const h = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a.latitude)) * Math.cos(rad(b.latitude)) * Math.sin(dLon / 2) ** 2;
+ return 2 * 6_371_000 * Math.asin(Math.sqrt(h));
+}
+
+// Leaving takes a margin past the radius, so a position that wobbles at the
+// edge does not flip the profile back and forth.
+const LEAVE_MARGIN = 1.25;
+
+export function placesHere(places: readonly Place[], here: { latitude: number; longitude: number }, wereHere: readonly string[]): string[] {
+ return places
+ .filter((place) => {
+ const distance = distanceMeters(place, here);
+ return distance <= place.radiusMeters * (wereHere.includes(place.id) ? LEAVE_MARGIN : 1);
+ })
+ .map((place) => place.id);
+}
+
+export function isInWindow(schedule: Schedule, now: Date, atPlaces: readonly string[]): boolean {
+ if (!schedule.enabled) return false;
+ return schedule.rule.kind === "time" ? windowAround(schedule.rule, now) !== null : atPlaces.includes(schedule.rule.placeId);
+}
+
+// The first enabled schedule in window wins, so list order is priority.
+export function scheduleInCharge(schedules: readonly Schedule[], now: Date, atPlaces: readonly string[], skipped: string | null): Schedule | null {
+ return schedules.find((schedule) => schedule.id !== skipped && isInWindow(schedule, now, atPlaces)) ?? null;
+}
+
+export interface Upcoming {
+ schedule: Schedule;
+ change: "start" | "end";
+ at: Date;
+}
+
+// Time schedules that start or end within the lead, for the heads-up; places
+// cannot be foreseen.
+export function upcomingChanges(schedules: readonly Schedule[], now: Date, leadMinutes: number, inCharge: Schedule | null): Upcoming[] {
+ const horizon = new Date(now.getTime() + leadMinutes * MINUTE);
+ const changes: Upcoming[] = [];
+ for (const schedule of schedules) {
+ if (!schedule.enabled || schedule.rule.kind !== "time") continue;
+ if (schedule.id === inCharge?.id) {
+ const end = currentEnd(schedule.rule, now);
+ if (end && end <= horizon) changes.push({ schedule, change: "end", at: end });
+ } else if (!inCharge) {
+ const start = nextStart(schedule.rule, now);
+ if (start && start <= horizon) changes.push({ schedule, change: "start", at: start });
+ }
+ }
+ return changes;
+}
+
+// The next moment anything happens: a heads-up, a start or an end.
+export function nextChangeAt(schedules: readonly Schedule[], now: Date, leadMinutes: number): Date | null {
+ const lead = leadMinutes * MINUTE;
+ const moments: number[] = [];
+ for (const schedule of schedules) {
+ if (!schedule.enabled || schedule.rule.kind !== "time") continue;
+ const start = nextStart(schedule.rule, now);
+ const end = currentEnd(schedule.rule, now);
+ for (const moment of [start, end]) if (moment) moments.push(moment.getTime() - lead, moment.getTime());
+ }
+ const future = moments.filter((moment) => moment > now.getTime());
+ return future.length ? new Date(Math.min(...future)) : null;
+}
+
+export const clock = (date: Date) => `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
diff --git a/web-ext/features/schedules/settings.ts b/web-ext/features/schedules/settings.ts
new file mode 100644
index 00000000..d5c5f484
--- /dev/null
+++ b/web-ext/features/schedules/settings.ts
@@ -0,0 +1,40 @@
+import { defineSettings } from "@/kernel/settings";
+
+// 0 is Sunday, as in Date#getDay.
+export type Weekday = 0 | 1 | 2 | 3 | 4 | 5 | 6;
+
+// "HH:MM", 24-hour. A window whose end is not after its start runs overnight
+// and belongs to the day it starts on.
+export interface TimeRule {
+ kind: "time";
+ days: Weekday[];
+ start: string;
+ end: string;
+}
+
+export interface PlaceRule {
+ kind: "place";
+ placeId: string;
+}
+
+export interface Schedule {
+ id: string;
+ profileId: string;
+ rule: TimeRule | PlaceRule;
+ enabled: boolean;
+}
+
+export interface Place {
+ id: string;
+ name: string;
+ latitude: number;
+ longitude: number;
+ radiusMeters: number;
+}
+
+// Local, never synced: places are where the user lives and works.
+export const schedulesStore = defineSettings<{ schedules: Schedule[]; places: Place[] }>(
+ "schedules",
+ { schedules: [], places: [] },
+ { area: "local" },
+);
diff --git a/web-ext/features/schedules/state.ts b/web-ext/features/schedules/state.ts
new file mode 100644
index 00000000..7c477539
--- /dev/null
+++ b/web-ext/features/schedules/state.ts
@@ -0,0 +1,29 @@
+import { defineSettings } from "@/kernel/settings";
+
+export interface ScheduleNotice {
+ id: number;
+ text: string;
+}
+
+// Written only by the background runner; the popup and YouTube tabs read it.
+export interface ScheduleState {
+ inCharge: string | null;
+ // What the profile goes back to when the schedule in charge ends.
+ profileBefore: string | null;
+ // Skipped from the popup for the window it was in: until that window's end
+ // for a time schedule, or until leaving for a place (until is then null).
+ skipped: { id: string; until: number | null } | null;
+ atPlaces: string[];
+ // Why places could not be checked, for the options page and popup to say.
+ locationError: string | null;
+ announced: string[];
+ // A sentence for the user, shown once by the YouTube tab they are on.
+ notice: ScheduleNotice | null;
+ status: string;
+}
+
+export const scheduleState = defineSettings(
+ "schedule-state",
+ { inCharge: null, profileBefore: null, skipped: null, atPlaces: [], locationError: null, announced: [], notice: null, status: "" },
+ { area: "local" },
+);
diff --git a/web-ext/features/segments/Markers.test.ts b/web-ext/features/segments/Markers.test.ts
new file mode 100644
index 00000000..5f897a53
--- /dev/null
+++ b/web-ext/features/segments/Markers.test.ts
@@ -0,0 +1,122 @@
+import { afterEach, describe, expect, test } from "bun:test";
+import { h, render } from "preact";
+import { Markers } from "./Markers";
+import type { Segment } from "./types";
+
+const TRACK_WIDTH = 1000;
+const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
+
+let container: HTMLElement;
+let errors: unknown[];
+const onError = (event: ErrorEvent) => errors.push(event.error);
+
+afterEach(() => {
+ render(null, container);
+ container.remove();
+ window.removeEventListener("error", onError);
+});
+
+// Stands in for the session: previews and merges come back as new props.
+function harness(initial: Segment[], duration = 100) {
+ container = document.createElement("div");
+ document.body.append(container);
+ errors = [];
+ window.addEventListener("error", onError);
+ const track = document.createElement("div");
+ track.getBoundingClientRect = () => new DOMRect(0, 0, TRACK_WIDTH, 4);
+ const state = { segments: initial, previews: [] as Segment[][], merges: [] as string[][] };
+ const draw = () =>
+ render(
+ h(Markers, {
+ segments: state.segments,
+ duration,
+ track,
+ onPreview: (segments) => {
+ state.previews.push(segments);
+ state.segments = segments;
+ draw();
+ },
+ onSeek: () => undefined,
+ onCommit: () => undefined,
+ onMerge: (keepId, removeId) => {
+ state.merges.push([keepId, removeId]);
+ const remove = state.segments.find((s) => s.id === removeId)!;
+ state.segments = state.segments.filter((s) => s !== remove).map((s) => (s.id === keepId ? { ...s, endTime: remove.endTime } : s));
+ draw();
+ },
+ }),
+ container,
+ );
+ draw();
+ const grab = (segmentIndex: number, role: "start" | "end") =>
+ container.querySelectorAll(".marker")[segmentIndex * 2 + (role === "start" ? 0 : 1)].querySelector(".grab")!;
+ const pointer = (target: Element, type: string, pct: number) =>
+ target.dispatchEvent(new PointerEvent(type, { clientX: (pct / 100) * TRACK_WIDTH, pointerId: 1, bubbles: true }));
+ return { state, grab, pointer };
+}
+
+const pair = (): Segment[] => [
+ { id: "a", startTime: 0, endTime: 40 },
+ { id: "b", startTime: 50, endTime: 100 },
+];
+
+describe("Markers", () => {
+ test("releasing before the hold elapses cancels the merge", async () => {
+ const { state, grab, pointer } = harness(pair());
+ const end = grab(0, "end");
+ pointer(end, "pointerdown", 40);
+ pointer(end, "pointermove", 49);
+ pointer(end, "pointerup", 49);
+ await wait(600);
+ expect(state.merges).toEqual([]);
+ });
+
+ test("a merge ends the drag, so the merged range survives further moves", async () => {
+ const { state, grab, pointer } = harness(pair());
+ const end = grab(0, "end");
+ pointer(end, "pointerdown", 40);
+ pointer(end, "pointermove", 49);
+ await wait(600);
+ expect(state.merges).toEqual([["a", "b"]]);
+ pointer(grab(0, "end"), "pointermove", 45);
+ expect(state.segments).toEqual([{ id: "a", startTime: 0, endTime: 100 }]);
+ });
+
+ test("merging away the segment being dragged by its start leaves nothing to trip on", async () => {
+ const { state, grab, pointer } = harness(pair());
+ const start = grab(1, "start");
+ pointer(start, "pointerdown", 50);
+ pointer(start, "pointermove", 40.5);
+ await wait(600);
+ expect(state.merges).toEqual([["a", "b"]]);
+ pointer(grab(0, "end"), "pointermove", 60);
+ expect(errors).toEqual([]);
+ expect(state.segments).toEqual([{ id: "a", startTime: 0, endTime: 100 }]);
+ });
+
+ test("unmounting cancels a pending merge", async () => {
+ const { state, grab, pointer } = harness(pair());
+ const end = grab(0, "end");
+ pointer(end, "pointerdown", 40);
+ pointer(end, "pointermove", 49);
+ render(null, container);
+ await wait(600);
+ expect(state.merges).toEqual([]);
+ });
+
+ test("a start marker never passes its own end, even inside a longer segment", () => {
+ const { state, grab, pointer } = harness(
+ [
+ { id: "a", startTime: 0, endTime: 100 },
+ { id: "b", startTime: 40, endTime: 50 },
+ ],
+ 200,
+ );
+ const start = grab(1, "start");
+ pointer(start, "pointerdown", 20);
+ pointer(start, "pointermove", 22);
+ const b = state.segments.find((s) => s.id === "b")!;
+ expect(b.startTime).toBeLessThan(b.endTime);
+ expect(b.startTime).toBeGreaterThanOrEqual(0);
+ });
+});
diff --git a/web-ext/features/segments/Markers.tsx b/web-ext/features/segments/Markers.tsx
new file mode 100644
index 00000000..69a2d210
--- /dev/null
+++ b/web-ext/features/segments/Markers.tsx
@@ -0,0 +1,168 @@
+import { useEffect, useRef } from "preact/hooks";
+import { credit } from "@/kernel/dom/credit";
+import { colorForIndex } from "./format";
+import type { Segment, SegmentId } from "./types";
+
+type Role = "start" | "end";
+
+export interface MarkersProps {
+ segments: Segment[];
+ duration: number;
+ // The progress bar, for turning pointer positions into times.
+ track: HTMLElement;
+ onPreview: (segments: Segment[]) => void;
+ // Scrubbing a start marker follows it with the playhead.
+ onSeek: (time: number) => void;
+ onCommit: () => void;
+ onMerge: (keepId: SegmentId, removeId: SegmentId) => void;
+}
+
+const EPSILON_PCT = 0.2;
+const MERGE_THRESHOLD_PCT = 1.5;
+const MERGE_HOLD_MS = 500;
+
+// Mounted inside YouTube's progress bar, which stacks its progress lists at
+// 32, chapter marks at 40 and the playhead dot at 43: at 42 the markers sit
+// under the dot. Everything is drawn above the bar, never on it, so the bar
+// and its chapter gaps stay exactly as YouTube draws them.
+// No filters, strokes or transforms: at this size each one leaves the
+// shape soft once it is rasterised at a fractional position.
+const markerStyle = `
+ :host { position: absolute; inset: 0; z-index: 42; pointer-events: none; }
+ .rail { position: absolute; bottom: calc(100% + 3px); height: 2px; background: var(--seg); opacity: .7; }
+ .marker { position: absolute; bottom: 100%; width: 0; height: 0; color: var(--seg); }
+ .head { position: absolute; left: -6px; bottom: 3px; }
+ .head svg { display: block; }
+ .needle { position: absolute; left: -1px; bottom: 0; width: 2px; height: 3px; background: currentColor; }
+ .grab { position: absolute; left: -10px; width: 20px; bottom: -2px; height: 16px; cursor: ew-resize; touch-action: none; pointer-events: all; }
+ .marker:hover, .marker[data-dragging] { color: color-mix(in srgb, var(--seg) 75%, white); }
+ .marker[data-merging] .head, .marker[data-merging] .needle { animation: pulse .4s ease-in-out infinite alternate; }
+ @keyframes pulse { from { opacity: 1; } to { opacity: .35; } }
+`;
+
+// An editor's in or out point: a trapezoid pin standing on the timeline, its
+// needle on the exact cut.
+function Pin() {
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+}
+
+const byStart = (segments: Segment[]) => [...segments].sort((a, b) => a.startTime - b.startTime);
+
+export function Markers({ segments, duration, track, onPreview, onSeek, onCommit, onMerge }: MarkersProps) {
+ const drag = useRef<{ id: SegmentId; role: Role } | null>(null);
+ const merge = useRef<{ keepId: SegmentId; removeId: SegmentId; timer: ReturnType } | null>(null);
+ const sorted = byStart(segments);
+ const pct = (time: number) => (duration > 0 ? (time / duration) * 100 : 0);
+
+ const clearMerge = () => {
+ if (merge.current) clearTimeout(merge.current.timer);
+ merge.current = null;
+ };
+ useEffect(() => clearMerge, []);
+
+ // The segment's own bounds are applied last so they win over a neighbour
+ // that overlaps it.
+ const clamp = (raw: number, own: Segment, index: number, role: Role) => {
+ if (role === "start") {
+ const floor = sorted[index - 1] ? pct(sorted[index - 1].endTime) + EPSILON_PCT : 0;
+ return Math.max(0, Math.min(Math.max(raw, floor), pct(own.endTime) - EPSILON_PCT));
+ }
+ const ceiling = sorted[index + 1] ? pct(sorted[index + 1].startTime) - EPSILON_PCT : 100;
+ return Math.min(100, Math.max(Math.min(raw, ceiling), pct(own.startTime) + EPSILON_PCT));
+ };
+
+ // Holding a marker against its neighbour for a moment merges the two.
+ const watchMerge = (id: SegmentId, role: Role, at: number) => {
+ const index = sorted.findIndex((s) => s.id === id);
+ const neighbour = role === "end" ? sorted[index + 1] : sorted[index - 1];
+ const edge = neighbour ? pct(role === "end" ? neighbour.startTime : neighbour.endTime) : null;
+ const candidate = neighbour && edge !== null && Math.abs(at - edge) < MERGE_THRESHOLD_PCT
+ ? role === "end" ? { keepId: id, removeId: neighbour.id } : { keepId: neighbour.id, removeId: id }
+ : null;
+ if (!candidate) {
+ clearMerge();
+ return;
+ }
+ if (merge.current?.keepId === candidate.keepId && merge.current.removeId === candidate.removeId) return;
+ clearMerge();
+ merge.current = {
+ ...candidate,
+ timer: setTimeout(() => {
+ merge.current = null;
+ drag.current = null;
+ onMerge(candidate.keepId, candidate.removeId);
+ }, MERGE_HOLD_MS),
+ };
+ };
+
+ const onPointerDown = (e: PointerEvent, id: SegmentId, role: Role) => {
+ e.preventDefault();
+ e.stopPropagation();
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
+ drag.current = { id, role };
+ };
+
+ const onPointerMove = (e: PointerEvent) => {
+ if (!drag.current || !duration) return;
+ const { id, role } = drag.current;
+ const index = sorted.findIndex((s) => s.id === id);
+ if (index === -1) return;
+ const rect = track.getBoundingClientRect();
+ const at = clamp(((e.clientX - rect.left) / rect.width) * 100, sorted[index], index, role);
+ const time = (at / 100) * duration;
+ onPreview(segments.map((s) => (s.id === id ? { ...s, [role === "start" ? "startTime" : "endTime"]: time } : s)));
+ if (role === "start") onSeek(time);
+ watchMerge(id, role, at);
+ };
+
+ const onPointerUp = () => {
+ if (!drag.current) return;
+ drag.current = null;
+ clearMerge();
+ onCommit();
+ };
+
+ return (
+ <>
+
+ {sorted.map((segment, index) => (
+
+ ))}
+ {sorted.flatMap((segment, index) =>
+ (["start", "end"] as const).map((role) => (
+
+
+
onPointerDown(e, segment.id, role)}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerCancel={onPointerUp}
+ />
+
+ )),
+ )}
+ >
+ );
+}
diff --git a/web-ext/features/segments/SegmentButton.tsx b/web-ext/features/segments/SegmentButton.tsx
new file mode 100644
index 00000000..dfa9146d
--- /dev/null
+++ b/web-ext/features/segments/SegmentButton.tsx
@@ -0,0 +1,29 @@
+import { credit } from "@/kernel/dom/credit";
+
+// State is shown by fill, never by colour, the same way YouTube's own
+// control-bar toggles do it and the only cue that survives colour-blindness.
+export function SegmentButton({ active }: { active: boolean }) {
+ const bar = (x: number, width: number) => (
+
+ );
+ return (
+
+ {bar(2, 7)}
+ {bar(11, 7)}
+ {bar(20, 2)}
+
+ );
+}
+
+export function segmentButtonHost(): HTMLButtonElement {
+ const host = document.createElement("button");
+ host.id = "tppng-segment-button";
+ host.className = "ytp-button";
+ host.setAttribute("aria-label", "Segments playback");
+ return host;
+}
+
+export function setSegmentButtonState(host: HTMLButtonElement, active: boolean, label: string | null): void {
+ host.setAttribute("aria-pressed", String(active));
+ host.title = credit(label ? `Segments: ${label}` : "Segments");
+}
diff --git a/web-ext/features/segments/engine.test.ts b/web-ext/features/segments/engine.test.ts
new file mode 100644
index 00000000..1e64564d
--- /dev/null
+++ b/web-ext/features/segments/engine.test.ts
@@ -0,0 +1,186 @@
+import { describe, expect, test } from "bun:test";
+import { SegmentEngine } from "./engine";
+import type { PlayStep, Segment, SegmentConfig } from "./types";
+
+function fakeVideo(duration = 100): HTMLVideoElement {
+ const video = document.createElement("video");
+ Object.defineProperty(video, "duration", { value: duration, configurable: true });
+ return video;
+}
+
+function config(segments: Segment[], sequence: Array & { segmentIds: string[] }>): SegmentConfig {
+ return {
+ id: "c",
+ label: "Test",
+ segments,
+ sequence: sequence.map((step, i) => ({ id: `step${i}`, count: 0, playbackRate: null, perIterationRates: [], ...step })),
+ shortcutKey: "",
+ createdAt: 0,
+ updatedAt: 0,
+ };
+}
+
+// happy-dom never fires seeking, so tests play the browser's part.
+function playTo(video: HTMLVideoElement, time: number): void {
+ video.currentTime = time;
+ video.dispatchEvent(new Event("timeupdate"));
+}
+
+function seekTo(video: HTMLVideoElement, time: number): void {
+ video.currentTime = time;
+ video.dispatchEvent(new Event("seeking"));
+ video.dispatchEvent(new Event("timeupdate"));
+}
+
+describe("SegmentEngine", () => {
+ test("adjacent segments play through their step's count before the next step", () => {
+ const video = fakeVideo();
+ const engine = new SegmentEngine(
+ video,
+ config(
+ [
+ { id: "a", startTime: 0, endTime: 10 },
+ { id: "b", startTime: 10, endTime: 20 },
+ { id: "c", startTime: 30, endTime: 40 },
+ ],
+ [{ segmentIds: ["a", "b"], count: 2 }, { segmentIds: ["c"], count: 1 }],
+ ),
+ );
+ engine.start();
+ const visited: number[] = [];
+ for (let i = 0; i < 4; i++) {
+ playTo(video, video.currentTime + 10);
+ video.dispatchEvent(new Event("seeking"));
+ visited.push(video.currentTime);
+ }
+ expect(visited).toEqual([10, 0, 10, 30]);
+ });
+
+ test("a segment nested in another ends on its own end", () => {
+ const video = fakeVideo(200);
+ const engine = new SegmentEngine(
+ video,
+ config(
+ [
+ { id: "a", startTime: 0, endTime: 100 },
+ { id: "b", startTime: 40, endTime: 50 },
+ ],
+ [{ segmentIds: ["a", "b"] }],
+ ),
+ );
+ engine.start();
+ playTo(video, 100);
+ expect(video.currentTime).toBe(40);
+ playTo(video, 50);
+ expect(video.currentTime).toBe(0);
+ });
+
+ test("a user seek into another segment adopts it", () => {
+ const video = fakeVideo();
+ const engine = new SegmentEngine(
+ video,
+ config(
+ [
+ { id: "a", startTime: 0, endTime: 10 },
+ { id: "b", startTime: 50, endTime: 60 },
+ ],
+ [{ segmentIds: ["a"] }, { segmentIds: ["b"] }],
+ ),
+ );
+ engine.start();
+ seekTo(video, 55);
+ expect(video.currentTime).toBe(55);
+ expect(engine.getCurrentSegment()?.id).toBe("b");
+ });
+
+ test("editing a segment so the playhead falls outside it keeps that segment in view", () => {
+ const video = fakeVideo(200);
+ const segments = [
+ { id: "a", startTime: 0, endTime: 50 },
+ { id: "b", startTime: 100, endTime: 150 },
+ ];
+ const engine = new SegmentEngine(video, config(segments, [{ segmentIds: ["a", "b"] }]));
+ video.currentTime = 120;
+ engine.start();
+ engine.setConfig(config([segments[0], { ...segments[1], endTime: 110 }], [{ segmentIds: ["a", "b"] }]));
+ video.dispatchEvent(new Event("timeupdate"));
+ expect(video.currentTime).toBe(100);
+ expect(engine.getCurrentSegment()?.id).toBe("b");
+ });
+
+ test("a segment inside the last 0.3 s plays instead of seeking every tick", () => {
+ const video = fakeVideo();
+ const engine = new SegmentEngine(video, config([{ id: "a", startTime: 99.8, endTime: 100 }], [{ segmentIds: ["a"] }]));
+ engine.start();
+ expect(video.currentTime).toBe(99.8);
+ playTo(video, 99.9);
+ expect(video.currentTime).toBe(99.9);
+ });
+
+ test("a mid-roll ad in the same video is left alone, and segments resume after it", () => {
+ const video = fakeVideo();
+ let ad = false;
+ const engine = new SegmentEngine(video, config([{ id: "a", startTime: 40, endTime: 60 }], [{ segmentIds: ["a"], playbackRate: 0.5 }]), () => ad);
+ engine.start();
+ playTo(video, 45);
+ ad = true;
+ video.playbackRate = 1;
+ seekTo(video, 3);
+ playTo(video, 20);
+ expect(video.currentTime).toBe(20);
+ expect(video.playbackRate).toBe(1);
+ ad = false;
+ playTo(video, 45);
+ expect(video.currentTime).toBe(45);
+ expect(video.playbackRate).toBe(0.5);
+ playTo(video, 60);
+ expect(video.currentTime).toBe(40);
+ });
+
+ test("a segment starting past the video's end does not seek forever", async () => {
+ const video = fakeVideo(100);
+ let time = 0;
+ let seeks = 0;
+ // Browsers fire seeking for every seek, the engine's own included.
+ Object.defineProperty(video, "currentTime", {
+ configurable: true,
+ get: () => time,
+ set: (t: number) => {
+ time = t;
+ if (++seeks < 50) queueMicrotask(() => video.dispatchEvent(new Event("seeking")));
+ },
+ });
+ const engine = new SegmentEngine(video, config([{ id: "a", startTime: 120, endTime: 140 }], [{ segmentIds: ["a"] }]));
+ engine.start();
+ await new Promise((r) => setTimeout(r, 0));
+ expect(seeks).toBeLessThan(3);
+ });
+
+ test("scrubbing a segment outside the sequence leaves the playhead there", () => {
+ const video = fakeVideo(200);
+ const engine = new SegmentEngine(video, config([{ id: "a", startTime: 10, endTime: 20 }, { id: "b", startTime: 50, endTime: 60 }, { id: "loose", startTime: 30, endTime: 40 }], [{ segmentIds: ["a", "b"] }]));
+ engine.start();
+ seekTo(video, 32);
+ expect(video.currentTime).toBe(32);
+ });
+
+ test("stop leaves a rate the user chose alone", () => {
+ const video = fakeVideo();
+ const engine = new SegmentEngine(video, config([{ id: "a", startTime: 0, endTime: 50 }], [{ segmentIds: ["a"] }]));
+ engine.start();
+ video.playbackRate = 1.5;
+ playTo(video, 5);
+ engine.stop();
+ expect(video.playbackRate).toBe(1.5);
+ });
+
+ test("stop restores the rate the engine replaced", () => {
+ const video = fakeVideo();
+ const engine = new SegmentEngine(video, config([{ id: "a", startTime: 0, endTime: 50 }], [{ segmentIds: ["a"], playbackRate: 0.5 }]));
+ engine.start();
+ playTo(video, 5);
+ expect(video.playbackRate).toBe(0.5);
+ engine.stop();
+ expect(video.playbackRate).toBe(1);
+ });
+});
diff --git a/web-ext/features/segments/engine.ts b/web-ext/features/segments/engine.ts
new file mode 100644
index 00000000..a451a505
--- /dev/null
+++ b/web-ext/features/segments/engine.ts
@@ -0,0 +1,256 @@
+import type { Segment, SegmentConfig, PlayStep, SegmentId } from "./types";
+
+// Decides when to seek and at what rate, and nothing else: no storage, no UI,
+// no DOM beyond the video element. Keep it that way — the panel and markers
+// both drive this class, so anything stateful added here leaks into both.
+
+interface EngineState {
+ stepIndex: number;
+ iterationInStep: number;
+ segmentIndexInStep: number; // step.segmentIds is user-ordered and may repeat an id
+}
+
+function initialState(): EngineState {
+ return { stepIndex: 0, iterationInStep: 0, segmentIndexInStep: 0 };
+}
+
+export class SegmentEngine {
+ private video: HTMLVideoElement;
+ private config: SegmentConfig;
+ private state: EngineState = initialState();
+ private detach: (() => void) | null = null;
+ private originalRate: number | null = null;
+ private appliedRate: number | null = null;
+ private _active = false;
+ private isHeld: () => boolean;
+ // A segment outside the sequence that the user seeked into, being edited.
+ private scrubbing: Segment | null = null;
+
+ // isHeld pauses the engine while the video is not the content, as during a
+ // mid-roll ad, which YouTube plays in this same element on its own clock.
+ constructor(video: HTMLVideoElement, config: SegmentConfig, isHeld: () => boolean = () => false) {
+ this.video = video;
+ this.config = config;
+ this.isHeld = isHeld;
+ }
+
+ start(): void {
+ if (this._active) this.stop();
+ this.state = initialState();
+ this._active = true;
+ this.originalRate = this.video.playbackRate;
+
+ // Never seek when the playhead already sits inside a segment: enabling
+ // segments mid-playback should not jump the video out from under the user.
+ if (!this.adoptSegmentAt(this.video.currentTime)) this.seekToCurrentSegment();
+
+ const onTimeUpdate = () => this.onTimeUpdate();
+ const onSeeking = () => this.onSeeking();
+ this.video.addEventListener("timeupdate", onTimeUpdate);
+ this.video.addEventListener("seeking", onSeeking);
+ this.detach = () => {
+ this.video.removeEventListener("timeupdate", onTimeUpdate);
+ this.video.removeEventListener("seeking", onSeeking);
+ };
+ }
+
+ stop(): void {
+ this.detach?.();
+ this.detach = null;
+ // Only undo a rate the engine set and nobody has changed since, so a speed
+ // the user picked while segments ran survives turning them off.
+ if (this.originalRate !== null && this.appliedRate === this.video.playbackRate) {
+ this.video.playbackRate = this.originalRate;
+ }
+ this.originalRate = null;
+ this.appliedRate = null;
+ this._active = false;
+ }
+
+ isActive(): boolean {
+ return this._active;
+ }
+
+ // Reconciles rather than restarts so that dragging a marker, which swaps the
+ // config on every pointer move, does not restart playback on every frame.
+ setConfig(config: SegmentConfig): void {
+ this.config = config;
+ if (!this._active) {
+ this.state = initialState();
+ return;
+ }
+ // An edit that leaves the playhead outside the segment it was in restarts
+ // that segment, rather than the next tick advancing past what is being edited.
+ const current = this.resolveCurrentSegment();
+ const ct = this.video.currentTime;
+ if (current && ct >= current.startTime && ct <= current.endTime) return;
+ if (this.adoptSegmentAt(ct)) return;
+ if (!current) this.state = initialState();
+ this.seekToCurrentSegment();
+ }
+
+ getCurrentSegment(): Segment | null {
+ return this.resolveCurrentSegment();
+ }
+
+ getSegmentAt(currentTime: number): Segment | null {
+ const segs = this.config.segments;
+ if (segs.length === 0) return null;
+
+ const containing = segs.find(
+ (s) => currentTime >= s.startTime && currentTime <= s.endTime,
+ );
+ if (containing) return containing;
+
+ return segs.reduce((best, seg) => {
+ const mid = (seg.startTime + seg.endTime) / 2;
+ const bestMid = (best.startTime + best.endTime) / 2;
+ return Math.abs(currentTime - mid) < Math.abs(currentTime - bestMid)
+ ? seg
+ : best;
+ });
+ }
+
+ private onTimeUpdate(): void {
+ if (this.isHeld()) return;
+ if (this.scrubbing) {
+ const ct = this.video.currentTime;
+ if (ct >= this.scrubbing.startTime && ct < this.scrubbing.endTime) return;
+ this.scrubbing = null;
+ }
+ // A tick queued before a seek reports the new position before the seeking
+ // event has had a chance to adopt it.
+ if (this.video.seeking) return;
+ const seg = this.resolveCurrentSegment();
+ if (!seg) return;
+
+ const rate = this.getEffectiveRate();
+ if (rate !== null && Math.abs(this.video.playbackRate - rate) > 0.001) {
+ this.video.playbackRate = rate;
+ this.appliedRate = this.video.playbackRate;
+ }
+
+ const ct = this.video.currentTime;
+
+ // 0.3 s short of the true end: seeking into the unmuxed tail of a YouTube
+ // stream can stall playback rather than fire the next timeupdate. A segment
+ // lying wholly in that tail plays to its real end instead.
+ const tail = this.video.duration - 0.3;
+ const safeEnd = tail > seg.startTime ? Math.min(seg.endTime, tail) : seg.endTime;
+
+ if (ct >= safeEnd) {
+ this.advance();
+ } else if (ct < seg.startTime) {
+ this.seekToCurrentSegment();
+ }
+ }
+
+ // Only a seek adopts another segment: during playback, reaching the end of
+ // one segment is where an adjacent or enclosing one starts, and adopting it
+ // there would reset the step's count or loop it forever.
+ private onSeeking(): void {
+ if (this.isHeld()) return;
+ const ct = this.video.currentTime;
+ const contains = (s: Segment) => ct >= s.startTime && ct < s.endTime;
+ this.scrubbing = null;
+ const current = this.resolveCurrentSegment();
+ if (current && contains(current)) return;
+ if (this.adoptSegmentAt(ct)) return;
+ // A segment outside the sequence is one being edited; scrubbing it stays put.
+ this.scrubbing = this.config.segments.find(contains) ?? null;
+ if (this.scrubbing) return;
+ const ahead = [...this.config.segments].sort((a, b) => a.startTime - b.startTime).filter((s) => s.startTime >= ct);
+ for (const segment of ahead) {
+ // The engine's own seek toward a segment past the video's end lands short
+ // of it; seeking there again would never stop.
+ if (segment.id === current?.id) return;
+ if (this.adoptSegment(segment.id)) {
+ this.seekToCurrentSegment();
+ return;
+ }
+ }
+ }
+
+ private advance(): void {
+ const step = this.currentStep();
+ if (!step) return;
+
+ const nextSegIdx = this.state.segmentIndexInStep + 1;
+
+ if (nextSegIdx < step.segmentIds.length) {
+ this.state.segmentIndexInStep = nextSegIdx;
+ this.seekToCurrentSegment();
+ return;
+ }
+
+ const nextIteration = this.state.iterationInStep + 1;
+ this.state.segmentIndexInStep = 0;
+
+ // count 0 is the stored encoding for "loop forever".
+ if (step.count === 0 || nextIteration < step.count) {
+ this.state.iterationInStep = nextIteration;
+ this.seekToCurrentSegment();
+ return;
+ }
+
+ const nextStepIdx =
+ (this.state.stepIndex + 1) % this.config.sequence.length;
+ this.state.stepIndex = nextStepIdx;
+ this.state.iterationInStep = 0;
+ this.state.segmentIndexInStep = 0;
+ this.seekToCurrentSegment();
+ }
+
+ private seekToCurrentSegment(): void {
+ if (this.isHeld()) return;
+ const seg = this.resolveCurrentSegment();
+ if (seg && this.video.duration > 0) {
+ const seekTo = Math.max(0, Math.min(seg.startTime, this.video.duration));
+ this.video.currentTime = seekTo;
+ }
+ }
+
+ // Half-open, so a boundary shared by two segments belongs to the later one.
+ private adoptSegmentAt(time: number): boolean {
+ return this.config.segments.some((s) => time >= s.startTime && time < s.endTime && this.adoptSegment(s.id));
+ }
+
+ // The current step is tried first so moving within it keeps its iteration.
+ private adoptSegment(segId: SegmentId): boolean {
+ const { stepIndex, iterationInStep } = this.state;
+ const order = [stepIndex, ...this.config.sequence.map((_, i) => i).filter((i) => i !== stepIndex)];
+ for (const si of order) {
+ const segIdx = this.config.sequence[si]?.segmentIds.indexOf(segId) ?? -1;
+ if (segIdx !== -1) {
+ this.state = { stepIndex: si, iterationInStep: si === stepIndex ? iterationInStep : 0, segmentIndexInStep: segIdx };
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private resolveCurrentSegment(): Segment | null {
+ const step = this.currentStep();
+ if (!step || step.segmentIds.length === 0) return null;
+ const segId = step.segmentIds[this.state.segmentIndexInStep];
+ return this.config.segments.find((s) => s.id === segId) ?? null;
+ }
+
+ private currentStep(): PlayStep | null {
+ return this.config.sequence[this.state.stepIndex] ?? null;
+ }
+
+ // null means "no override" — the caller leaves the user's rate untouched.
+ private getEffectiveRate(): number | null {
+ const step = this.currentStep();
+ if (!step) return null;
+ if (step.perIterationRates.length > 0) {
+ const idx = Math.min(
+ this.state.iterationInStep,
+ step.perIterationRates.length - 1,
+ );
+ return step.perIterationRates[idx];
+ }
+ return step.playbackRate;
+ }
+}
diff --git a/web-ext/features/segments/factories.ts b/web-ext/features/segments/factories.ts
new file mode 100644
index 00000000..8a5366f5
--- /dev/null
+++ b/web-ext/features/segments/factories.ts
@@ -0,0 +1,138 @@
+import type { Segment, SegmentConfig, PlayStep } from "./types";
+
+function randomId(): string {
+ return crypto.randomUUID();
+}
+
+export function createFreshConfig(videoDuration: number): SegmentConfig {
+ const segId = randomId();
+ const stepId = randomId();
+
+ const segment: Segment = {
+ id: segId,
+ startTime: 0,
+ endTime: videoDuration > 0 ? videoDuration : 0,
+ };
+
+ const step: PlayStep = {
+ id: stepId,
+ segmentIds: [segId],
+ count: 0,
+ playbackRate: null,
+ perIterationRates: [],
+ };
+
+ const now = Date.now();
+ return {
+ id: randomId(),
+ label: "Default",
+ segments: [segment],
+ sequence: [step],
+ shortcutKey: "",
+ createdAt: now,
+ updatedAt: now,
+ };
+}
+
+
+export function addSegmentToConfig(
+ config: SegmentConfig,
+ startTime: number,
+ endTime: number,
+ label?: string,
+): SegmentConfig {
+ const newSeg: Segment = { id: randomId(), startTime, endTime, label };
+
+ let newSequence: PlayStep[];
+ if (config.sequence.length === 0) {
+ const step: PlayStep = {
+ id: randomId(),
+ segmentIds: [newSeg.id],
+ count: 0,
+ playbackRate: null,
+ perIterationRates: [],
+ };
+ newSequence = [step];
+ } else {
+ // Lands in the first step by default; rearranging is left to the user.
+ newSequence = config.sequence.map((step, i) =>
+ i === 0 ? { ...step, segmentIds: [...step.segmentIds, newSeg.id] } : step,
+ );
+ }
+
+ return {
+ ...config,
+ segments: [...config.segments, newSeg],
+ sequence: newSequence,
+ updatedAt: Date.now(),
+ };
+}
+
+// Also drops any step left with no segments, so the sequence never contains a
+// step the engine would spin on forever.
+export function removeSegmentFromConfig(
+ config: SegmentConfig,
+ segmentId: string,
+): SegmentConfig {
+ const newSegments = config.segments.filter((s) => s.id !== segmentId);
+ const newSequence = config.sequence
+ .map((step) => ({
+ ...step,
+ segmentIds: step.segmentIds.filter((id) => id !== segmentId),
+ }))
+ .filter((step) => step.segmentIds.length > 0);
+
+ return {
+ ...config,
+ segments: newSegments,
+ sequence: newSequence,
+ updatedAt: Date.now(),
+ };
+}
+
+export function updateSegmentTimes(
+ config: SegmentConfig,
+ segmentId: string,
+ startTime: number,
+ endTime: number,
+): SegmentConfig {
+ return {
+ ...config,
+ segments: config.segments.map((s) =>
+ s.id === segmentId ? { ...s, startTime, endTime } : s,
+ ),
+ updatedAt: Date.now(),
+ };
+}
+
+export function generateConfigLabel(existingCount: number): string {
+ return `Segment Config ${existingCount + 1}`;
+}
+
+// Returns null when the split would leave either half under 0.1 s, which is the
+// same floor the markers enforce while dragging.
+export function splitSegmentAtTime(
+ config: SegmentConfig,
+ segmentId: string,
+ splitTime: number,
+): SegmentConfig | null {
+ const seg = config.segments.find((s) => s.id === segmentId);
+ if (!seg) return null;
+ if (splitTime <= seg.startTime + 0.1 || splitTime >= seg.endTime - 0.1) return null;
+
+ const newSegId = randomId();
+ const segA: Segment = { ...seg, endTime: splitTime };
+ const segB: Segment = { id: newSegId, startTime: splitTime, endTime: seg.endTime };
+
+ const newSegments = config.segments.map((s) => (s.id === segmentId ? segA : s)).concat(segB);
+
+ const newSequence = config.sequence.map((step) => {
+ const idx = step.segmentIds.indexOf(segmentId);
+ if (idx < 0) return step;
+ const ids = [...step.segmentIds];
+ ids.splice(idx + 1, 0, newSegId);
+ return { ...step, segmentIds: ids };
+ });
+
+ return { ...config, segments: newSegments, sequence: newSequence, updatedAt: Date.now() };
+}
diff --git a/web-ext/features/segments/format.ts b/web-ext/features/segments/format.ts
new file mode 100644
index 00000000..151b6d15
--- /dev/null
+++ b/web-ext/features/segments/format.ts
@@ -0,0 +1,7 @@
+// The first segment takes YouTube's own red (its playhead dot and played fill)
+// so a single loop reads as part of the player.
+const SEGMENT_COLORS = ["#ff0033", "#3ea6ff", "#f5c518", "#4caf50", "#ab47bc", "#00bcd4", "#ff9800"];
+
+export function colorForIndex(i: number): string {
+ return SEGMENT_COLORS[i % SEGMENT_COLORS.length];
+}
diff --git a/web-ext/features/segments/index.test.ts b/web-ext/features/segments/index.test.ts
new file mode 100644
index 00000000..82fc98c1
--- /dev/null
+++ b/web-ext/features/segments/index.test.ts
@@ -0,0 +1,160 @@
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import { createSegments } from "./index";
+import { memorySegmentStorage, openVideoSegments, type SegmentStorage } from "./store";
+import { createFreshConfig } from "./factories";
+
+const page = `
+
+
Title `;
+
+const route = { name: "watch", videoId: "v1", playlistId: null } as const;
+const press = (key: string, init: KeyboardEventInit = {}) => document.body.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init }));
+const tick = () => new Promise((r) => setTimeout(r, 10));
+const button = () => document.getElementById("tppng-segment-button")!;
+const panel = () => document.getElementById("tppng-segment-panel")!.shadowRoot!;
+const markers = () => document.getElementById("tppng-segment-markers")!.shadowRoot!.querySelectorAll(".marker");
+
+let storage: SegmentStorage;
+let unmount: (() => void) | undefined | void;
+
+beforeEach(async () => {
+ await chrome.storage.sync.clear();
+ storage = memorySegmentStorage();
+ document.body.innerHTML = page;
+ const video = document.querySelector("video")!;
+ Object.defineProperty(video, "duration", { value: 200, configurable: true });
+ Object.defineProperty(video, "readyState", { value: 1, configurable: true });
+});
+afterEach(() => {
+ unmount?.();
+ unmount = undefined;
+ document.body.innerHTML = "";
+});
+
+const mountOn = async () => {
+ unmount = await createSegments({ storage }).mount({ signal: new AbortController().signal, route });
+ await tick();
+};
+
+describe("segments", () => {
+ test("stays off, keys included, when switched off", async () => {
+ await chrome.storage.sync.set({ "settings:segments": { enabled: false } });
+ await mountOn();
+ press("z");
+ await tick();
+ expect(document.getElementById("tppng-segment-button")).toBeNull();
+ expect(document.getElementById("tppng-segment-panel")).toBeNull();
+ });
+
+ test("mounts a control-bar button, and the toggle key shows the panel and markers", async () => {
+ await mountOn();
+ expect(button().getAttribute("aria-pressed")).toBe("false");
+ expect(panel().querySelector(".panel")).toBeNull();
+
+ press("z");
+ await tick();
+ expect(button().getAttribute("aria-pressed")).toBe("true");
+ expect(panel().querySelector(".title")?.textContent).toBe("Segments");
+ expect(panel().querySelector(".range")?.textContent).toBe("0:00 → 3:20");
+ expect(markers()).toHaveLength(2);
+
+ press("z");
+ await tick();
+ expect(panel().querySelector(".panel")).toBeNull();
+ expect(markers()).toHaveLength(0);
+ });
+
+ test("the button toggles too, and the panel can add a segment at the playhead", async () => {
+ await mountOn();
+ button().click();
+ await tick();
+ document.querySelector("video")!.currentTime = 100;
+ [...panel().querySelectorAll("button")].find((b) => b.textContent === "+ Add Segment")!.click();
+ await tick();
+ expect([...panel().querySelectorAll(".range")].map((r) => r.textContent)).toEqual(["0:00 → 1:40", "1:40 → 3:20"]);
+ expect(markers()).toHaveLength(4);
+ });
+
+ test("adding a segment on a slate that fills the video splits it instead of nesting one inside", async () => {
+ await mountOn();
+ button().click();
+ await tick();
+ [...panel().querySelectorAll("button")].find((b) => b.textContent === "+ Add Segment")!.click();
+ await tick();
+ expect([...panel().querySelectorAll(".range")].map((r) => r.textContent)).toEqual(["0:00 → 1:40", "1:40 → 3:20"]);
+ });
+
+ test("the config name field keeps what is typed", async () => {
+ await mountOn();
+ button().click();
+ await tick();
+ [...panel().querySelectorAll("button")].find((b) => b.textContent === "Save ▾")!.click();
+ await tick();
+ [...panel().querySelectorAll("button")].find((b) => b.textContent === "Save as Named Config…")!.click();
+ await tick();
+ const input = panel().querySelector("input.text")!;
+ expect([input.selectionStart, input.selectionEnd]).toEqual([0, input.value.length]);
+ input.value = "Verse";
+ input.dispatchEvent(new InputEvent("input", { bubbles: true }));
+ await tick();
+ expect(input.selectionStart).toBe(input.selectionEnd);
+ });
+
+ test("auto-load waits out a pre-roll ad rather than clamping to its length", async () => {
+ const store = openVideoSegments(storage, "v1");
+ const config = { ...createFreshConfig(200), label: "Intro" };
+ await store.saveConfig(config);
+ await store.setDefaultConfig(config.id);
+ await chrome.storage.sync.set({ "settings:segments": { autoLoad: "default" } });
+ const player = document.getElementById("movie_player")!;
+ player.className = "html5-video-player ad-showing";
+ const video = document.querySelector("video")!;
+ Object.defineProperty(video, "duration", { value: 15, configurable: true });
+ await mountOn();
+ expect(button().getAttribute("aria-pressed")).toBe("false");
+
+ player.classList.remove("ad-showing");
+ Object.defineProperty(video, "duration", { value: 200, configurable: true });
+ video.dispatchEvent(new Event("timeupdate"));
+ await tick();
+ expect(button().getAttribute("aria-pressed")).toBe("true");
+ expect(panel().querySelector(".range")?.textContent).toBe("0:00 → 3:20");
+ });
+
+ test("auto-load restores the default config on mount", async () => {
+ const store = openVideoSegments(storage, "v1");
+ const config = { ...createFreshConfig(200), label: "Intro" };
+ await store.saveConfig(config);
+ await store.setDefaultConfig(config.id);
+ await chrome.storage.sync.set({ "settings:segments": { autoLoad: "default" } });
+ await mountOn();
+ expect(button().getAttribute("aria-pressed")).toBe("true");
+ expect(panel().querySelector(".chip")?.textContent).toContain("Intro");
+ });
+
+ test("a saved config's own shortcut loads it", async () => {
+ const store = openVideoSegments(storage, "v1");
+ await store.saveConfig({ ...createFreshConfig(200), label: "Solo", shortcutKey: "Ctrl+1" });
+ await mountOn();
+ press("1", { ctrlKey: true });
+ await tick();
+ expect(button().getAttribute("aria-pressed")).toBe("true");
+ expect(panel().querySelector(".chip")?.textContent).toContain("Solo");
+ });
+
+ test("unmount removes everything it added", async () => {
+ await mountOn();
+ press("z");
+ await tick();
+ unmount?.();
+ unmount = undefined;
+ expect(document.getElementById("tppng-segment-button")).toBeNull();
+ expect(document.getElementById("tppng-segment-panel")).toBeNull();
+ expect(document.getElementById("tppng-segment-markers")).toBeNull();
+ expect(document.querySelector(".ytp-progress-bar-container")!.style.overflow).toBe("");
+ });
+});
diff --git a/web-ext/features/segments/index.tsx b/web-ext/features/segments/index.tsx
new file mode 100644
index 00000000..582701b8
--- /dev/null
+++ b/web-ext/features/segments/index.tsx
@@ -0,0 +1,117 @@
+import type { Feature } from "@/kernel/features";
+import { bindKeys } from "@/kernel/keys";
+import { mount, mountInline } from "@/kernel/dom/mount";
+import { setCapabilityStatus } from "@/kernel/dom/capabilities";
+import { showToast } from "@/kernel/dom/toast";
+import { isTypingTarget, matchesBinding } from "@/kernel/keybinding";
+import { resolveBelowPlayer, resolveProgressBar, resolveRightControls, resolveVideo } from "@/youtube/player";
+import { Markers } from "./Markers";
+import { Panel } from "./panel/Panel";
+import { SegmentButton, segmentButtonHost, setSegmentButtonState } from "./SegmentButton";
+import { segmentsKeys } from "./keys";
+import { SegmentSession } from "./session";
+import { segmentsSettings } from "./settings";
+import { indexedDbSegmentStorage, openVideoSegments, type SegmentStorage } from "./store";
+
+export interface SegmentsDeps {
+ storage: SegmentStorage;
+}
+
+export function createSegments({ storage }: SegmentsDeps): Feature {
+ return {
+ id: "segments",
+ routes: ["watch"],
+ async mount({ route }) {
+ if (route.name !== "watch" || !route.videoId) return;
+ const settings = await segmentsSettings.get();
+ if (!settings.enabled) return;
+ const [player, controls, bar, below] = await Promise.all([resolveVideo(), resolveRightControls(), resolveProgressBar(), resolveBelowPlayer()]);
+ void setCapabilityStatus("watch.player", "watch", player);
+ void setCapabilityStatus("watch.rightControls", "watch", controls);
+ void setCapabilityStatus("watch.progressBar", "watch", bar);
+ if (!player.resolved) return;
+ const video = player.element as HTMLVideoElement;
+
+ const session = new SegmentSession({ video, store: openVideoSegments(storage, route.videoId), settings, toast: showToast });
+ await session.init();
+
+ const button = controls.resolved ? mountInline(segmentButtonHost(), controls.element, , "prepend") : undefined;
+ if (button) button.host.onclick = () => void session.toggle();
+ // The handles rest above the bar, which YouTube would otherwise clip.
+ const track = bar.element as HTMLElement | null;
+ if (track) track.style.overflow = "visible";
+ const markers = track ? mount("tppng-segment-markers", track, null) : undefined;
+ const panel = below.resolved ? mount("tppng-segment-panel", below.element, null, "prepend") : undefined;
+ const playhead = () => ({ time: video.currentTime, duration: video.duration || 0 });
+
+ const draw = () => {
+ const { active, config } = session.state;
+ if (button) {
+ button.update( );
+ setSegmentButtonState(button.host as HTMLButtonElement, active, config && config.label !== "Default" ? config.label : null);
+ }
+ markers?.update(
+ session.preview(segments)}
+ onSeek={(time) => (video.currentTime = time)}
+ onCommit={() => session.commitPreview()}
+ onMerge={(keep, remove) => session.merge(keep, remove)}
+ />,
+ );
+ panel?.update( );
+ };
+ const unsubscribe = session.subscribe(draw);
+ draw();
+
+ // Retried as playback goes on, because a pre-roll ad loads its own
+ // metadata into the video first and holds it until the ad is over.
+ const restore = () => {
+ if (!session.ready) return;
+ video.removeEventListener("loadedmetadata", restore);
+ video.removeEventListener("timeupdate", restore);
+ void session.restore();
+ };
+ video.addEventListener("loadedmetadata", restore);
+ video.addEventListener("timeupdate", restore);
+ restore();
+
+ const unbindKeys = bindKeys(segmentsKeys, {
+ toggle: () => void session.toggle(),
+ fresh: () => session.fresh(),
+ setStart: () => session.setStart(),
+ setEnd: () => session.setEnd(),
+ nudgeStartBackward: () => session.nudgeMarker("start", "backward"),
+ nudgeStartForward: () => session.nudgeMarker("start", "forward"),
+ nudgeEndBackward: () => session.nudgeMarker("end", "backward"),
+ nudgeEndForward: () => session.nudgeMarker("end", "forward"),
+ save: () => void session.save(),
+ });
+ // Saved configs carry their own shortcut keys, which are per video and so
+ // cannot live in the static key registry.
+ const onSavedShortcut = (event: KeyboardEvent) => {
+ if (isTypingTarget(event.target)) return;
+ const hit = session.state.saved.find((c) => c.shortcutKey && matchesBinding(event, c.shortcutKey));
+ if (hit) session.load(hit);
+ };
+ document.addEventListener("keydown", onSavedShortcut);
+
+ return () => {
+ document.removeEventListener("keydown", onSavedShortcut);
+ video.removeEventListener("loadedmetadata", restore);
+ video.removeEventListener("timeupdate", restore);
+ unbindKeys();
+ unsubscribe();
+ session.dispose();
+ button?.unmount();
+ markers?.unmount();
+ panel?.unmount();
+ if (track) track.style.overflow = "";
+ };
+ },
+ };
+}
+
+export const segments = createSegments({ storage: indexedDbSegmentStorage });
diff --git a/web-ext/features/segments/keys.ts b/web-ext/features/segments/keys.ts
new file mode 100644
index 00000000..b7969b80
--- /dev/null
+++ b/web-ext/features/segments/keys.ts
@@ -0,0 +1,17 @@
+import { defineKeys } from "@/kernel/keys";
+
+export const segmentsKeys = defineKeys({
+ id: "segments",
+ title: "Segments",
+ keys: {
+ toggle: { label: "Toggle segments", description: "Load the last-used segments, or turn them off.", defaultBinding: "Z" },
+ fresh: { label: "Fresh slate", description: "Start over with one segment spanning the video.", defaultBinding: "Shift+Z" },
+ setStart: { label: "Set segment start", description: "Move the current segment's start to the playhead.", defaultBinding: "Q" },
+ setEnd: { label: "Set segment end", description: "Move the current segment's end to the playhead.", defaultBinding: "E" },
+ nudgeStartBackward: { label: "Nudge start backward", defaultBinding: "Shift+Q" },
+ nudgeStartForward: { label: "Nudge start forward", defaultBinding: "" },
+ nudgeEndBackward: { label: "Nudge end backward", defaultBinding: "" },
+ nudgeEndForward: { label: "Nudge end forward", defaultBinding: "Shift+E" },
+ save: { label: "Save segments", description: "Save as this video's default, or clear the last-used slate when off.", defaultBinding: "" },
+ },
+});
diff --git a/web-ext/features/segments/panel/ConfigManager.tsx b/web-ext/features/segments/panel/ConfigManager.tsx
new file mode 100644
index 00000000..2ad7969f
--- /dev/null
+++ b/web-ext/features/segments/panel/ConfigManager.tsx
@@ -0,0 +1,107 @@
+import { useEffect, useRef, useState } from "preact/hooks";
+import type { SegmentConfig } from "../types";
+
+export interface ConfigManagerProps {
+ current: SegmentConfig;
+ saved: SegmentConfig[];
+ defaultId: string | null;
+ onBack: () => void;
+ onLoad: (config: SegmentConfig) => void;
+ onRename: (configId: string, label: string) => void;
+ onShortcut: (configId: string, key: string) => void;
+ onSetDefault: (configId: string) => void;
+ onDelete: (configId: string) => void;
+}
+
+export function ConfigManager({ current, saved, defaultId, onBack, onLoad, onRename, onShortcut, onSetDefault, onDelete }: ConfigManagerProps) {
+ return (
+
+
+ {saved.length === 0 ? (
+
No saved configs yet. Use Save ▾ → Save as Named Config to create one.
+ ) : (
+
+ {saved.map((config) => (
+ onLoad(config)}
+ onRename={(label) => onRename(config.id, label)}
+ onShortcut={(key) => onShortcut(config.id, key)}
+ onSetDefault={() => onSetDefault(config.id)}
+ onDelete={() => onDelete(config.id)}
+ />
+ ))}
+
+ )}
+
+ );
+}
+
+interface SavedRowProps {
+ config: SegmentConfig;
+ isDefault: boolean;
+ isLoaded: boolean;
+ onLoad: () => void;
+ onRename: (label: string) => void;
+ onShortcut: (key: string) => void;
+ onSetDefault: () => void;
+ onDelete: () => void;
+}
+
+function SavedRow({ config, isDefault, isLoaded, onLoad, onRename, onShortcut, onSetDefault, onDelete }: SavedRowProps) {
+ const [draft, setDraft] = useState(null);
+ const draftInput = useRef(null);
+ const isRenaming = draft !== null;
+ useEffect(() => {
+ if (isRenaming) draftInput.current?.select();
+ }, [isRenaming]);
+
+ if (draft !== null) {
+ const commit = () => {
+ if (draft.trim()) onRename(draft.trim());
+ setDraft(null);
+ };
+ return (
+
+ setDraft(e.currentTarget.value)}
+ onKeyDown={(e) => {
+ e.stopPropagation();
+ if (e.key === "Enter") commit();
+ if (e.key === "Escape") setDraft(null);
+ }}
+ />
+ OK
+ setDraft(null)}>Cancel
+
+ );
+ }
+
+ return (
+
+ {config.label}
+ {isDefault && default }
+ onShortcut(e.currentTarget.value.trim())}
+ onKeyDown={(e) => e.stopPropagation()}
+ />
+ {!isDefault && ★ }
+ setDraft(config.label)}>✎
+ ✕
+
+ );
+}
diff --git a/web-ext/features/segments/panel/Header.tsx b/web-ext/features/segments/panel/Header.tsx
new file mode 100644
index 00000000..abca09ac
--- /dev/null
+++ b/web-ext/features/segments/panel/Header.tsx
@@ -0,0 +1,110 @@
+import { useEffect, useRef, useState } from "preact/hooks";
+import { generateConfigLabel } from "../factories";
+import type { SegmentAutoLoadPin, SegmentConfig } from "../types";
+import { Menu } from "./Menu";
+
+export interface HeaderProps {
+ config: SegmentConfig;
+ saved: SegmentConfig[];
+ pin: SegmentAutoLoadPin;
+ collapsed: boolean;
+ onLoad: (config: SegmentConfig) => void;
+ onPin: (pin: SegmentAutoLoadPin) => void;
+ onSaveDefault: () => void;
+ onSaveNamed: (label: string) => void;
+ onManage: () => void;
+ onCollapse: () => void;
+}
+
+const PIN_OPTIONS: ReadonlyArray<[Exclude, string, string]> = [
+ [null, "○", "Use global setting"],
+ ["off", "⊗", "Always off for this video"],
+ ["last-used", "↺", "Restore last-used"],
+ ["default", "★", "Restore default saved config"],
+];
+
+function describePin(pin: SegmentAutoLoadPin): { icon: string; title: string } {
+ const option = PIN_OPTIONS.find(([value]) => value === pin);
+ return option ? { icon: option[1], title: `Auto-load: ${option[2].toLowerCase()}` } : { icon: "📌", title: "Auto-load pin" };
+}
+
+export function Header({ config, saved, pin, collapsed, onLoad, onPin, onSaveDefault, onSaveNamed, onManage, onCollapse }: HeaderProps) {
+ const [naming, setNaming] = useState(null);
+ const nameInput = useRef(null);
+ const isNaming = naming !== null;
+ useEffect(() => {
+ if (isNaming) nameInput.current?.select();
+ }, [isNaming]);
+ const pinInfo = describePin(pin);
+
+ return (
+ <>
+
+ {naming !== null && (
+
+ setNaming(e.currentTarget.value)}
+ onKeyDown={(e) => {
+ e.stopPropagation();
+ if (e.key === "Enter") (onSaveNamed(naming), setNaming(null));
+ if (e.key === "Escape") setNaming(null);
+ }}
+ />
+ (onSaveNamed(naming), setNaming(null))}>Save
+ setNaming(null)}>Cancel
+
+ )}
+ >
+ );
+}
diff --git a/web-ext/features/segments/panel/Menu.tsx b/web-ext/features/segments/panel/Menu.tsx
new file mode 100644
index 00000000..861d057f
--- /dev/null
+++ b/web-ext/features/segments/panel/Menu.tsx
@@ -0,0 +1,36 @@
+import type { ComponentChildren } from "preact";
+import { useEffect, useRef, useState } from "preact/hooks";
+
+export interface MenuProps {
+ trigger: ComponentChildren;
+ triggerClass?: string;
+ title?: string;
+ align?: "left" | "right";
+ children: ComponentChildren | ((close: () => void) => ComponentChildren);
+}
+
+// A dropdown that closes on any click outside it, including clicks on the page
+// beyond the shadow root, which composedPath still reports.
+export function Menu({ trigger, triggerClass = "btn", title, align = "left", children }: MenuProps) {
+ const [open, setOpen] = useState(false);
+ const root = useRef(null);
+
+ useEffect(() => {
+ if (!open) return;
+ const onClick = (e: Event) => {
+ if (!e.composedPath().includes(root.current!)) setOpen(false);
+ };
+ document.addEventListener("click", onClick);
+ return () => document.removeEventListener("click", onClick);
+ }, [open]);
+
+ const close = () => setOpen(false);
+ return (
+
+ setOpen((o) => !o)}>
+ {trigger}
+
+ {open && }
+
+ );
+}
diff --git a/web-ext/features/segments/panel/Panel.tsx b/web-ext/features/segments/panel/Panel.tsx
new file mode 100644
index 00000000..f58560c3
--- /dev/null
+++ b/web-ext/features/segments/panel/Panel.tsx
@@ -0,0 +1,112 @@
+import { useState } from "preact/hooks";
+import { addSegmentToConfig, removeSegmentFromConfig, splitSegmentAtTime } from "../factories";
+import type { SegmentSession } from "../session";
+import type { PlayStep, Segment, SegmentConfig } from "../types";
+import { ConfigManager } from "./ConfigManager";
+import { Header } from "./Header";
+import { SegmentList } from "./SegmentList";
+import { SequenceEditor, type StepActions } from "./SequenceEditor";
+import { panelStyle } from "./styles";
+
+export interface PanelProps {
+ session: SegmentSession;
+ playhead: () => { time: number; duration: number };
+}
+
+// Splits the segment under the playhead when there is one, else appends a
+// short segment after the last, else halves the last: the markers clamp each
+// segment against its neighbours and assume none overlap.
+function addSegment(config: SegmentConfig, time: number, duration: number): SegmentConfig {
+ const sorted = [...config.segments].sort((a, b) => a.startTime - b.startTime);
+ const under = sorted.find((s) => time > s.startTime + 0.1 && time < s.endTime - 0.1);
+ const split = under ? splitSegmentAtTime(config, under.id, time) : null;
+ if (split) return split;
+ const last = sorted.reduce((latest, s) => (latest && latest.endTime >= s.endTime ? latest : s), undefined);
+ const start = last ? last.endTime : 0;
+ if (last && duration - start < 1) return splitSegmentAtTime(config, last.id, (last.startTime + last.endTime) / 2) ?? config;
+ const end = Math.min(start + Math.min(30, (duration - start) * 0.5), duration);
+ return addSegmentToConfig(config, start, Math.min(duration, Math.max(start + 1, end)));
+}
+
+export function Panel({ session, playhead }: PanelProps) {
+ const { state } = session;
+ const [view, setView] = useState<"main" | "manage">("main");
+ const [collapsed, setCollapsed] = useState(false);
+ if (!state.active || !state.config) return ;
+ const config = state.config;
+
+ const updateStep = (stepId: string, change: (step: PlayStep) => PlayStep) =>
+ session.mutate((c) => ({ ...c, sequence: c.sequence.map((s) => (s.id === stepId ? change(s) : s)), updatedAt: Date.now() }));
+
+ const steps: StepActions = {
+ addStep: () => {
+ const first = config.segments[0]?.id;
+ if (!first) return;
+ session.mutate((c) => ({ ...c, sequence: [...c.sequence, { id: crypto.randomUUID(), segmentIds: [first], count: 1, playbackRate: null, perIterationRates: [] }], updatedAt: Date.now() }));
+ },
+ removeStep: (stepId) => session.mutate((c) => (c.sequence.length <= 1 ? c : { ...c, sequence: c.sequence.filter((s) => s.id !== stepId), updatedAt: Date.now() })),
+ addToStep: (stepId, segmentId) => updateStep(stepId, (s) => ({ ...s, segmentIds: [...s.segmentIds, segmentId] })),
+ removeFromStep: (stepId, index) => updateStep(stepId, (s) => (s.segmentIds.length <= 1 ? s : { ...s, segmentIds: s.segmentIds.filter((_, i) => i !== index) })),
+ reorder: (stepId, from, to) =>
+ updateStep(stepId, (s) => {
+ const ids = [...s.segmentIds];
+ const [moved] = ids.splice(from, 1);
+ ids.splice(to, 0, moved);
+ return { ...s, segmentIds: ids };
+ }),
+ setCount: (stepId, count) => updateStep(stepId, (s) => ({ ...s, count })),
+ setRate: (stepId, playbackRate) => updateStep(stepId, (s) => ({ ...s, playbackRate })),
+ setPerIterationRates: (stepId, perIterationRates) => updateStep(stepId, (s) => ({ ...s, perIterationRates })),
+ };
+
+ return (
+ <>
+
+
+ {view === "manage" ? (
+ setView("main")}
+ onLoad={(c) => (setView("main"), session.load(c))}
+ onRename={(id, label) => void session.updateSaved(id, { label })}
+ onShortcut={(id, shortcutKey) => void session.updateSaved(id, { shortcutKey })}
+ onSetDefault={(id) => void session.setDefault(id)}
+ onDelete={(id) => void session.deleteSaved(id)}
+ />
+ ) : (
+ <>
+ session.load(c)}
+ onPin={(pin) => void session.setPin(pin)}
+ onSaveDefault={() => void session.saveDefault()}
+ onSaveNamed={(label) => void session.saveNamed(label)}
+ onManage={() => setView("manage")}
+ onCollapse={() => setCollapsed((c) => !c)}
+ />
+ {!collapsed && (
+ <>
+ {
+ const { time, duration } = playhead();
+ session.mutate((c) => addSegment(c, time, duration || 100));
+ }}
+ onRemove={(id) => session.mutate((c) => removeSegmentFromConfig(c, id))}
+ onCount={steps.setCount}
+ onRate={steps.setRate}
+ />
+
+ >
+ )}
+ >
+ )}
+
+ >
+ );
+}
diff --git a/web-ext/features/segments/panel/SegmentList.tsx b/web-ext/features/segments/panel/SegmentList.tsx
new file mode 100644
index 00000000..084f026f
--- /dev/null
+++ b/web-ext/features/segments/panel/SegmentList.tsx
@@ -0,0 +1,56 @@
+import { formatClock } from "@/lib/duration";
+import { colorForIndex } from "../format";
+import type { SegmentConfig } from "../types";
+
+export interface SegmentListProps {
+ config: SegmentConfig;
+ onAdd: () => void;
+ onRemove: (segmentId: string) => void;
+ onCount: (stepId: string, count: number) => void;
+ onRate: (stepId: string, rate: number | null) => void;
+}
+
+export function SegmentList({ config, onAdd, onRemove, onCount, onRate }: SegmentListProps) {
+ const sorted = [...config.segments].sort((a, b) => a.startTime - b.startTime);
+ // Loop count and rate belong to steps; they are editable here only when
+ // there is a single step, where the mapping is unambiguous.
+ const step = config.sequence.length === 1 ? config.sequence[0] : null;
+
+ return (
+
+ );
+}
diff --git a/web-ext/features/segments/panel/SequenceEditor.tsx b/web-ext/features/segments/panel/SequenceEditor.tsx
new file mode 100644
index 00000000..d0943931
--- /dev/null
+++ b/web-ext/features/segments/panel/SequenceEditor.tsx
@@ -0,0 +1,120 @@
+import { useRef, useState } from "preact/hooks";
+import { formatClock } from "@/lib/duration";
+import { colorForIndex } from "../format";
+import type { PlayStep, SegmentConfig } from "../types";
+
+export interface StepActions {
+ addStep: () => void;
+ removeStep: (stepId: string) => void;
+ addToStep: (stepId: string, segmentId: string) => void;
+ removeFromStep: (stepId: string, index: number) => void;
+ reorder: (stepId: string, from: number, to: number) => void;
+ setCount: (stepId: string, count: number) => void;
+ setRate: (stepId: string, rate: number | null) => void;
+ setPerIterationRates: (stepId: string, rates: number[]) => void;
+}
+
+export function SequenceEditor({ config, actions }: { config: SegmentConfig; actions: StepActions }) {
+ const [open, setOpen] = useState(false);
+ return (
+
+
setOpen((o) => !o)}>{open ? "▼" : "▶"} Advanced Sequence
+ {open && (
+
+ {config.sequence.map((step, i) => )}
+ + Add Step
+
+ )}
+
+ );
+}
+
+const parseRates = (text: string) => text.split(",").map((v) => parseFloat(v.trim())).filter((v) => !isNaN(v) && v > 0);
+
+function StepRow({ config, step, index, actions }: { config: SegmentConfig; step: PlayStep; index: number; actions: StepActions }) {
+ const sorted = [...config.segments].sort((a, b) => a.startTime - b.startTime);
+ const dragFrom = useRef(null);
+ const [over, setOver] = useState(null);
+
+ return (
+
+
+
Step {index + 1}
+
+ {step.segmentIds.map((segmentId, chipIndex) => {
+ const segment = config.segments.find((s) => s.id === segmentId);
+ const position = segment ? sorted.indexOf(segment) : -1;
+ const label = position >= 0 ? `Seg ${position + 1}` : "?";
+ const range = segment ? ` (${formatClock(segment.startTime)}→${formatClock(segment.endTime)})` : "";
+ return (
+ = 0 ? colorForIndex(position) : "#666" }}
+ draggable
+ data-over={over === chipIndex ? "" : undefined}
+ title={`${label}${range} — drag to reorder`}
+ onDragStart={(e) => {
+ dragFrom.current = chipIndex;
+ e.dataTransfer?.setData("text/plain", String(chipIndex));
+ }}
+ onDragOver={(e) => (e.preventDefault(), setOver(chipIndex))}
+ onDragLeave={() => setOver(null)}
+ onDrop={(e) => {
+ e.preventDefault();
+ setOver(null);
+ const from = dragFrom.current;
+ dragFrom.current = null;
+ if (from !== null && from !== chipIndex) actions.reorder(step.id, from, chipIndex);
+ }}
+ >
+ ⠿
+ {label}
+ actions.removeFromStep(step.id, chipIndex)}>✕
+
+ );
+ })}
+
+
{
+ if (e.currentTarget.value) actions.addToStep(step.id, e.currentTarget.value);
+ e.currentTarget.value = "";
+ }}
+ >
+ + Seg
+ {sorted.map((s, i) => Seg {i + 1} )}
+
+
actions.removeStep(step.id)}>✕
+
+
+
+ Count
+ actions.setCount(step.id, Math.max(0, parseInt(e.currentTarget.value, 10) || 0))} />
+ (0=∞)
+
+
+ Rate
+ {
+ const raw = e.currentTarget.value.trim();
+ actions.setRate(step.id, raw === "" ? null : parseFloat(raw));
+ }}
+ />
+
+
+ Per-iter
+ actions.setPerIterationRates(step.id, parseRates(e.currentTarget.value))} />
+
+
+
+ );
+}
diff --git a/web-ext/features/segments/panel/styles.ts b/web-ext/features/segments/panel/styles.ts
new file mode 100644
index 00000000..5e33d16a
--- /dev/null
+++ b/web-ext/features/segments/panel/styles.ts
@@ -0,0 +1,75 @@
+import { themeTokens } from "@/kernel/dom/theme";
+
+export const panelStyle = `
+ ${themeTokens}
+ :host { display: block; }
+ * { box-sizing: border-box; }
+ .panel {
+ margin: 8px 0 4px; padding: 12px 16px; border-radius: 12px;
+ background: var(--tp-raised); border: 1px solid var(--tp-outline); color: var(--tp-text);
+ font: 13px/1.4 var(--tp-font);
+ }
+ .row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
+ .header { margin-bottom: 10px; }
+ .title { font-weight: 700; letter-spacing: .01em; }
+ .grow { flex: 1; }
+ .stack { display: flex; flex-direction: column; gap: 6px; }
+ button, input, select { font: inherit; color: inherit; }
+ .btn {
+ cursor: pointer; border: 0; border-radius: 4px; padding: 3px 8px; font-size: 12px; line-height: 1.4;
+ background: transparent; color: var(--tp-text-2);
+ }
+ .btn:hover { background: var(--tp-additive); color: var(--tp-text); }
+ .btn.accent { background: var(--tp-accent); color: #fff; }
+ .btn.accent:hover { opacity: .9; }
+ .btn.chip { background: var(--tp-additive); color: var(--tp-text-2); padding: 2px 8px; }
+ .btn.active { color: var(--tp-accent); }
+ .btn.tiny { padding: 2px 5px; font-size: 11px; }
+ .input {
+ width: 42px; padding: 2px 4px; border-radius: 4px; text-align: right; font-size: 11px;
+ background: var(--tp-additive); border: 1px solid var(--tp-outline); color: var(--tp-text);
+ }
+ .input.text { width: auto; flex: 1; text-align: left; font-size: 12px; padding: 4px 8px; }
+ .input.short { width: 70px; text-align: left; }
+ .dropdown { position: relative; display: inline-block; }
+ .menu {
+ position: absolute; top: calc(100% + 4px); z-index: 9999; min-width: 170px; padding: 4px; border-radius: 8px;
+ background: var(--tp-menu); border: 1px solid var(--tp-outline); box-shadow: 0 8px 24px rgba(0, 0, 0, .4);
+ }
+ .menu.left { left: 0; } .menu.right { right: 0; }
+ .menu .item {
+ display: block; width: 100%; text-align: left; cursor: pointer; border: 0; border-radius: 4px; padding: 7px 10px;
+ font-size: 12px; background: transparent; color: var(--tp-text);
+ }
+ .menu .item:hover { background: var(--tp-additive); }
+ .menu .item.active { color: var(--tp-accent); font-weight: 600; }
+ .menu .item.muted { color: var(--tp-text-2); font-size: 11px; }
+ .menu .hint { padding: 4px 10px 6px; font-size: 11px; color: var(--tp-text-2); border-bottom: 1px solid var(--tp-outline); margin-bottom: 4px; }
+ .menu .divider { border-top: 1px solid var(--tp-outline); margin-top: 4px; padding-top: 4px; }
+ .empty { padding: 8px 10px; font-size: 12px; color: var(--tp-text-2); font-style: italic; }
+ .segment {
+ display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 8px;
+ background: var(--tp-additive); border-left: 3px solid var(--seg);
+ }
+ .segment .index { color: var(--seg); font-weight: 700; min-width: 18px; font-size: 12px; }
+ .segment .range { flex: 1; font-variant-numeric: tabular-nums; font-size: 12px; }
+ .segment .note { font-size: 11px; color: var(--tp-text-2); font-style: italic; }
+ .field { display: flex; align-items: center; gap: 4px; font-size: 11px; color: var(--tp-text-2); }
+ .step { padding: 8px 10px; border-radius: 8px; background: var(--tp-additive); display: flex; flex-direction: column; gap: 6px; }
+ .step .name { font-size: 11px; font-weight: 600; color: var(--tp-text-2); min-width: 44px; }
+ .chips { display: flex; gap: 4px; flex-wrap: wrap; flex: 1; min-width: 0; }
+ .segchip {
+ display: inline-flex; align-items: center; gap: 4px; padding: 2px 6px 2px 8px; border-radius: 4px; font-size: 11px;
+ cursor: grab; user-select: none; background: color-mix(in srgb, var(--seg) 15%, transparent); border: 1px solid var(--seg);
+ }
+ .segchip[data-over] { outline: 2px solid var(--seg); }
+ .segchip .grip { opacity: .4; font-size: 9px; }
+ .segchip .x { background: none; border: 0; cursor: pointer; padding: 0; font-size: 10px; line-height: 1; opacity: .6; }
+ .select { padding: 2px 4px; border-radius: 4px; font-size: 11px; background: var(--tp-additive); border: 1px solid var(--tp-outline); }
+ .saved { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; padding: 6px 10px; border-radius: 8px; background: var(--tp-additive); border-left: 3px solid transparent; }
+ .saved.loaded { border-left-color: var(--tp-accent); background: color-mix(in srgb, var(--tp-accent) 10%, transparent); }
+ .saved .name { flex: 1; text-align: left; background: none; border: 0; cursor: pointer; padding: 0; font-size: 13px; }
+ .saved.loaded .name { font-weight: 700; }
+ .badge { font-size: 10px; color: var(--tp-accent); border: 1px solid var(--tp-accent); border-radius: 3px; padding: 1px 5px; }
+ .disclosure { width: 100%; text-align: left; padding: 4px 0; }
+`;
diff --git a/web-ext/features/segments/session.test.ts b/web-ext/features/segments/session.test.ts
new file mode 100644
index 00000000..7453b1bf
--- /dev/null
+++ b/web-ext/features/segments/session.test.ts
@@ -0,0 +1,180 @@
+import { beforeEach, describe, expect, spyOn, test } from "bun:test";
+import { createFreshConfig } from "./factories";
+import { SegmentSession } from "./session";
+import { memorySegmentStorage, openVideoSegments } from "./store";
+
+function fakeVideo(duration = 100): HTMLVideoElement {
+ const video = document.createElement("video");
+ Object.defineProperty(video, "duration", { value: duration, configurable: true });
+ Object.defineProperty(video, "readyState", { value: 1, configurable: true });
+ return video;
+}
+
+const settings = { enabled: true, autoLoad: "off" as const, nudgeBaseStep: 1, nudgeMultiplier: 2, nudgeMaxStep: 4 };
+let toasts: string[];
+let video: HTMLVideoElement;
+let store: ReturnType;
+let session: SegmentSession;
+
+beforeEach(async () => {
+ toasts = [];
+ video = fakeVideo();
+ store = openVideoSegments(memorySegmentStorage(), "v1");
+ session = new SegmentSession({ video, store, settings, toast: (m) => toasts.push(m) });
+ await session.init();
+});
+
+describe("SegmentSession", () => {
+ test("toggle starts a fresh slate, then remembers it as last used", async () => {
+ await session.toggle();
+ expect(session.state.active).toBe(true);
+ expect(session.state.config?.segments).toHaveLength(1);
+ expect(session.state.config?.segments[0].endTime).toBe(100);
+
+ session.deactivate();
+ expect(session.state.active).toBe(false);
+ expect((await store.getLastUsed())?.id).toBe(session.state.config?.id);
+ });
+
+ test("set start and end move the segment under the playhead", async () => {
+ await session.toggle();
+ video.currentTime = 20;
+ session.setStart();
+ video.currentTime = 60;
+ session.setEnd();
+ const [segment] = session.state.config!.segments;
+ expect(segment.startTime).toBe(20);
+ expect(segment.endTime).toBe(60);
+ expect((await store.getLastUsed())?.segments[0].endTime).toBe(60);
+ });
+
+ test("repeated nudges accelerate up to the maximum", async () => {
+ await session.toggle();
+ video.currentTime = 50;
+ session.setStart();
+ for (const _ of [1, 2, 3, 4]) session.nudgeMarker("start", "backward");
+ // 1 + 2 + 4 + 4 = 11 seconds back from 50
+ expect(session.state.config!.segments[0].startTime).toBe(39);
+ expect(video.currentTime).toBe(39);
+ });
+
+ test("named saves land in the saved list and become the active label", async () => {
+ await session.toggle();
+ await session.saveNamed("Chorus");
+ expect(session.state.saved.map((c) => c.label)).toEqual(["Chorus"]);
+ expect(session.state.config?.label).toBe("Chorus");
+ await session.saveDefault();
+ expect(session.state.defaultId).toBe(session.state.config!.id);
+ });
+
+ test("restore honours the global setting and a per-video pin", async () => {
+ const config = createFreshConfig(100);
+ await store.saveConfig({ ...config, label: "Pinned" });
+ await store.setDefaultConfig(config.id);
+ await session.restore();
+ expect(session.state.active).toBe(false);
+
+ const withDefault = new SegmentSession({ video, store, settings: { ...settings, autoLoad: "default" }, toast: (m) => toasts.push(m) });
+ await withDefault.restore();
+ expect(withDefault.state.config?.label).toBe("Pinned");
+ expect(toasts).toContain("↺ Segments restored");
+
+ await store.setPin("off");
+ const pinnedOff = new SegmentSession({ video, store, settings: { ...settings, autoLoad: "default" }, toast: () => undefined });
+ await pinnedOff.restore();
+ expect(pinnedOff.state.active).toBe(false);
+ });
+
+ test("merge joins two segments into one", async () => {
+ await session.toggle();
+ const a = session.state.config!.segments[0];
+ session.mutate((c) => ({
+ ...c,
+ segments: [{ ...a, endTime: 40 }, { id: "b", startTime: 40, endTime: 100 }],
+ sequence: [{ ...c.sequence[0], segmentIds: [a.id, "b"] }],
+ }));
+ session.merge(a.id, "b");
+ expect(session.state.config!.segments).toHaveLength(1);
+ expect(session.state.config!.segments[0].endTime).toBe(100);
+ expect(session.state.config!.sequence[0].segmentIds).toEqual([a.id]);
+ });
+
+ test("save as named keeps the config it was saved from", async () => {
+ await session.toggle();
+ await session.saveNamed("First");
+ await session.updateSaved(session.state.config!.id, { shortcutKey: "Ctrl+1" });
+ session.load(session.state.saved[0]);
+ video.currentTime = 20;
+ session.setStart();
+ await session.saveNamed("Second");
+ expect(session.state.saved.map((c) => c.label)).toEqual(["First", "Second"]);
+ expect(session.state.saved[0].segments[0].startTime).toBe(0);
+ expect(session.state.saved[1].segments[0].startTime).toBe(20);
+ expect(session.state.saved.map((c) => c.shortcutKey)).toEqual(["Ctrl+1", ""]);
+ });
+
+ test("nothing activates once disposed, even if storage answers later", async () => {
+ const config = createFreshConfig(100);
+ await store.saveConfig({ ...config, segments: [{ ...config.segments[0], startTime: 10, endTime: 20 }] });
+ await store.setDefaultConfig(config.id);
+ const restoring = new SegmentSession({ video, store, settings: { ...settings, autoLoad: "default" }, toast: (m) => toasts.push(m) });
+ video.currentTime = 50;
+ const pending = restoring.restore();
+ restoring.dispose();
+ await pending;
+ expect(restoring.state.active).toBe(false);
+ expect(video.currentTime).toBe(50);
+ expect(toasts).not.toContain("↺ Segments restored");
+
+ const toggling = new SegmentSession({ video, store, settings, toast: () => undefined });
+ const toggled = toggling.toggle();
+ toggling.dispose();
+ await toggled;
+ expect(toggling.state.active).toBe(false);
+ });
+
+ test("segments stay off until the video has a finite duration", async () => {
+ Object.defineProperty(video, "duration", { value: NaN, configurable: true });
+ await session.toggle();
+ session.fresh();
+ expect(session.state.active).toBe(false);
+ expect(await store.getLastUsed()).toBeNull();
+
+ Object.defineProperty(video, "duration", { value: Infinity, configurable: true });
+ await session.toggle();
+ expect(session.state.active).toBe(false);
+ });
+
+ test("segments stay off while an ad plays in the video", async () => {
+ const player = document.createElement("div");
+ player.className = "html5-video-player ad-showing";
+ player.append(video);
+ await session.toggle();
+ expect(session.state.active).toBe(false);
+ player.classList.remove("ad-showing");
+ await session.toggle();
+ expect(session.state.active).toBe(true);
+ });
+
+ test("toggle still starts a fresh slate when storage fails", async () => {
+ const failing = new SegmentSession({
+ video,
+ store: { ...store, getLastUsed: () => Promise.reject(new Error("blocked")) },
+ settings,
+ toast: () => undefined,
+ });
+ const logged = spyOn(console, "error").mockImplementation(() => undefined);
+ await failing.toggle();
+ expect(logged).toHaveBeenCalled();
+ logged.mockRestore();
+ expect(failing.state.active).toBe(true);
+ });
+
+ test("the save key clears the last-used slate while inactive", async () => {
+ await session.toggle();
+ session.deactivate();
+ expect(await store.getLastUsed()).not.toBeNull();
+ await session.save();
+ expect(await store.getLastUsed()).toBeNull();
+ });
+});
diff --git a/web-ext/features/segments/session.ts b/web-ext/features/segments/session.ts
new file mode 100644
index 00000000..f3a69563
--- /dev/null
+++ b/web-ext/features/segments/session.ts
@@ -0,0 +1,270 @@
+import { isAdShowing } from "@/youtube/player";
+import { SegmentEngine } from "./engine";
+import { createFreshConfig, removeSegmentFromConfig, updateSegmentTimes } from "./factories";
+import type { SegmentsSettings } from "./settings";
+import type { VideoSegments } from "./store";
+import type { Segment, SegmentAutoLoadPin, SegmentConfig, SegmentId } from "./types";
+
+export interface SessionState {
+ active: boolean;
+ config: SegmentConfig | null;
+ saved: SegmentConfig[];
+ defaultId: string | null;
+ pin: SegmentAutoLoadPin;
+}
+
+export interface SessionDeps {
+ video: HTMLVideoElement;
+ store: VideoSegments;
+ settings: SegmentsSettings;
+ toast: (message: string) => void;
+}
+
+type Marker = "start" | "end";
+type Direction = "forward" | "backward";
+
+const MIN_SEGMENT = 0.1;
+const NUDGE_RESET_GAP_MS = 600;
+const NOT_READY = "Segments need a loaded video, not an ad or a live stream";
+
+// Owns one video's segment state: what is loaded, whether it plays, and every
+// change to it. UI and keys call in; the session persists and notifies.
+export class SegmentSession {
+ state: SessionState = { active: false, config: null, saved: [], defaultId: null, pin: null };
+
+ private engine: SegmentEngine | null = null;
+ private disposed = false;
+ private listeners = new Set<() => void>();
+ private nudge = { marker: null as Marker | null, direction: null as Direction | null, step: 1, at: 0 };
+
+ constructor(private deps: SessionDeps) {}
+
+ subscribe(listener: () => void): () => void {
+ this.listeners.add(listener);
+ return () => this.listeners.delete(listener);
+ }
+
+ // Storage failures degrade to "nothing saved" rather than a dead feature.
+ async init(): Promise {
+ try {
+ await this.refreshSaved();
+ } catch (error) {
+ console.error("[toppings] segment storage unavailable", error);
+ }
+ }
+
+ // A pre-roll ad plays in the same , so its duration is the ad's and
+ // any segment clamped or created against it would be cut to the ad's length.
+ get ready(): boolean {
+ return Number.isFinite(this.deps.video.duration) && this.deps.video.duration > 0 && !isAdShowing(this.deps.video);
+ }
+
+ async restore(): Promise {
+ const config = await this.deps.store.autoLoadConfig(this.deps.settings.autoLoad);
+ if (config && this.activate(this.clampToDuration(config))) this.deps.toast("↺ Segments restored");
+ }
+
+ async toggle(): Promise {
+ if (this.state.active) {
+ this.deactivate();
+ return;
+ }
+ if (!this.ready) {
+ this.deps.toast(NOT_READY);
+ return;
+ }
+ let lastUsed: SegmentConfig | null = null;
+ try {
+ lastUsed = await this.deps.store.getLastUsed();
+ } catch (error) {
+ console.error("[toppings] segment storage unavailable", error);
+ }
+ this.activate(lastUsed ? this.clampToDuration(lastUsed) : createFreshConfig(this.duration));
+ }
+
+ fresh(): void {
+ if (this.activate(createFreshConfig(this.duration))) this.deps.toast("Fresh segments slate");
+ else this.deps.toast(NOT_READY);
+ }
+
+ // Also refuses after dispose, since restore and toggle resume from storage
+ // reads that can finish after the user has navigated away.
+ activate(config: SegmentConfig): boolean {
+ if (this.disposed || !this.ready) return false;
+ this.engine?.stop();
+ this.engine = new SegmentEngine(this.deps.video, config, () => isAdShowing(this.deps.video));
+ this.engine.start();
+ this.set({ active: true, config });
+ return true;
+ }
+
+ deactivate(): void {
+ this.engine?.stop();
+ this.engine = null;
+ if (this.state.config) void this.deps.store.setLastUsed(this.state.config);
+ this.set({ active: false });
+ }
+
+ load(config: SegmentConfig): void {
+ this.deps.toast(this.activate(this.clampToDuration(config)) ? `Segments: ${config.label}` : NOT_READY);
+ }
+
+ mutate(update: (config: SegmentConfig) => SegmentConfig): void {
+ if (!this.state.config) return;
+ this.commit(update(this.state.config));
+ }
+
+ // Applied to the engine on every pointer move, persisted once the drag ends.
+ preview(segments: Segment[]): void {
+ if (!this.state.config) return;
+ const config = { ...this.state.config, segments, updatedAt: Date.now() };
+ this.engine?.setConfig(config);
+ this.set({ config });
+ }
+
+ commitPreview(): void {
+ if (this.state.config) void this.deps.store.setLastUsed(this.state.config);
+ }
+
+ merge(keepId: SegmentId, removeId: SegmentId): void {
+ const config = this.state.config;
+ const keep = config?.segments.find((s) => s.id === keepId);
+ const remove = config?.segments.find((s) => s.id === removeId);
+ if (!config || !keep || !remove) return;
+ this.commit(removeSegmentFromConfig(updateSegmentTimes(config, keepId, keep.startTime, remove.endTime), removeId));
+ this.deps.toast("Segments merged");
+ }
+
+ setStart(): void {
+ const segment = this.segmentAtPlayhead();
+ if (!segment) return;
+ const start = Math.max(0, Math.min(this.deps.video.currentTime, segment.endTime - MIN_SEGMENT));
+ this.mutate((c) => updateSegmentTimes(c, segment.id, start, segment.endTime));
+ }
+
+ setEnd(): void {
+ const segment = this.segmentAtPlayhead();
+ if (!segment) return;
+ const end = Math.max(segment.startTime + MIN_SEGMENT, Math.min(this.deps.video.currentTime, this.duration));
+ this.mutate((c) => updateSegmentTimes(c, segment.id, segment.startTime, end));
+ }
+
+ nudgeMarker(marker: Marker, direction: Direction): void {
+ const segment = this.segmentAtPlayhead();
+ if (!segment || !this.duration) return;
+ const step = this.nextNudgeStep(marker, direction);
+ const delta = direction === "forward" ? step : -step;
+ if (marker === "start") {
+ const start = Math.max(0, Math.min(segment.startTime + delta, segment.endTime - MIN_SEGMENT));
+ this.mutate((c) => updateSegmentTimes(c, segment.id, start, segment.endTime));
+ this.deps.video.currentTime = start;
+ } else {
+ const end = Math.max(segment.startTime + MIN_SEGMENT, Math.min(segment.endTime + delta, this.duration));
+ this.mutate((c) => updateSegmentTimes(c, segment.id, segment.startTime, end));
+ }
+ }
+
+ // The save key means "keep this" while active and "forget the slate" while off.
+ async save(): Promise {
+ if (this.state.active && this.state.config) {
+ await this.saveDefault();
+ return;
+ }
+ await this.deps.store.setLastUsed(null);
+ this.deps.toast("Last-used segments cleared");
+ }
+
+ async saveDefault(): Promise {
+ if (!this.state.config) return;
+ const config = { ...this.state.config, updatedAt: Date.now() };
+ await this.deps.store.saveConfig(config);
+ await this.deps.store.setDefaultConfig(config.id);
+ await this.refreshSaved();
+ this.set({ config });
+ this.deps.toast("Saved to default slot ✓");
+ }
+
+ async saveNamed(label: string): Promise {
+ if (!this.state.config || !label.trim()) return;
+ const now = Date.now();
+ const config = { ...this.state.config, id: crypto.randomUUID(), label: label.trim(), shortcutKey: "", createdAt: now, updatedAt: now };
+ await this.deps.store.saveConfig(config);
+ await this.refreshSaved();
+ this.commit(config);
+ this.deps.toast(`Saved as "${config.label}" ✓`);
+ }
+
+ async setPin(pin: SegmentAutoLoadPin): Promise {
+ await this.deps.store.setPin(pin);
+ this.set({ pin });
+ }
+
+ async updateSaved(configId: string, change: Partial>): Promise {
+ const saved = this.state.saved.find((c) => c.id === configId);
+ if (!saved) return;
+ await this.deps.store.saveConfig({ ...saved, ...change, updatedAt: Date.now() });
+ await this.refreshSaved();
+ }
+
+ async deleteSaved(configId: string): Promise {
+ await this.deps.store.deleteConfig(configId);
+ await this.refreshSaved();
+ }
+
+ async setDefault(configId: string): Promise {
+ await this.deps.store.setDefaultConfig(configId);
+ await this.refreshSaved();
+ }
+
+ dispose(): void {
+ this.disposed = true;
+ this.engine?.stop();
+ this.engine = null;
+ this.listeners.clear();
+ }
+
+ private get duration(): number {
+ return this.deps.video.duration || 0;
+ }
+
+ private segmentAtPlayhead(): Segment | null {
+ return this.state.active ? (this.engine?.getSegmentAt(this.deps.video.currentTime) ?? null) : null;
+ }
+
+ private commit(config: SegmentConfig): void {
+ this.engine?.setConfig(config);
+ this.set({ config });
+ void this.deps.store.setLastUsed(config);
+ }
+
+ private async refreshSaved(): Promise {
+ const { configs, defaultConfigId, autoLoadPin } = await this.deps.store.data();
+ this.set({ saved: configs, defaultId: defaultConfigId, pin: autoLoadPin ?? null });
+ }
+
+ private set(patch: Partial): void {
+ this.state = { ...this.state, ...patch };
+ for (const listener of this.listeners) listener();
+ }
+
+ private nextNudgeStep(marker: Marker, direction: Direction): number {
+ const { nudgeBaseStep, nudgeMultiplier, nudgeMaxStep } = this.deps.settings;
+ const now = Date.now();
+ const repeated = this.nudge.marker === marker && this.nudge.direction === direction && now - this.nudge.at < NUDGE_RESET_GAP_MS;
+ const step = repeated ? Math.min(this.nudge.step * nudgeMultiplier, nudgeMaxStep) : nudgeBaseStep;
+ this.nudge = { marker, direction, step, at: now };
+ return step;
+ }
+
+ private clampToDuration(config: SegmentConfig): SegmentConfig {
+ const duration = this.duration;
+ if (!duration) return config;
+ return {
+ ...config,
+ segments: config.segments.map((s) => {
+ const startTime = Math.max(0, Math.min(s.startTime, duration - MIN_SEGMENT));
+ return { ...s, startTime, endTime: Math.max(startTime + MIN_SEGMENT, Math.min(s.endTime, duration)) };
+ }),
+ };
+ }
+}
diff --git a/web-ext/features/segments/settings.ts b/web-ext/features/segments/settings.ts
new file mode 100644
index 00000000..0c8e640e
--- /dev/null
+++ b/web-ext/features/segments/settings.ts
@@ -0,0 +1,38 @@
+import { defineSettings } from "@/kernel/settings";
+import type { AutoLoad } from "./store";
+
+export interface SegmentsSettings {
+ enabled: boolean;
+ autoLoad: AutoLoad;
+ // Repeated nudges in the same direction grow from base by multiplier up to max.
+ nudgeBaseStep: number;
+ nudgeMultiplier: number;
+ nudgeMaxStep: number;
+}
+
+interface LegacyWatch {
+ isEnabled?: boolean;
+ segments?: { autoLoad?: AutoLoad };
+ nudgeLoopSegment?: { baseStep?: string; multiplier?: string; maxStep?: string };
+}
+
+const number = (value: string | undefined) => (value === undefined ? undefined : Number(value));
+
+export const segmentsSettings = defineSettings(
+ "segments",
+ { enabled: true, autoLoad: "off", nudgeBaseStep: 1, nudgeMultiplier: 2, nudgeMaxStep: 16 },
+ {
+ legacy: (store) => {
+ const watch = (store.preferences as { watch?: LegacyWatch } | undefined)?.watch;
+ if (!watch) return undefined;
+ // The old watch-page switch covered segments as well as playback.
+ return {
+ enabled: watch.isEnabled,
+ autoLoad: watch.segments?.autoLoad,
+ nudgeBaseStep: number(watch.nudgeLoopSegment?.baseStep),
+ nudgeMultiplier: number(watch.nudgeLoopSegment?.multiplier),
+ nudgeMaxStep: number(watch.nudgeLoopSegment?.maxStep),
+ };
+ },
+ },
+);
diff --git a/web-ext/features/segments/store.ts b/web-ext/features/segments/store.ts
new file mode 100644
index 00000000..888cca2c
--- /dev/null
+++ b/web-ext/features/segments/store.ts
@@ -0,0 +1,122 @@
+import { withStore } from "@/lib/indexedDb";
+import { BROWSER_STORAGE_IDB_STORE } from "@/lib/storageKeys";
+import type { SegmentAutoLoadPin, SegmentConfig, VideoSegmentData } from "./types";
+
+export type AutoLoad = "off" | "last-used" | "default";
+
+// The persistence boundary, so the session can be driven by an in-memory
+// store in tests and by IndexedDB in the extension.
+export interface SegmentStorage {
+ read(videoId: string): Promise;
+ write(data: VideoSegmentData): Promise;
+}
+
+interface LegacyLoopSegment {
+ videoId: string;
+ startTime: number;
+ endTime: number;
+ savedAt: number;
+}
+
+// A record from before segments replaced single loops is converted on first
+// read and written back, so the conversion happens once.
+async function readLegacyLoop(videoId: string): Promise {
+ let legacy: LegacyLoopSegment | undefined;
+ try {
+ legacy = await withStore(BROWSER_STORAGE_IDB_STORE.LOOP_SEGMENT, "readonly", (store) => store.get(videoId));
+ } catch {
+ return null;
+ }
+ if (!legacy) return null;
+ const segmentId = crypto.randomUUID();
+ const config: SegmentConfig = {
+ id: crypto.randomUUID(),
+ label: "Restored Loop",
+ segments: [{ id: segmentId, startTime: legacy.startTime, endTime: legacy.endTime }],
+ sequence: [{ id: crypto.randomUUID(), segmentIds: [segmentId], count: 0, playbackRate: null, perIterationRates: [] }],
+ shortcutKey: "",
+ createdAt: legacy.savedAt,
+ updatedAt: legacy.savedAt,
+ };
+ const data: VideoSegmentData = { videoId, lastUsed: config, configs: [], defaultConfigId: null, savedAt: legacy.savedAt };
+ await indexedDbSegmentStorage.write(data);
+ return data;
+}
+
+export const indexedDbSegmentStorage: SegmentStorage = {
+ async read(videoId) {
+ const existing = await withStore(BROWSER_STORAGE_IDB_STORE.SEGMENT_DATA, "readonly", (store) => store.get(videoId));
+ return existing ?? readLegacyLoop(videoId);
+ },
+ async write(data) {
+ await withStore(BROWSER_STORAGE_IDB_STORE.SEGMENT_DATA, "readwrite", (store) => store.put({ ...data, savedAt: Date.now() }));
+ },
+};
+
+export function memorySegmentStorage(): SegmentStorage {
+ const records = new Map();
+ return {
+ async read(videoId) {
+ return records.get(videoId) ?? null;
+ },
+ async write(data) {
+ records.set(data.videoId, data);
+ },
+ };
+}
+
+export interface VideoSegments {
+ data(): Promise;
+ getLastUsed(): Promise;
+ setLastUsed(config: SegmentConfig | null): Promise;
+ saveConfig(config: SegmentConfig): Promise;
+ deleteConfig(configId: string): Promise;
+ setDefaultConfig(configId: string | null): Promise;
+ setPin(pin: SegmentAutoLoadPin): Promise;
+ // null means "leave segments off"; callers must not substitute a fresh config.
+ autoLoadConfig(globalAutoLoad: AutoLoad): Promise;
+}
+
+// Everything the feature stores about one video, with the per-video record
+// read and written as a unit.
+export function openVideoSegments(storage: SegmentStorage, videoId: string): VideoSegments {
+ const empty = (): VideoSegmentData => ({ videoId, lastUsed: null, configs: [], defaultConfigId: null, savedAt: 0 });
+ // Writes are read-modify-write, so they run one at a time, and a read waits
+ // for the writes queued before it; rapid edits then never clobber each other.
+ let queue: Promise = Promise.resolve();
+ const data = async () => {
+ await queue;
+ return (await storage.read(videoId)) ?? empty();
+ };
+ const patch = (change: Partial) => {
+ const write = queue.then(async () => storage.write({ ...((await storage.read(videoId)) ?? empty()), ...change }));
+ queue = write.catch(() => undefined);
+ return write;
+ };
+
+ return {
+ data,
+ getLastUsed: async () => (await data()).lastUsed,
+ setLastUsed: (lastUsed) => patch({ lastUsed }),
+ async saveConfig(config) {
+ const { configs } = await data();
+ const known = configs.some((c) => c.id === config.id);
+ await patch({ configs: known ? configs.map((c) => (c.id === config.id ? config : c)) : [...configs, config] });
+ },
+ async deleteConfig(configId) {
+ const { configs, defaultConfigId } = await data();
+ await patch({ configs: configs.filter((c) => c.id !== configId), defaultConfigId: defaultConfigId === configId ? null : defaultConfigId });
+ },
+ setDefaultConfig: (defaultConfigId) => patch({ defaultConfigId }),
+ setPin: (autoLoadPin) => patch({ autoLoadPin }),
+ async autoLoadConfig(globalAutoLoad) {
+ const record = await data();
+ // A per-video pin outranks the global preference.
+ const effective = record.autoLoadPin ?? globalAutoLoad;
+ if (effective === "off") return null;
+ if (effective === "last-used") return record.lastUsed;
+ const wanted = effective === "default" ? record.defaultConfigId : effective.configId;
+ return record.configs.find((c) => c.id === wanted) ?? null;
+ },
+ };
+}
diff --git a/web-ext/features/segments/types.ts b/web-ext/features/segments/types.ts
new file mode 100644
index 00000000..6d330f9f
--- /dev/null
+++ b/web-ext/features/segments/types.ts
@@ -0,0 +1,50 @@
+export type SegmentId = string;
+export type StepId = string;
+export type ConfigId = string;
+
+export interface Segment {
+ id: SegmentId;
+ startTime: number; // seconds
+ endTime: number; // seconds
+ label?: string;
+}
+
+// Repeats in `segmentIds` are intentional and are the only way to express an
+// arrangement like "seg3 twice, then seg1" — there is no count-per-reference field.
+export interface PlayStep {
+ id: StepId;
+ segmentIds: SegmentId[];
+ count: number; // full cycles through segmentIds; 0 loops forever
+ playbackRate: number | null; // null keeps the video's current rate
+ perIterationRates: number[]; // per cycle; last entry repeats, empty falls back to playbackRate
+}
+
+// The engine wraps from the last step back to step 0, so a sequence loops
+// globally unless its final step sets count = 0 and loops on its own.
+export interface SegmentConfig {
+ id: ConfigId;
+ label: string; // auto-generated when the user leaves it blank
+ segments: Segment[];
+ sequence: PlayStep[];
+ shortcutKey: string; // "" = unbound, e.g. "Ctrl+1"
+ createdAt: number; // unix ms
+ updatedAt: number;
+}
+
+// null defers to the global preference; "off" actively suppresses auto-load for
+// this one video. The two are not interchangeable.
+export type SegmentAutoLoadPin =
+ | null
+ | "off"
+ | "last-used"
+ | "default"
+ | { configId: string };
+
+export interface VideoSegmentData {
+ videoId: string;
+ lastUsed: SegmentConfig | null; // volatile snapshot, written without an explicit save
+ configs: SegmentConfig[];
+ defaultConfigId: string | null;
+ autoLoadPin?: SegmentAutoLoadPin;
+ savedAt: number;
+}
diff --git a/web-ext/features/shorts/Controls.tsx b/web-ext/features/shorts/Controls.tsx
new file mode 100644
index 00000000..d334f44d
--- /dev/null
+++ b/web-ext/features/shorts/Controls.tsx
@@ -0,0 +1,32 @@
+import { credit } from "@/kernel/dom/credit";
+
+export interface ControlsProps {
+ autoScroll: boolean;
+ fastRate: number;
+ isFast: boolean;
+ onToggleAutoScroll: () => void;
+ onToggleRate: () => void;
+}
+
+// Two round buttons in the reel's action column, drawn like YouTube's own.
+const styles = `
+ .stack { display: flex; flex-direction: column; gap: 16px; align-items: center; margin-bottom: 16px; }
+ button { width: 48px; height: 48px; border: 0; border-radius: 50%; cursor: pointer;
+ background: rgba(255,255,255,0.1); color: #fff; font: 500 12px/1 "Roboto", "Arial", sans-serif; }
+ button:hover { background: rgba(255,255,255,0.2); }
+ button.on { background: rgba(255,255,255,0.3); }
+`;
+
+export function Controls({ autoScroll, fastRate, isFast, onToggleAutoScroll, onToggleRate }: ControlsProps) {
+ return (
+
+
+
+ Auto
+
+
+ {fastRate}×
+
+
+ );
+}
diff --git a/web-ext/features/shorts/index.test.ts b/web-ext/features/shorts/index.test.ts
new file mode 100644
index 00000000..8df25ca2
--- /dev/null
+++ b/web-ext/features/shorts/index.test.ts
@@ -0,0 +1,121 @@
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import { shorts } from "./index";
+import { shortsSettings } from "./settings";
+
+// The older layout with is-active and #actions inside the renderer, and the
+// newer one with a reused renderer and an extracted action bar.
+const layouts = {
+ active: `
+
`,
+ extracted: `
+
+
`,
+};
+const reel = layouts.active;
+
+const route = { name: "shorts", shortId: "s1" } as const;
+const press = (key: string) => document.body.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true }));
+const video = () => document.querySelector("video") as HTMLVideoElement;
+const controls = () => document.getElementById("tppng-shorts-controls")!.shadowRoot!;
+const panel = (target: string, visibility: "EXPANDED" | "HIDDEN") =>
+ ` `;
+const countNextClicks = () => {
+ const clicks = { count: 0 };
+ document.querySelector("#navigation-button-down button")!.addEventListener("click", () => clicks.count++);
+ return clicks;
+};
+
+beforeEach(async () => {
+ await chrome.storage.sync.clear();
+ document.body.innerHTML = reel;
+ video().currentTime = 30;
+});
+afterEach(() => {
+ document.body.innerHTML = "";
+});
+
+describe("shorts", () => {
+ for (const [name, html] of Object.entries(layouts)) {
+ test(`puts the two controls in the action column (${name} layout)`, async () => {
+ document.body.innerHTML = html;
+ const unmount = await shorts.mount({ signal: new AbortController().signal, route });
+ const buttons = [...controls().querySelectorAll("button")].map((b) => b.textContent);
+ expect(buttons).toEqual(["Auto", "1.5×"]);
+ unmount?.();
+ expect(document.getElementById("tppng-shorts-controls")).toBeNull();
+ });
+ }
+
+ test("seek keys move the playhead by the configured amount", async () => {
+ await shortsSettings.set({ seekForward: 7 });
+ const unmount = await shorts.mount({ signal: new AbortController().signal, route });
+ press("d");
+ expect(video().currentTime).toBe(37);
+ press("a");
+ expect(video().currentTime).toBe(32);
+ unmount?.();
+ });
+
+ test("the rate key toggles between 1 and the configured rate", async () => {
+ await shortsSettings.set({ toggleRate: 2 });
+ const unmount = await shorts.mount({ signal: new AbortController().signal, route });
+ press("x");
+ expect(video().playbackRate).toBe(2);
+ press("x");
+ expect(video().playbackRate).toBe(1);
+ unmount?.();
+ });
+
+ test("advances to the next reel when one ends", async () => {
+ const next = countNextClicks();
+ const unmount = await shorts.mount({ signal: new AbortController().signal, route });
+ video().dispatchEvent(new Event("ended"));
+ expect(next.count).toBe(1);
+ unmount?.();
+ });
+
+ test("stays on the reel while a panel is open, behind a parked watch page's panels", async () => {
+ document.body.insertAdjacentHTML("afterbegin", `${panel("PAmodern_transcript_view", "HIDDEN")} `);
+ document.querySelector("ytd-shorts")!.insertAdjacentHTML("beforeend", panel("engagement-panel-comments-section", "EXPANDED"));
+ const next = countNextClicks();
+ const unmount = await shorts.mount({ signal: new AbortController().signal, route });
+ video().dispatchEvent(new Event("ended"));
+ expect(next.count).toBe(0);
+ unmount?.();
+ });
+
+ test("ignores a panel left open on a parked watch page", async () => {
+ document.body.insertAdjacentHTML("afterbegin", `${panel("engagement-panel-comments-section", "EXPANDED")} `);
+ const next = countNextClicks();
+ const unmount = await shorts.mount({ signal: new AbortController().signal, route });
+ video().dispatchEvent(new Event("ended"));
+ expect(next.count).toBe(1);
+ unmount?.();
+ });
+
+ test("advances in a narrow window, where YouTube hides the navigation buttons", async () => {
+ document.querySelector("#navigation-button-down")!.style.display = "none";
+ const next = countNextClicks();
+ const unmount = await shorts.mount({ signal: new AbortController().signal, route });
+ video().dispatchEvent(new Event("ended"));
+ expect(next.count).toBe(1);
+ unmount?.();
+ });
+
+ test("the Auto button turns auto-scroll off and persists it", async () => {
+ const unmount = await shorts.mount({ signal: new AbortController().signal, route });
+ controls().querySelector("button")!.click();
+ await new Promise((r) => setTimeout(r, 0));
+ expect((await shortsSettings.get()).autoScroll).toBe(false);
+ const next = countNextClicks();
+ video().dispatchEvent(new Event("ended"));
+ expect(next.count).toBe(0);
+ unmount?.();
+ });
+
+ test("does nothing when disabled", async () => {
+ await shortsSettings.set({ enabled: false });
+ await shorts.mount({ signal: new AbortController().signal, route });
+ expect(document.getElementById("tppng-shorts-controls")).toBeNull();
+ });
+});
diff --git a/web-ext/features/shorts/index.tsx b/web-ext/features/shorts/index.tsx
new file mode 100644
index 00000000..fcf739bd
--- /dev/null
+++ b/web-ext/features/shorts/index.tsx
@@ -0,0 +1,76 @@
+import type { Feature } from "@/kernel/features";
+import { bindKeys } from "@/kernel/keys";
+import { mount } from "@/kernel/dom/mount";
+import { setCapabilityStatus } from "@/kernel/dom/capabilities";
+import { isReelPanelOpen, nextReelButton, resolveReelActionBar, resolveReelVideo } from "@/youtube/shorts";
+import { Controls } from "./Controls";
+import { shortsKeys } from "./keys";
+import { shortsSettings } from "./settings";
+
+export const shorts: Feature = {
+ id: "shorts",
+ routes: ["shorts"],
+ async mount() {
+ const settings = await shortsSettings.get();
+ if (!settings.enabled) return;
+ const [player, actionBar] = await Promise.all([resolveReelVideo(), resolveReelActionBar()]);
+ void setCapabilityStatus("shorts.player", "shorts", player);
+ void setCapabilityStatus("shorts.actionBar", "shorts", actionBar);
+ if (!player.resolved || !actionBar.resolved) return;
+ const video = player.element as HTMLVideoElement;
+ const actions = actionBar.element;
+
+ let autoScroll = settings.autoScroll;
+
+ // YouTube loops a Short by default; the loop attribute comes back after
+ // playback starts, so it is stripped shortly after each "playing".
+ let unloop: ReturnType | undefined;
+ const onPlaying = () => {
+ clearTimeout(unloop);
+ if (autoScroll) unloop = setTimeout(() => video.removeAttribute("loop"), 400);
+ };
+ const onEnded = () => {
+ if (!autoScroll || isReelPanelOpen(video)) {
+ void video.play();
+ return;
+ }
+ nextReelButton(video)?.click();
+ };
+ video.addEventListener("playing", onPlaying);
+ video.addEventListener("ended", onEnded);
+ onPlaying();
+
+ const view = () => (
+ {
+ autoScroll = !autoScroll;
+ void shortsSettings.set({ autoScroll });
+ controls.update(view());
+ }}
+ onToggleRate={toggleRate}
+ />
+ );
+ const toggleRate = () => {
+ video.playbackRate = video.playbackRate === 1 ? settings.toggleRate : 1;
+ controls.update(view());
+ };
+ const controls = mount("tppng-shorts-controls", actions, view(), "prepend");
+
+ const unbindKeys = bindKeys(shortsKeys, {
+ toggleRate,
+ seekBackward: () => (video.currentTime -= settings.seekBackward),
+ seekForward: () => (video.currentTime += settings.seekForward),
+ });
+
+ return () => {
+ unbindKeys();
+ controls.unmount();
+ clearTimeout(unloop);
+ video.removeEventListener("playing", onPlaying);
+ video.removeEventListener("ended", onEnded);
+ };
+ },
+};
diff --git a/web-ext/features/shorts/keys.ts b/web-ext/features/shorts/keys.ts
new file mode 100644
index 00000000..40896c3e
--- /dev/null
+++ b/web-ext/features/shorts/keys.ts
@@ -0,0 +1,11 @@
+import { defineKeys } from "@/kernel/keys";
+
+export const shortsKeys = defineKeys({
+ id: "shorts",
+ title: "Shorts",
+ keys: {
+ toggleRate: { label: "Toggle playback rate", defaultBinding: "X" },
+ seekBackward: { label: "Seek backward", defaultBinding: "A" },
+ seekForward: { label: "Seek forward", defaultBinding: "D" },
+ },
+});
diff --git a/web-ext/features/shorts/settings.ts b/web-ext/features/shorts/settings.ts
new file mode 100644
index 00000000..ad01c244
--- /dev/null
+++ b/web-ext/features/shorts/settings.ts
@@ -0,0 +1,37 @@
+import { defineSettings } from "@/kernel/settings";
+
+export interface ShortsSettings {
+ enabled: boolean;
+ autoScroll: boolean;
+ toggleRate: number;
+ seekBackward: number;
+ seekForward: number;
+}
+
+interface LegacyShorts {
+ isEnabled?: boolean;
+ reelAutoScroll?: { value?: boolean };
+ togglePlaybackRate?: { value?: string };
+ seekBackward?: { value?: string };
+ seekForward?: { value?: string };
+}
+
+const number = (value: string | undefined) => (value === undefined ? undefined : Number(value));
+
+export const shortsSettings = defineSettings(
+ "shorts",
+ { enabled: true, autoScroll: true, toggleRate: 1.5, seekBackward: 5, seekForward: 5 },
+ {
+ legacy: (store) => {
+ const legacy = (store.preferences as { shorts?: LegacyShorts } | undefined)?.shorts;
+ if (!legacy) return undefined;
+ return {
+ enabled: legacy.isEnabled,
+ autoScroll: legacy.reelAutoScroll?.value,
+ toggleRate: number(legacy.togglePlaybackRate?.value),
+ seekBackward: number(legacy.seekBackward?.value),
+ seekForward: number(legacy.seekForward?.value),
+ };
+ },
+ },
+);
diff --git a/web-ext/index.d.ts b/web-ext/index.d.ts
deleted file mode 100644
index 512c0a71..00000000
--- a/web-ext/index.d.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-declare module "*.png";
-declare module "*.jpg";
-declare module "*.jpeg";
-declare module "*.gif";
-declare module "*.svg";
diff --git a/web-ext/kernel/dom/adoptTextStyle.ts b/web-ext/kernel/dom/adoptTextStyle.ts
new file mode 100644
index 00000000..ce0917db
--- /dev/null
+++ b/web-ext/kernel/dom/adoptTextStyle.ts
@@ -0,0 +1,10 @@
+const TEXT_PROPERTIES = ["font-family", "font-size", "font-weight", "line-height", "letter-spacing", "color"];
+
+// Copies the type of a piece of YouTube's text onto a shadow host. These
+// properties inherit across the shadow boundary, so injected text reads
+// exactly like its neighbour in any layout, theme or tint.
+export function adoptTextStyle(host: HTMLElement, sample: Element | null): void {
+ if (!sample) return;
+ const style = getComputedStyle(sample);
+ for (const property of TEXT_PROPERTIES) host.style.setProperty(property, style.getPropertyValue(property));
+}
diff --git a/web-ext/kernel/dom/capabilities.test.ts b/web-ext/kernel/dom/capabilities.test.ts
new file mode 100644
index 00000000..7eaf3160
--- /dev/null
+++ b/web-ext/kernel/dom/capabilities.test.ts
@@ -0,0 +1,36 @@
+import { beforeEach, describe, expect, test } from "bun:test";
+import { clearCapabilityCache, getAllCapabilityEntries, getCapabilityStatus, setCapabilityStatus } from "./capabilities";
+
+const found = { resolved: true, element: document.createElement("div"), strategyIndex: 1 } as const;
+const missing = { resolved: false, element: null, strategyIndex: null } as const;
+
+beforeEach(async () => {
+ await chrome.storage.local.clear();
+});
+
+describe("capability cache", () => {
+ test("statuses written by the content script are read back from extension storage", async () => {
+ await Promise.all([setCapabilityStatus("watch.player", "watch", found), setCapabilityStatus("watch.ratePanel", "watch", missing)]);
+ expect(await getCapabilityStatus("watch.player")).toBe("supported");
+ expect(await getCapabilityStatus("watch.ratePanel")).toBe("unsupported");
+ expect(await getCapabilityStatus("watch.sidebar")).toBe("untested");
+ expect((await getAllCapabilityEntries()).map((e) => e.primitiveId).sort()).toEqual(["watch.player", "watch.ratePanel"]);
+ });
+
+ test("an unchanged status is not rewritten", async () => {
+ await setCapabilityStatus("watch.player", "watch", found);
+ const key = "toppings:capability:watch.player";
+ const first = (await chrome.storage.local.get(key))[key];
+ await new Promise((r) => setTimeout(r, 5));
+ await setCapabilityStatus("watch.player", "watch", found);
+ expect((await chrome.storage.local.get(key))[key].lastCheckedAt).toBe(first.lastCheckedAt);
+ });
+
+ test("clearing removes only capability entries", async () => {
+ await chrome.storage.local.set({ other: 1 });
+ await setCapabilityStatus("watch.player", "watch", found);
+ await clearCapabilityCache();
+ expect(await getAllCapabilityEntries()).toEqual([]);
+ expect(await chrome.storage.local.get("other")).toEqual({ other: 1 });
+ });
+});
diff --git a/web-ext/kernel/dom/capabilities.ts b/web-ext/kernel/dom/capabilities.ts
new file mode 100644
index 00000000..77766720
--- /dev/null
+++ b/web-ext/kernel/dom/capabilities.ts
@@ -0,0 +1,71 @@
+import { EXTENSION_VERSION } from "@/lib/version";
+import { CHROME_STORAGE_LOCAL_KEY } from "@/lib/storageKeys";
+import type { PrimitiveResolution } from "./resolve";
+
+export type CapabilityStatus = "supported" | "unsupported" | "untested";
+
+export type PrimitiveScope =
+ | "watch"
+ | "home"
+ | "search"
+ | "shorts"
+ | "playlist";
+
+export interface CapabilityCacheEntry {
+ primitiveId: string; // e.g. "watch.sidebar"
+ scope: PrimitiveScope;
+ status: CapabilityStatus;
+ resolvedStrategyIndex: number | null;
+ lastCheckedAt: number; // unix ms
+ // Stamped so an upgrade that adds selector strategies invalidates the entry:
+ // a primitive recorded as unsupported may resolve under the new version.
+ extensionVersion: string;
+}
+
+// Kept in extension storage, not IndexedDB: content scripts write the cache,
+// and their IndexedDB belongs to youtube.com, out of reach of the options page
+// and background that read it. One key per primitive, so features recording
+// at the same moment never overwrite each other.
+const PREFIX = CHROME_STORAGE_LOCAL_KEY.CAPABILITY_PREFIX;
+
+export async function getCapabilityStatus(primitiveId: string): Promise {
+ const key = PREFIX + primitiveId;
+ const entry = (await chrome.storage.local.get(key))[key] as CapabilityCacheEntry | undefined;
+ if (!entry || entry.extensionVersion !== EXTENSION_VERSION) return "untested";
+ return entry.status;
+}
+
+export async function setCapabilityStatus(primitiveId: string, scope: PrimitiveScope, resolution: PrimitiveResolution): Promise {
+ const entry: CapabilityCacheEntry = {
+ primitiveId,
+ scope,
+ status: resolution.resolved ? "supported" : "unsupported",
+ resolvedStrategyIndex: resolution.resolved ? resolution.strategyIndex : null,
+ lastCheckedAt: Date.now(),
+ extensionVersion: EXTENSION_VERSION,
+ };
+ // Swallowed: a content script outliving an extension reload can no longer
+ // reach storage, and a lost entry only costs a re-check on the next page.
+ // An unchanged entry is not rewritten, since each write wakes the background.
+ try {
+ const key = PREFIX + primitiveId;
+ const stored = (await chrome.storage.local.get(key))[key] as CapabilityCacheEntry | undefined;
+ const unchanged = stored?.status === entry.status && stored.resolvedStrategyIndex === entry.resolvedStrategyIndex && stored.extensionVersion === entry.extensionVersion;
+ if (!unchanged) await chrome.storage.local.set({ [key]: entry });
+ } catch {}
+}
+
+async function capabilityKeys(): Promise {
+ return Object.keys(await chrome.storage.local.get()).filter((key) => key.startsWith(PREFIX));
+}
+
+export async function getAllCapabilityEntries(): Promise {
+ const keys = await capabilityKeys();
+ if (keys.length === 0) return [];
+ return Object.values(await chrome.storage.local.get(keys)) as CapabilityCacheEntry[];
+}
+
+export async function clearCapabilityCache(): Promise {
+ const keys = await capabilityKeys();
+ if (keys.length) await chrome.storage.local.remove(keys);
+}
diff --git a/web-ext/kernel/dom/credit.ts b/web-ext/kernel/dom/credit.ts
new file mode 100644
index 00000000..c367da8c
--- /dev/null
+++ b/web-ext/kernel/dom/credit.ts
@@ -0,0 +1,3 @@
+// Injected UI carries no Toppings logo so it reads as part of YouTube; the
+// credit lives in the hover tooltip instead.
+export const credit = (label: string) => `${label} · Toppings`;
diff --git a/web-ext/kernel/dom/featureReports.ts b/web-ext/kernel/dom/featureReports.ts
new file mode 100644
index 00000000..5ce364d2
--- /dev/null
+++ b/web-ext/kernel/dom/featureReports.ts
@@ -0,0 +1,58 @@
+// A report is the user saying "this primitive shows as unavailable". Reports
+// stay on the device; their only reader is the post-update check that marks
+// a reported primitive as recovered once it resolves again, so the options
+// page can say it started working.
+
+import { getCurrentVersion } from "@/lib/version";
+import { CHROME_STORAGE_LOCAL_KEY } from "@/lib/storageKeys";
+
+export interface FeatureReport {
+ primitiveId: string;
+ reportedAt: number;
+ reportedVersion: string;
+}
+
+export interface RecoveredFeature {
+ primitiveId: string;
+ recoveredAt: number;
+ dismissedAt: number | null;
+}
+
+const REPORTS = CHROME_STORAGE_LOCAL_KEY.FEATURE_REPORTS;
+const RECOVERED = CHROME_STORAGE_LOCAL_KEY.FEATURE_RECOVERED;
+
+async function readList(key: string): Promise {
+ const stored = (await chrome.storage.local.get(key))[key];
+ return Array.isArray(stored) ? (stored as T[]) : [];
+}
+
+const writeList = (key: string, list: unknown[]) => chrome.storage.local.set({ [key]: list });
+
+export const getFeatureReports = () => readList(REPORTS);
+
+export async function addFeatureReport(primitiveId: string): Promise {
+ const reports = await getFeatureReports();
+ if (reports.some((r) => r.primitiveId === primitiveId)) return;
+ await writeList(REPORTS, [...reports, { primitiveId, reportedAt: Date.now(), reportedVersion: await getCurrentVersion() }]);
+}
+
+export async function removeFeatureReport(primitiveId: string): Promise {
+ await writeList(REPORTS, (await getFeatureReports()).filter((r) => r.primitiveId !== primitiveId));
+}
+
+export const getRecoveredFeatures = () => readList(RECOVERED);
+
+export async function markRecovered(primitiveId: string): Promise {
+ const existing = await getRecoveredFeatures();
+ if (existing.some((r) => r.primitiveId === primitiveId && r.dismissedAt === null)) return;
+ await writeList(RECOVERED, [...existing.filter((r) => r.primitiveId !== primitiveId), { primitiveId, recoveredAt: Date.now(), dismissedAt: null }]);
+}
+
+export async function dismissRecovered(primitiveId: string): Promise {
+ const updated = (await getRecoveredFeatures()).map((r) => (r.primitiveId === primitiveId ? { ...r, dismissedAt: Date.now() } : r));
+ await writeList(RECOVERED, updated);
+}
+
+export async function getUndismissedRecovered(): Promise {
+ return (await getRecoveredFeatures()).filter((r) => r.dismissedAt === null);
+}
diff --git a/web-ext/kernel/dom/mount.test.ts b/web-ext/kernel/dom/mount.test.ts
new file mode 100644
index 00000000..bcedc49c
--- /dev/null
+++ b/web-ext/kernel/dom/mount.test.ts
@@ -0,0 +1,25 @@
+import { afterEach, describe, expect, test } from "bun:test";
+import { h } from "preact";
+import { mount } from "./mount";
+
+afterEach(() => {
+ document.body.innerHTML = "";
+});
+
+describe("mount", () => {
+ test("replaces a host left behind by a reloaded extension", () => {
+ document.body.innerHTML = `stale
`;
+ const ui = mount("tppng-x", document.body, h("span", null, "fresh"));
+ expect(document.querySelectorAll("#tppng-x").length).toBe(1);
+ ui.unmount();
+ });
+
+ test("leaves another live mount's host alone, and each unmount removes its own", () => {
+ const live = mount("tppng-x", document.body, h("span", null, "live"));
+ const stale = mount("tppng-x", document.body, h("span", null, "stale"));
+ stale.unmount();
+ expect(document.getElementById("tppng-x")).toBe(live.host);
+ live.unmount();
+ expect(document.getElementById("tppng-x")).toBeNull();
+ });
+});
diff --git a/web-ext/kernel/dom/mount.ts b/web-ext/kernel/dom/mount.ts
new file mode 100644
index 00000000..ea0eba73
--- /dev/null
+++ b/web-ext/kernel/dom/mount.ts
@@ -0,0 +1,51 @@
+import { render, type ComponentChild } from "preact";
+
+export interface Mounted {
+ host: HTMLElement;
+ update(next: ComponentChild): void;
+ unmount(): void;
+}
+
+const liveHosts = new WeakSet();
+
+// A host with this id that nothing here owns was left by the content script of
+// an extension version since reloaded, and is replaced. One that is owned
+// belongs to another navigation's mount, which removes its own host when the
+// kernel undoes it; removing it here would take the live page's UI with it.
+function removeOrphan(id: string): void {
+ const existing = document.getElementById(id);
+ if (existing && !liveHosts.has(existing)) existing.remove();
+}
+
+function track(host: HTMLElement, render: (ui: ComponentChild) => void, parent: Element, position: "append" | "prepend"): Mounted {
+ liveHosts.add(host);
+ parent[position](host);
+ return {
+ host,
+ update: render,
+ unmount() {
+ render(null);
+ liveHosts.delete(host);
+ host.remove();
+ },
+ };
+}
+
+// Injected UI renders inside a shadow root, so YouTube's stylesheet and ours
+// cannot reach each other.
+export function mount(id: string, parent: Element, ui: ComponentChild, position: "append" | "prepend" = "append"): Mounted {
+ removeOrphan(id);
+ const host = document.createElement("div");
+ host.id = id;
+ const root = host.attachShadow({ mode: "open" });
+ render(ui, root);
+ return track(host, (next) => render(next, root), parent, position);
+}
+
+// For UI that should take YouTube's own styling (a .ytp-button, a menu row):
+// no shadow root, and the caller shapes the host element.
+export function mountInline(host: HTMLElement, parent: Element, ui: ComponentChild, position: "append" | "prepend" = "append"): Mounted {
+ if (host.id) removeOrphan(host.id);
+ render(ui, host);
+ return track(host, (next) => render(next, host), parent, position);
+}
diff --git a/web-ext/kernel/dom/resolve.test.ts b/web-ext/kernel/dom/resolve.test.ts
new file mode 100644
index 00000000..d4736e76
--- /dev/null
+++ b/web-ext/kernel/dom/resolve.test.ts
@@ -0,0 +1,45 @@
+import { afterEach, describe, expect, test } from "bun:test";
+import { resolveTarget } from "./resolve";
+
+afterEach(() => {
+ document.body.innerHTML = "";
+});
+
+describe("resolveTarget", () => {
+ test("prefers the earliest strategy that matches", async () => {
+ document.body.innerHTML = `
`;
+ const found = await resolveTarget([".a", ".b"]);
+ expect(found.strategyIndex).toBe(0);
+ });
+
+ test("skips elements on a page YouTube has hidden", async () => {
+ document.body.innerHTML = ` `;
+ const found = await resolveTarget([".header"]);
+ expect(found.element?.id).toBe("live");
+ });
+
+ test("skips elements under display: none", async () => {
+ document.body.innerHTML = `
`;
+ const found = await resolveTarget([".header"]);
+ expect(found.element?.id).toBe("shown");
+ });
+
+ test("waits for a live element to appear", async () => {
+ document.body.innerHTML = ` `;
+ const pending = resolveTarget([".header"], { timeout: 1000 });
+ setTimeout(() => document.body.insertAdjacentHTML("beforeend", ``), 20);
+ expect((await pending).element?.id).toBe("late");
+ });
+
+ test("resolves once a hidden page is shown again", async () => {
+ document.body.innerHTML = ` `;
+ const pending = resolveTarget([".header"], { timeout: 1000 });
+ setTimeout(() => document.getElementById("page")!.removeAttribute("hidden"), 20);
+ expect((await pending).element?.id).toBe("back");
+ });
+
+ test("gives up after the timeout", async () => {
+ const found = await resolveTarget([".never"], { timeout: 30 });
+ expect(found.resolved).toBe(false);
+ });
+});
diff --git a/web-ext/kernel/dom/resolve.ts b/web-ext/kernel/dom/resolve.ts
new file mode 100644
index 00000000..6cb9ca0d
--- /dev/null
+++ b/web-ext/kernel/dom/resolve.ts
@@ -0,0 +1,69 @@
+export type PrimitiveResolution =
+ | { resolved: true; element: Element; strategyIndex: number }
+ | { resolved: false; element: null; strategyIndex: null };
+
+export type PrimitiveStrategy = string;
+
+export interface ResolveOptions {
+ timeout?: number;
+}
+
+const UNRESOLVED: PrimitiveResolution = { resolved: false, element: null, strategyIndex: null };
+
+// YouTube keeps the pages you navigated away from alive but hidden, and parks
+// unused layouts under display:none, so a selector can match a header or
+// player that is not on screen. Only rendered elements count.
+function isRendered(element: Element): boolean {
+ return !element.closest("[hidden]") && (element.checkVisibility?.() ?? true);
+}
+
+function findLive(strategies: readonly PrimitiveStrategy[]): PrimitiveResolution | null {
+ for (let i = 0; i < strategies.length; i++) {
+ for (const element of document.querySelectorAll(strategies[i])) {
+ if (isRendered(element)) return { resolved: true, element, strategyIndex: i };
+ }
+ }
+ return null;
+}
+
+// For looking inside a root that was itself resolved live. There YouTube hides
+// elements on purpose (a closed menu, buttons a narrow layout tucks away), and
+// those still count.
+export function findWithin(root: ParentNode, strategies: readonly PrimitiveStrategy[]): PrimitiveResolution {
+ for (let i = 0; i < strategies.length; i++) {
+ const element = root.querySelector(strategies[i]);
+ if (element) return { resolved: true, element, strategyIndex: i };
+ }
+ return UNRESOLVED;
+}
+
+// Strategies are ordered by preference, and the first to match a live element
+// wins. YouTube renders long after load, so this waits for the DOM to change
+// until something matches or the timeout passes. Callers record the result in
+// the capability cache, which is how the options page reports a broken one.
+export function resolveTarget(strategies: readonly PrimitiveStrategy[], { timeout = 10_000 }: ResolveOptions = {}): Promise {
+ const found = findLive(strategies);
+ if (found || strategies.length === 0) return Promise.resolve(found ?? UNRESOLVED);
+
+ return new Promise((resolve) => {
+ let pending: ReturnType | undefined;
+ const finish = (resolution: PrimitiveResolution) => {
+ observer.disconnect();
+ clearTimeout(timer);
+ clearTimeout(pending);
+ resolve(resolution);
+ };
+ const check = () => {
+ pending = undefined;
+ const match = findLive(strategies);
+ if (match) finish(match);
+ };
+ // Batched on a timer rather than an animation frame, which never comes in
+ // a background tab and would leave a page opened there unresolved.
+ const observer = new MutationObserver(() => {
+ pending ??= setTimeout(check, 16);
+ });
+ observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["hidden", "class", "style"] });
+ const timer = setTimeout(() => finish(UNRESOLVED), timeout);
+ });
+}
diff --git a/web-ext/kernel/dom/theme.ts b/web-ext/kernel/dom/theme.ts
new file mode 100644
index 00000000..869eda6c
--- /dev/null
+++ b/web-ext/kernel/dom/theme.ts
@@ -0,0 +1,16 @@
+// In-page UI reads YouTube's own colour tokens, which inherit into shadow
+// roots as custom properties, so it follows the page's light or dark theme
+// without a theme of its own. Fallbacks are YouTube's dark values.
+export const themeTokens = `
+ :host {
+ --tp-bg: var(--yt-sys-color-baseline--base-background, #0f0f0f);
+ --tp-raised: var(--yt-sys-color-baseline--raised-background, #212121);
+ --tp-menu: var(--yt-sys-color-baseline--menu-background, #282828);
+ --tp-text: var(--yt-sys-color-baseline--text-primary, #f1f1f1);
+ --tp-text-2: var(--yt-sys-color-baseline--text-secondary, #aaa);
+ --tp-outline: var(--yt-sys-color-baseline--outline, rgba(255, 255, 255, .2));
+ --tp-additive: var(--yt-sys-color-baseline--additive-background, rgba(255, 255, 255, .1));
+ --tp-accent: var(--yt-sys-color-baseline--call-to-action, #3ea6ff);
+ --tp-font: "YouTube Sans", Roboto, Arial, sans-serif;
+ }
+`;
diff --git a/web-ext/kernel/dom/toast.tsx b/web-ext/kernel/dom/toast.tsx
new file mode 100644
index 00000000..f46ad639
--- /dev/null
+++ b/web-ext/kernel/dom/toast.tsx
@@ -0,0 +1,33 @@
+import { mount } from "./mount";
+
+const style = `
+ .toast {
+ position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); z-index: 2147483647;
+ padding: 8px 16px; border-radius: 8px; white-space: nowrap; pointer-events: none; user-select: none;
+ background: rgba(15, 15, 15, .88); backdrop-filter: blur(8px); color: #fff;
+ font: 500 13px/1.4 Roboto, system-ui, sans-serif; letter-spacing: .01em;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, .3);
+ opacity: 0; transition: opacity .15s ease;
+ }
+ .toast[data-visible] { opacity: 1; }
+`;
+
+let ui: ReturnType | undefined;
+let timer: ReturnType | undefined;
+
+function Toast({ message, visible }: { message: string; visible: boolean }) {
+ return (
+ <>
+
+ {message}
+ >
+ );
+}
+
+// One shared element: rapid calls replace the message rather than stacking.
+export function showToast(message: string, duration = 2000): void {
+ if (!ui || !ui.host.isConnected) ui = mount("tppng-toast", document.body, );
+ ui.update( );
+ clearTimeout(timer);
+ timer = setTimeout(() => ui?.update( ), duration);
+}
diff --git a/web-ext/kernel/dom/useCapabilities.ts b/web-ext/kernel/dom/useCapabilities.ts
new file mode 100644
index 00000000..8ac26e39
--- /dev/null
+++ b/web-ext/kernel/dom/useCapabilities.ts
@@ -0,0 +1,45 @@
+import { useEffect, useState } from "preact/hooks";
+import {
+ getAllCapabilityEntries,
+ clearCapabilityCache,
+ type CapabilityCacheEntry,
+ type CapabilityStatus,
+} from "./capabilities";
+
+export type CapabilityMap = Map;
+
+interface UseCapabilityCacheResult {
+ capabilities: CapabilityMap;
+ isLoading: boolean;
+ getStatus: (primitiveId: string) => CapabilityStatus;
+ rescan: () => Promise;
+}
+
+// rescan only clears the cache — the actual re-probe happens on the next
+// YouTube navigation, so the UI cannot show fresh results immediately.
+export function useCapabilityCache(): UseCapabilityCacheResult {
+ const [capabilities, setCapabilities] = useState(new Map());
+ const [isLoading, setIsLoading] = useState(true);
+
+ const load = async () => {
+ setIsLoading(true);
+ const entries = await getAllCapabilityEntries();
+ setCapabilities(new Map(entries.map((e) => [e.primitiveId, e])));
+ setIsLoading(false);
+ };
+
+ useEffect(() => {
+ void load();
+ }, []);
+
+ const getStatus = (primitiveId: string): CapabilityStatus => {
+ return capabilities.get(primitiveId)?.status ?? "untested";
+ };
+
+ const rescan = async () => {
+ await clearCapabilityCache();
+ await load();
+ };
+
+ return { capabilities, isLoading, getStatus, rescan };
+}
diff --git a/web-ext/kernel/features.test.ts b/web-ext/kernel/features.test.ts
new file mode 100644
index 00000000..da444e92
--- /dev/null
+++ b/web-ext/kernel/features.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, test } from "bun:test";
+import type { Route } from "@/youtube/route";
+import { bootFeatures, type Feature } from "./features";
+
+const watch: Route = { name: "watch", videoId: "v1", playlistId: null };
+const settle = () => new Promise((r) => setTimeout(r, 0));
+
+function navigator() {
+ let listener: (route: Route) => void = () => {};
+ return {
+ onNavigate: (l: (route: Route) => void) => (listener = l),
+ go: (route: Route) => listener(route),
+ };
+}
+
+describe("bootFeatures", () => {
+ test("a mount that finishes after the next navigation is undone", async () => {
+ const nav = navigator();
+ let release!: () => void;
+ let unmounts = 0;
+ const slow: Feature = { id: "slow", routes: ["watch"], mount: () => new Promise((r) => (release = () => r(() => unmounts++))) };
+ bootFeatures([slow], nav.onNavigate);
+
+ nav.go(watch);
+ await settle();
+ const stale = release;
+ nav.go({ name: "home" });
+ stale();
+ await settle();
+ expect(unmounts).toBe(1);
+ });
+
+ test("features mount without waiting for each other, and see the navigation end", async () => {
+ const nav = navigator();
+ let signal: AbortSignal | undefined;
+ let quickMounted = false;
+ const slow: Feature = { id: "slow", routes: ["watch"], mount: (ctx) => new Promise(() => void (signal = ctx.signal)) };
+ const quick: Feature = { id: "quick", routes: ["watch"], mount: () => void (quickMounted = true) };
+ bootFeatures([slow, quick], nav.onNavigate);
+
+ nav.go(watch);
+ await settle();
+ expect(quickMounted).toBe(true);
+ expect(signal?.aborted).toBe(false);
+ nav.go({ name: "home" });
+ expect(signal?.aborted).toBe(true);
+ });
+
+ test("stop unmounts everything and ignores later navigations", async () => {
+ const nav = navigator();
+ let mounts = 0;
+ let unmounts = 0;
+ const feature: Feature = { id: "f", routes: ["watch", "home"], mount: () => (mounts++, () => unmounts++) };
+ const booted = bootFeatures([feature], nav.onNavigate);
+ nav.go(watch);
+ await settle();
+ booted.stop();
+ expect(unmounts).toBe(1);
+ nav.go({ name: "home" });
+ booted.refresh();
+ await settle();
+ expect(mounts).toBe(1);
+ });
+
+ test("the previous route's features unmount on navigation", async () => {
+ const nav = navigator();
+ let unmounts = 0;
+ const feature: Feature = { id: "f", routes: ["watch"], mount: () => () => unmounts++ };
+ bootFeatures([feature], nav.onNavigate);
+ nav.go(watch);
+ await settle();
+ nav.go({ name: "home" });
+ await settle();
+ expect(unmounts).toBe(1);
+ });
+});
diff --git a/web-ext/kernel/features.ts b/web-ext/kernel/features.ts
new file mode 100644
index 00000000..915b1c4e
--- /dev/null
+++ b/web-ext/kernel/features.ts
@@ -0,0 +1,82 @@
+import type { Route, RouteName } from "@/youtube/route";
+
+export type Unmount = () => void;
+
+export interface FeatureContext {
+ route: Route;
+ // Aborted by the next navigation, for a mount with slow steps to stop early.
+ signal: AbortSignal;
+}
+
+// A feature declares where it runs and how to start and stop. The kernel
+// mounts it on every navigation that lands on one of its routes and unmounts
+// it on the next navigation, so features never have to be idempotent
+// against a page they did not start on.
+export interface Feature {
+ id: string;
+ routes: readonly RouteName[];
+ mount(ctx: FeatureContext): Promise | Unmount | void;
+}
+
+export interface BootOptions {
+ // Consulted on every navigation; false unmounts everything and mounts nothing.
+ enabled?: () => boolean;
+}
+
+export interface Booted {
+ // Re-runs the current route, e.g. after the enabled switch flips.
+ refresh(): void;
+ // Unmounts everything and ignores later navigations, for handing the page
+ // to a newer copy of the content script.
+ stop(): void;
+}
+
+export function bootFeatures(features: readonly Feature[], onNavigate: (listener: (route: Route) => void) => void, { enabled = () => true }: BootOptions = {}): Booted {
+ const mounted = new Map();
+ let navigation: AbortController | undefined;
+ let current: Route | undefined;
+ let stopped = false;
+
+ const navigate = async (route: Route) => {
+ if (stopped) return;
+ current = route;
+ navigation?.abort();
+ const own = (navigation = new AbortController());
+ for (const [id, unmount] of mounted) {
+ mounted.delete(id);
+ unmount();
+ }
+ if (!enabled() || stopped) return;
+ // Started together, so one feature waiting on the network or on an element
+ // that never renders holds up none of the others.
+ await Promise.all(
+ features
+ .filter((feature) => feature.routes.includes(route.name))
+ .map(async (feature) => {
+ try {
+ const unmount = await feature.mount({ route, signal: own.signal });
+ // A navigation that happened while mounting owns the page now.
+ if (own.signal.aborted) unmount?.();
+ else if (unmount) mounted.set(feature.id, unmount);
+ } catch (error) {
+ console.error(`[toppings] ${feature.id} failed to mount`, error);
+ }
+ }),
+ );
+ };
+
+ onNavigate((route) => void navigate(route));
+ return {
+ refresh() {
+ if (current) void navigate(current);
+ },
+ stop() {
+ stopped = true;
+ navigation?.abort();
+ for (const [id, unmount] of mounted) {
+ mounted.delete(id);
+ unmount();
+ }
+ },
+ };
+}
diff --git a/web-ext/kernel/keybinding.test.ts b/web-ext/kernel/keybinding.test.ts
new file mode 100644
index 00000000..82fd0f38
--- /dev/null
+++ b/web-ext/kernel/keybinding.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, test } from "bun:test";
+import { matchesBinding } from "./keybinding";
+
+const key = (k: string, init: KeyboardEventInit = {}) => new KeyboardEvent("keydown", { key: k, ...init });
+
+describe("matchesBinding", () => {
+ test("modifiers must match exactly", () => {
+ expect(matchesBinding(key("q", { shiftKey: true }), "Shift+Q")).toBe(true);
+ expect(matchesBinding(key("q", { shiftKey: true }), "Q")).toBe(false);
+ });
+
+ test("modifier names match regardless of case", () => {
+ expect(matchesBinding(key("1", { ctrlKey: true }), "ctrl+1")).toBe(true);
+ expect(matchesBinding(key("1"), "ctrl+1")).toBe(false);
+ });
+
+ test("an unknown modifier never matches", () => {
+ expect(matchesBinding(key("1"), "Hyper+1")).toBe(false);
+ });
+});
diff --git a/web-ext/kernel/keybinding.ts b/web-ext/kernel/keybinding.ts
new file mode 100644
index 00000000..55dc2f7a
--- /dev/null
+++ b/web-ext/kernel/keybinding.ts
@@ -0,0 +1,74 @@
+export const MODIFIER_KEYS = ["Ctrl", "Alt", "Shift", "Meta"] as const;
+export type ModifierKey = (typeof MODIFIER_KEYS)[number];
+
+const MODIFIER_KEY_VALUES = new Set([
+ "Control", "Alt", "Shift", "Meta",
+ "CapsLock", "NumLock", "ScrollLock",
+]);
+
+const MODIFIER_SYMBOLS: Record = {
+ Ctrl: "⌃",
+ Alt: "⌥",
+ Shift: "⇧",
+ Meta: "⌘",
+};
+
+export function formatBindingDisplay(combo: string): string {
+ if (!combo) return "";
+ return combo
+ .split("+")
+ .map((part) => MODIFIER_SYMBOLS[part as ModifierKey] ?? part)
+ .join("");
+}
+
+// null for a modifier-only or non-alphanumeric press, which callers treat as
+// "keep listening" rather than as a binding.
+export function recordBinding(e: KeyboardEvent): string | null {
+ if (MODIFIER_KEY_VALUES.has(e.key)) return null;
+ const baseKey = e.key.toUpperCase();
+ if (!/^[A-Z0-9]$/.test(baseKey)) return null;
+
+ const modState: Record = {
+ Ctrl: e.ctrlKey,
+ Alt: e.altKey,
+ Shift: e.shiftKey,
+ Meta: e.metaKey,
+ };
+ const parts: string[] = MODIFIER_KEYS.filter((m) => modState[m]);
+ parts.push(baseKey);
+
+ return parts.join("+");
+}
+
+// Modifiers must match exactly, so a binding of "Q" deliberately does not fire
+// while Shift is held — that combo belongs to "Shift+Q".
+export function matchesBinding(event: KeyboardEvent, binding: string): boolean {
+ if (!binding) return false;
+
+ const parts = binding.split("+");
+ const storedKey = parts[parts.length - 1].toUpperCase();
+ const mods = new Set(parts.slice(0, -1).map((name) => MODIFIER_KEYS.find((m) => m.toLowerCase() === name.toLowerCase())));
+ if (mods.has(undefined)) return false;
+
+ const modState: Record = {
+ Ctrl: event.ctrlKey,
+ Alt: event.altKey,
+ Shift: event.shiftKey,
+ Meta: event.metaKey,
+ };
+
+ return (
+ event.key.toUpperCase() === storedKey &&
+ MODIFIER_KEYS.every((m) => mods.has(m) === modState[m])
+ );
+}
+
+// A key typed into a field inside one of Toppings' shadow roots reaches the
+// document retargeted to the shadow host, so the field is found through the
+// roots' focus instead.
+export function isTypingTarget(target: EventTarget | null): boolean {
+ let el = target as HTMLElement | null;
+ while (el?.shadowRoot?.activeElement) el = el.shadowRoot.activeElement as HTMLElement;
+ if (!el) return false;
+ return el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT" || el.isContentEditable;
+}
diff --git a/web-ext/kernel/keys.test.ts b/web-ext/kernel/keys.test.ts
new file mode 100644
index 00000000..ce37a957
--- /dev/null
+++ b/web-ext/kernel/keys.test.ts
@@ -0,0 +1,67 @@
+import { beforeEach, describe, expect, test } from "bun:test";
+import { actionId, bindKeys, bindingOf, defineKeys, keybindings } from "./keys";
+
+const group = defineKeys({
+ id: "demo",
+ title: "Demo",
+ keys: {
+ go: { label: "Go", defaultBinding: "G" },
+ back: { label: "Back", defaultBinding: "Shift+B" },
+ },
+});
+
+const press = (key: string, init: KeyboardEventInit = {}, target: EventTarget = document.body) =>
+ target.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init }));
+
+beforeEach(async () => {
+ await chrome.storage.sync.clear();
+ document.body.innerHTML = "";
+});
+
+describe("key bindings", () => {
+ test("default bindings run their handler and nothing else", () => {
+ const ran: string[] = [];
+ const unbind = bindKeys(group, { go: () => ran.push("go"), back: () => ran.push("back") });
+ press("g");
+ press("b", { shiftKey: true });
+ press("b");
+ unbind();
+ expect(ran).toEqual(["go", "back"]);
+ });
+
+ test("unbound handlers no longer run", () => {
+ let count = 0;
+ const unbind = bindKeys(group, { go: () => count++, back: () => {} });
+ unbind();
+ press("g");
+ expect(count).toBe(0);
+ });
+
+ test("keys are ignored while typing", () => {
+ document.body.innerHTML = ` `;
+ let count = 0;
+ const unbind = bindKeys(group, { go: () => count++, back: () => {} });
+ press("g", {}, document.getElementById("i")!);
+ unbind();
+ expect(count).toBe(0);
+ });
+
+ test("keys are ignored while typing in a field inside a shadow root", () => {
+ const host = document.createElement("div");
+ document.body.append(host);
+ const input = host.attachShadow({ mode: "open" }).appendChild(document.createElement("input"));
+ input.focus();
+ let count = 0;
+ const unbind = bindKeys(group, { go: () => count++, back: () => {} });
+ // Dispatched from the host, as a browser retargets it on leaving the shadow root.
+ press("g", {}, host);
+ unbind();
+ expect(count).toBe(0);
+ });
+
+ test("a stored binding overrides the default", async () => {
+ await keybindings.set({ [actionId(group, "go")]: "K" });
+ expect(bindingOf(group, "go", await keybindings.get())).toBe("K");
+ expect(bindingOf(group, "back", await keybindings.get())).toBe("Shift+B");
+ });
+});
diff --git a/web-ext/kernel/keys.ts b/web-ext/kernel/keys.ts
new file mode 100644
index 00000000..e5d295d3
--- /dev/null
+++ b/web-ext/kernel/keys.ts
@@ -0,0 +1,62 @@
+import { isTypingTarget, matchesBinding } from "./keybinding";
+import { defineSettings } from "./settings";
+
+export interface KeyDefinition {
+ label: string;
+ description?: string;
+ defaultBinding: string;
+}
+
+// A feature declares its actions once, statically, so the options page can
+// list them without mounting anything; handlers arrive at mount time.
+export interface KeyGroup {
+ id: string;
+ title: string;
+ keys: Record;
+}
+
+// Only bindings the user changed are stored, by action id ("shorts.seekForward").
+export const keybindings = defineSettings>("keybindings", {});
+
+interface Binding {
+ group: KeyGroup;
+ key: string;
+ run: () => void;
+}
+
+let active: Binding[] = [];
+let stored: Record = {};
+let installed = false;
+
+export function defineKeys(group: KeyGroup): KeyGroup {
+ return group;
+}
+
+export function actionId(group: KeyGroup, key: string): string {
+ return `${group.id}.${key}`;
+}
+
+export function bindingOf(group: KeyGroup, key: string, stored: Record): string {
+ return stored[actionId(group, key)] ?? group.keys[key].defaultBinding;
+}
+
+function install(): void {
+ if (installed) return;
+ installed = true;
+ void keybindings.get().then((value) => (stored = value));
+ keybindings.subscribe((value) => (stored = value));
+ document.addEventListener("keydown", (event) => {
+ if (isTypingTarget(event.target)) return;
+ const hit = active.find((b) => matchesBinding(event, bindingOf(b.group, b.key, stored)));
+ hit?.run();
+ });
+}
+
+export function bindKeys(group: KeyGroup, handlers: Record void>): () => void {
+ install();
+ const added: Binding[] = (Object.keys(handlers) as K[]).map((key) => ({ group, key, run: handlers[key] }));
+ active.push(...added);
+ return () => {
+ active = active.filter((b) => !added.includes(b));
+ };
+}
diff --git a/web-ext/kernel/messaging.ts b/web-ext/kernel/messaging.ts
new file mode 100644
index 00000000..674a6808
--- /dev/null
+++ b/web-ext/kernel/messaging.ts
@@ -0,0 +1,53 @@
+// Typed request/response between a page and the background. Messages are
+// objects with a `kind`; the older string protocol on the same channel ignores
+// them, and this listener ignores strings.
+
+type Handler = (request: Req, sender: chrome.runtime.MessageSender) => Promise;
+
+interface Envelope {
+ kind: string;
+ request: unknown;
+}
+
+const handlers = new Map>();
+let listening = false;
+
+function listen(): void {
+ if (listening) return;
+ listening = true;
+ chrome.runtime.onMessage.addListener((message: unknown, sender, sendResponse) => {
+ if (typeof message !== "object" || message === null || !("kind" in message)) return false;
+ const handler = handlers.get((message as Envelope).kind);
+ if (!handler) return false;
+ handler((message as Envelope).request, sender).then(
+ (response) => sendResponse({ response }),
+ (error: unknown) => sendResponse({ error: String(error) }),
+ );
+ return true;
+ });
+}
+
+export interface Message {
+ kind: string;
+ send(request: Req): Promise;
+ handle(handler: Handler): void;
+}
+
+export function defineMessage(kind: string): Message {
+ return {
+ kind,
+ async send(request) {
+ const reply = (await chrome.runtime.sendMessage({ kind, request } satisfies Envelope)) as
+ | { response: Res }
+ | { error: string }
+ | undefined;
+ if (!reply) throw new Error(`no handler for ${kind}`);
+ if ("error" in reply) throw new Error(reply.error);
+ return reply.response;
+ },
+ handle(handler) {
+ handlers.set(kind, handler as Handler);
+ listen();
+ },
+ };
+}
diff --git a/web-ext/kernel/primitives.test.ts b/web-ext/kernel/primitives.test.ts
new file mode 100644
index 00000000..b5097863
--- /dev/null
+++ b/web-ext/kernel/primitives.test.ts
@@ -0,0 +1,94 @@
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import { definePrimitive, runPrimitives } from "./primitives";
+
+const hide = definePrimitive<{ visible: boolean }>({
+ id: "watch.box",
+ label: "Box",
+ routes: ["watch"],
+ strategies: ["#missing", ".box"],
+ parse: () => undefined,
+ apply: (el, { visible }) => (el.style.display = visible ? "" : "none"),
+ reset: (el) => (el.style.display = ""),
+});
+
+const blur = definePrimitive<{ mode: string }>({
+ id: "home.thumbs",
+ label: "Thumbs",
+ routes: ["home"],
+ strategies: ["img"],
+ all: true,
+ parse: () => undefined,
+ apply: (el, { mode }) => (el.style.filter = mode === "blur" ? "blur(12px)" : ""),
+ reset: (el) => (el.style.filter = ""),
+});
+
+const frame = () => new Promise((r) => requestAnimationFrame(() => r(undefined)));
+const box = () => document.querySelector(".box")!;
+
+beforeEach(() => {
+ document.body.innerHTML = `
`;
+});
+afterEach(() => {
+ document.body.innerHTML = "";
+});
+
+describe("runPrimitives", () => {
+ test("applies on start and restores on stop", () => {
+ const run = runPrimitives([hide], { "watch.box": { visible: false } }, "watch");
+ expect(box().style.display).toBe("none");
+ run.stop();
+ expect(box().style.display).toBe("");
+ });
+
+ test("acts on the live page, not one YouTube keeps hidden after navigating away", () => {
+ document.body.innerHTML = `
`;
+ const run = runPrimitives([hide], { "watch.box": { visible: false } }, "watch");
+ expect(document.getElementById("parked")!.style.display).toBe("");
+ expect(document.getElementById("live")!.style.display).toBe("none");
+ run.stop();
+ });
+
+ test("ignores primitives for other routes and absent values", () => {
+ const run = runPrimitives([hide, blur], {}, "watch");
+ expect(box().style.display).toBe("");
+ run.stop();
+ });
+
+ test("keeps applying as the page re-renders", async () => {
+ document.body.innerHTML = ` `;
+ const run = runPrimitives([blur], { "home.thumbs": { mode: "blur" } }, "home");
+ expect(document.getElementById("a")!.style.filter).toBe("blur(12px)");
+ const late = document.createElement("img");
+ late.id = "b";
+ document.body.append(late);
+ await frame();
+ await frame();
+ expect(late.style.filter).toBe("blur(12px)");
+ run.stop();
+ expect(late.style.filter).toBe("");
+ });
+
+ test("resets an element the selector no longer matches", async () => {
+ const run = runPrimitives([hide], { "watch.box": { visible: false } }, "watch");
+ const first = box();
+ first.className = "other";
+ const next = document.createElement("div");
+ next.className = "box";
+ document.body.append(next);
+ await frame();
+ await frame();
+ expect(first.style.display).toBe("");
+ expect(next.style.display).toBe("none");
+ run.stop();
+ });
+
+ test("set changes a value at runtime", () => {
+ const run = runPrimitives([hide], {}, "watch");
+ run.set("watch.box", { visible: false });
+ expect(box().style.display).toBe("none");
+ expect(run.get("watch.box")).toEqual({ visible: false });
+ run.set("watch.box", { visible: true });
+ expect(box().style.display).toBe("");
+ run.stop();
+ });
+});
diff --git a/web-ext/kernel/primitives.ts b/web-ext/kernel/primitives.ts
new file mode 100644
index 00000000..ef3f4f78
--- /dev/null
+++ b/web-ext/kernel/primitives.ts
@@ -0,0 +1,112 @@
+import type { RouteName } from "@/youtube/route";
+import { resolveTarget } from "./dom/resolve";
+import { setCapabilityStatus, type PrimitiveScope } from "./dom/capabilities";
+
+// A primitive is one knob on YouTube's page: what to find and how to set it
+// to a value. Apply must be idempotent, because it is re-run whenever the
+// page re-renders; reset must undo apply on the same element.
+export interface Primitive {
+ id: string;
+ label: string;
+ routes: readonly RouteName[];
+ strategies: readonly string[];
+ // Every match rather than the first, for feeds that render many of a thing.
+ all?: boolean;
+ parse(value: unknown): V | undefined;
+ apply(element: HTMLElement, value: V): void;
+ reset(element: HTMLElement): void;
+}
+
+export function definePrimitive(primitive: Primitive): Primitive {
+ return primitive;
+}
+
+export function scopeOf(primitive: Primitive): PrimitiveScope {
+ return primitive.id.split(".")[0] as PrimitiveScope;
+}
+
+export type PrimitiveValues = Record;
+
+export interface PrimitiveRun {
+ get(id: string): unknown;
+ set(id: string, value: unknown): void;
+ stop(): void;
+}
+
+// Pages YouTube keeps alive after navigating away sit under a hidden
+// ancestor. Being on screen cannot be the test, since hiding is what many
+// primitives do.
+const onLivePage = (element: Element) => !element.closest("[hidden]");
+
+// Keeps a set of values applied to the page until stopped: YouTube re-renders
+// feeds as they scroll and moves the player between containers, so a single
+// pass would drift out of date within seconds.
+export function runPrimitives(primitives: readonly Primitive[], initial: PrimitiveValues, route: RouteName): PrimitiveRun {
+ const onRoute = primitives.filter((p) => p.routes.includes(route));
+ const values: PrimitiveValues = { ...initial };
+ const touched = new Map>(onRoute.map((p) => [p, new Set()]));
+ let stopped = false;
+ let frame: number | undefined;
+
+ const matches = (p: Primitive): HTMLElement[] => {
+ if (p.all) return [...document.querySelectorAll(p.strategies.join(", "))].filter(onLivePage);
+ for (const strategy of p.strategies) {
+ const element = [...document.querySelectorAll(strategy)].find(onLivePage);
+ if (element) return [element];
+ }
+ return [];
+ };
+
+ const apply = () => {
+ for (const p of onRoute) {
+ const value = values[p.id];
+ const seen = touched.get(p)!;
+ const current = value === undefined ? [] : matches(p);
+ for (const el of seen) {
+ if (!current.includes(el)) {
+ p.reset(el);
+ seen.delete(el);
+ }
+ }
+ for (const el of current) {
+ p.apply(el, value);
+ seen.add(el);
+ }
+ }
+ };
+
+ const schedule = () => {
+ if (frame !== undefined) return;
+ frame = requestAnimationFrame(() => {
+ frame = undefined;
+ if (!stopped) apply();
+ });
+ };
+
+ const observer = new MutationObserver(schedule);
+ observer.observe(document.body, { childList: true, subtree: true });
+
+ for (const p of onRoute) {
+ if (values[p.id] === undefined) continue;
+ void resolveTarget(p.strategies).then((resolution) => {
+ void setCapabilityStatus(p.id, scopeOf(p), resolution);
+ if (!stopped) schedule();
+ });
+ }
+ apply();
+
+ return {
+ get: (id) => values[id],
+ set(id, value) {
+ values[id] = value;
+ apply();
+ },
+ stop() {
+ stopped = true;
+ if (frame !== undefined) cancelAnimationFrame(frame);
+ observer.disconnect();
+ for (const [p, seen] of touched) for (const el of seen) p.reset(el);
+ touched.clear();
+ },
+ };
+}
diff --git a/web-ext/kernel/settings.test.ts b/web-ext/kernel/settings.test.ts
new file mode 100644
index 00000000..a7fa4636
--- /dev/null
+++ b/web-ext/kernel/settings.test.ts
@@ -0,0 +1,49 @@
+import { beforeEach, describe, expect, test } from "bun:test";
+import { defineSettings, migrateSettings } from "./settings";
+
+const slice = defineSettings("demo", { enabled: true, size: 3 }, {
+ legacy: (store) => {
+ const legacy = store.preferences as { demo?: { isEnabled: boolean } } | undefined;
+ return legacy?.demo ? { enabled: legacy.demo.isEnabled } : undefined;
+ },
+});
+
+beforeEach(() => chrome.storage.sync.clear());
+
+describe("settings slice", () => {
+ test("reads defaults when nothing is stored", async () => {
+ expect(await slice.get()).toEqual({ enabled: true, size: 3 });
+ });
+
+ test("merges a partial patch and keeps other keys", async () => {
+ await slice.set({ size: 5 });
+ expect(await slice.get()).toEqual({ enabled: true, size: 5 });
+ });
+
+ test("fills in a setting added after the user's last write", async () => {
+ await chrome.storage.sync.set({ [slice.key]: { enabled: false } });
+ expect(await slice.get()).toEqual({ enabled: false, size: 3 });
+ });
+});
+
+describe("legacy migration", () => {
+ test("copies the old value once, then leaves the slice alone", async () => {
+ await chrome.storage.sync.set({ preferences: { demo: { isEnabled: false } } });
+ await migrateSettings([slice]);
+ expect(await slice.get()).toEqual({ enabled: false, size: 3 });
+
+ await slice.set({ enabled: true });
+ await migrateSettings([slice]);
+ expect((await slice.get()).enabled).toBe(true);
+ });
+
+ test("two writes in the same tick both land", async () => {
+ await Promise.all([slice.set({ enabled: false }), slice.set({ size: 9 })]);
+ expect(await slice.get()).toEqual({ enabled: false, size: 9 });
+ });
+
+ test("writes nothing when there is no legacy value", async () => {
+ await migrateSettings([slice]);
+ expect(await chrome.storage.sync.get()).toEqual({});
+ });
+});
diff --git a/web-ext/kernel/settings.ts b/web-ext/kernel/settings.ts
new file mode 100644
index 00000000..e8929196
--- /dev/null
+++ b/web-ext/kernel/settings.ts
@@ -0,0 +1,79 @@
+// Each feature owns one settings slice under its own storage key, so writing
+// one feature's settings can never touch another's. The slice is read with its
+// defaults merged in, which is also how a new setting reaches existing users.
+
+export type StorageArea = "sync" | "local";
+
+export interface SettingsOptions {
+ // Sync by default; local for data too large or too personal to roam.
+ area?: StorageArea;
+ legacy?: (store: Record) => Partial | undefined;
+}
+
+export interface Settings {
+ key: string;
+ area: StorageArea;
+ defaults: T;
+ get(): Promise;
+ set(patch: Partial): Promise;
+ subscribe(listener: (value: T) => void): () => void;
+ legacy?: (store: Record) => Partial | undefined;
+}
+
+export function defineSettings(
+ id: string,
+ defaults: T,
+ { area = "sync", legacy }: SettingsOptions = {},
+): Settings {
+ const key = `settings:${id}`;
+ const storage = chrome.storage[area];
+ // Writes are merged into what is stored, so two made in the same tick would
+ // both merge into the same old value and one would be lost; they queue.
+ let writing = Promise.resolve();
+
+ const get = async (): Promise => {
+ const stored = await storage.get(key);
+ return { ...defaults, ...(stored[key] as Partial | undefined) };
+ };
+
+ return {
+ key,
+ area,
+ defaults,
+ legacy,
+ get,
+ set(patch) {
+ const write = writing.then(async () => {
+ const current = await get();
+ await storage.set({ [key]: { ...current, ...patch } });
+ });
+ writing = write.catch(() => {});
+ return write;
+ },
+ subscribe(listener) {
+ const onChanged = (changes: Record, changedArea: string) => {
+ if (changedArea !== area || !(key in changes)) return;
+ listener({ ...defaults, ...(changes[key].newValue as Partial | undefined) });
+ };
+ chrome.storage.onChanged.addListener(onChanged);
+ return () => chrome.storage.onChanged.removeListener(onChanged);
+ },
+ };
+}
+
+// Runs once per slice: copies the value out of the pre-kernel store shape
+// when the slice has never been written.
+export async function migrateSettings(slices: Settings[]): Promise {
+ const stores = {
+ sync: (await chrome.storage.sync.get(undefined)) as Record,
+ local: (await chrome.storage.local.get(undefined)) as Record,
+ };
+ for (const slice of slices) {
+ const all = stores[slice.area];
+ if (!slice.legacy || slice.key in all) continue;
+ // A mapper may hand back undefined for fields the old store lacked; those
+ // must not shadow the defaults.
+ const value = Object.fromEntries(Object.entries(slice.legacy(all) ?? {}).filter(([, v]) => v !== undefined));
+ if (Object.keys(value).length) await chrome.storage[slice.area].set({ [slice.key]: { ...slice.defaults, ...value } });
+ }
+}
diff --git a/web-ext/kernel/useSettings.ts b/web-ext/kernel/useSettings.ts
new file mode 100644
index 00000000..ec0d720b
--- /dev/null
+++ b/web-ext/kernel/useSettings.ts
@@ -0,0 +1,28 @@
+import { useEffect, useState } from "preact/hooks";
+import type { Settings } from "./settings";
+
+// Component binding for a settings slice, for the popup and options pages.
+export function useSettings(settings: Settings): {
+ value: T;
+ update: (patch: Partial) => void;
+} {
+ const [value, setValue] = useState(settings.defaults);
+
+ useEffect(() => {
+ let live = true;
+ void settings.get().then((v) => live && setValue(v));
+ const unsubscribe = settings.subscribe(setValue);
+ return () => {
+ live = false;
+ unsubscribe();
+ };
+ }, [settings]);
+
+ return {
+ value,
+ update: (patch) => {
+ setValue((v) => ({ ...v, ...patch }));
+ void settings.set(patch);
+ },
+ };
+}
diff --git a/web-ext/lib/audioTap.ts b/web-ext/lib/audioTap.ts
new file mode 100644
index 00000000..671fac13
--- /dev/null
+++ b/web-ext/lib/audioTap.ts
@@ -0,0 +1,42 @@
+const FFT_SIZE = 512;
+const SMOOTHING = 0.82;
+
+let context: AudioContext | undefined;
+const analysers = new WeakMap();
+const untappable = new WeakSet