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" && ( + + )} +
+ ); +} 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 ( + + ); + })} +
+ ); +} + +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 ( +
+ + label="Profile" + value={chosenProfile} + options={profiles.map((p) => ({ value: p.id, label: p.name }))} + onChange={setProfileId} + /> + + label="Switch by" + value={kind} + options={[ + { value: "time", label: "Time", description: "On chosen days, between two times" }, + { value: "place", label: "Place", description: "While you are at a saved place" }, + ]} + onChange={setKind} + /> + {kind === "time" ? ( + <> + + + + + setStart(e.currentTarget.value)} class={`tw-w-32 ${INPUT_CLASS}`} /> + + + setEnd(e.currentTarget.value)} class={`tw-w-32 ${INPUT_CLASS}`} /> + + + ) : places.length ? ( + + label="Place" + value={chosenPlace} + options={places.map((place) => ({ value: place.id, label: place.name }))} + onChange={setPlaceId} + /> + ) : ( +
No places yet. Add one under Places below first.
+ )} +
+ + +
+
+ ); +} + +function ScheduleRow({ summary, enabled, inCharge, onToggle, onMoveUp, onMoveDown, onDelete }: ScheduleRowProps) { + return ( +
+
+ {summary} + {inCharge && In charge} +
+
+ + + + + + + + + + +
+
+ ); +} + +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 + + + + + + {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. +
+ )} +
+ + +
+
+ ); +} + +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(" · ")} +
+ +
+ ); + }) + )} +
+
+ + ); +} 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 + 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) => ( + + ))} +
+ ); +} 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 })} + /> + +
+ +
+ +
+
+ + Features are re-checked the next time you open YouTube. +
+ +
+
+
+
+ + ); +} 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 && } + + { + 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 ( +
+
+ + 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 })} + /> +
+
+
+ +
+ + +
+
+ ); +} + +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} +
+ +
+ ); +} + +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 + +
+
+ + + + +
+
+ ); +} + +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)} + /> + ))} + +
+ +
+ + { + // currentTarget is null once the handler first awaits. + const input = e.currentTarget; + const file = input.files?.[0]; + input.value = ""; + if (!file) return; + const result = await importProfileFromFile(file); + if (!result.ok) { + toast.error("Import failed", result.message); + return; + } + await createProfile({ + name: result.name, + primitives: result.primitives, + }); + await load(); + toast.success(`"${result.name}" imported`); + }} + /> + +
+ ) : 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 })} + /> + +
+ +
+ + update({ toggleRate: Number(v) })} + /> + +
+ +
+ + update({ seekBackward: Number(v) })} + /> + update({ seekForward: Number(v) })} + /> + +
+
+ + ); +} 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. + +
+ +
+ ))} + +
+
+ + update({ enabled })} + /> + update({ defaultRate: Number(v) })} + /> + update({ customRates: parseRates(v) ?? [] })} + /> + update({ toggleRate: Number(v) })} + /> + update({ rateStep: Number(v) })} + /> + +
+ +
+ + update({ seekBackward: Number(v) })} + /> + update({ seekForward: Number(v) })} + /> + +
+ +
+ + segments.update({ enabled })} + /> + + label="Auto-load on page open" + description="Whether to automatically restore segments when you open a video. Per-video pins (set in the player panel) override this setting." + value={segments.value.autoLoad} + options={[ + { value: "off", label: "Off — manual only (press Z to load)" }, + { value: "last-used", label: "Restore last-used session" }, + { value: "default", label: "Restore default saved config" }, + ]} + onChange={(autoLoad) => segments.update({ autoLoad })} + /> + isNudgeStep(v, segments.value.nudgeMaxStep)} + errorMessage="Must be more than 0 and no more than the nudge maximum" + onChange={(v) => segments.update({ nudgeBaseStep: Number(v) })} + /> + segments.update({ nudgeMultiplier: Number(v) })} + /> + isNudgeMax(v, segments.value.nudgeBaseStep)} + errorMessage="Must be at least the nudge step" + onChange={(v) => segments.update({ nudgeMaxStep: Number(v) })} + /> + +
+ +
+ + {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