Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html)

## [Unreleased]

## [0.3.1] - 2026-08-16

### Fixed
- Queue overlay ("Add to queue") stopped working everywhere: YouTube moved `role="menuitem"` off the popup item wrapper onto its inner button/link, so the item was never found after the trigger opened the menu.
- Playlist picker (`Ctrl+A, Shift+P`) no-op'd on the Shorts standalone player; it now also checks the Shorts header "..." menu for the Save-to-playlist trigger (best-effort, not yet live-confirmed — see `docs/ai/questions-for-K.md`).

## [0.3.0] - 2026-08-10

### Added
Expand Down
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Press `Ctrl+A`, then a command key (tmux-style prefix — outside that window ev
| --- | --- |
| `Ctrl+A` `o` | Open jump overlay (labels clickable elements) |
| `Ctrl+A` `p` | Open queue overlay (add a video to the Up Next queue) |
| `Ctrl+A` `Shift+P` | Open playlist picker for the current video (fuzzy-search, multi-add, create new) — watch page only, requires being logged in |
| `Ctrl+A` `Shift+P` | Open playlist picker for the current video (fuzzy-search, multi-add, create new) — watch page, and Shorts on a best-effort basis, requires being logged in |
| `Ctrl+A` `y` | Go home |
| `Ctrl+A` `v` | Set speed to preset 1 (default `1x`) |
| `Ctrl+A` `b` | Set speed to preset 2 (default `1.5x`) |
Expand All @@ -27,15 +27,21 @@ Full reference: [docs/keyboard-quickstart.md](docs/keyboard-quickstart.md).

Edit `src/core/shortcuts.config.json` to change the prefix key, any chord binding, or the three preset speed values. Speed values must fall within the allowed range (`0.25`–`4.0`), but only use values YouTube itself will actually honor for your account — e.g. speeds above `2x` are a YouTube Premium feature, so a non-Premium account silently caps at `2x` regardless of what's configured here. Respect the platform's own limits when picking a value. Details: [docs/specs/keyboard-shortcuts.md](docs/specs/keyboard-shortcuts.md).

## Status
## Browser Support

Active development on `dev` branch. Also works on Chromium-based browsers.
Active development on `dev` branch.

| Browser | Status | Listing |
| --- | --- | --- |
| 🦊 Firefox (AMO) | Published | [addons.mozilla.org/.../videodefaults](https://addons.mozilla.org/en-US/firefox/addon/videodefaults/) |
| 🌐 Chrome (Web Store) | Published | [chromewebstore.google.com/.../videodefaults](https://chromewebstore.google.com/detail/videodefaults/olbdclkanolgkhfghooecilkhbghadob) |
| 🟦 Edge (Add-ons) | Not yet submitted | — |
| 🟦 Edge | Unofficial* | [chromewebstore.google.com/.../videodefaults](https://chromewebstore.google.com/detail/videodefaults/olbdclkanolgkhfghooecilkhbghadob) |
| 🧭 Arc | Unofficial* | [chromewebstore.google.com/.../videodefaults](https://chromewebstore.google.com/detail/videodefaults/olbdclkanolgkhfghooecilkhbghadob) |

\* Not yet submitted to Edge Add-ons or Arc's own store. Both are
Chromium-based and can install the Chrome Web Store listing above instead.
Edge requires enabling "Allow extensions from other stores" in
`edge://extensions` first; Arc installs directly, no extra setting needed.

## Requirements

Expand Down
2 changes: 1 addition & 1 deletion apps/chrome-extension/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "VideoDefaults",
"version": "0.3.0",
"version": "0.3.1",
"description": "Preserves default video playback settings, starting with YouTube playback speed.",
"permissions": [
"storage"
Expand Down
2 changes: 1 addition & 1 deletion apps/edge-extension/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "VideoDefaults",
"version": "0.3.0",
"version": "0.3.1",
"description": "Preserves default video playback settings, starting with YouTube playback speed.",
"permissions": [
"storage"
Expand Down
2 changes: 1 addition & 1 deletion apps/firefox-extension/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "VideoDefaults",
"version": "0.3.0",
"version": "0.3.1",
"description": "Preserves default video playback settings, starting with YouTube playback speed.",
"permissions": [
"storage"
Expand Down
24 changes: 24 additions & 0 deletions docs/ai/questions-for-K.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,27 @@ to whichever matches first).
signal was found in probes to key on instead. Flag if this turns out to
matter in practice (e.g. your account actually has duplicate-named
playlists).

---

Dated 2026-08-16. Surfaced while implementing issue #20 (Shorts playlist-
picker gap).

## Q17 — Shorts "Save to playlist" trigger selector, JSON-inferred only

`findSaveToPlaylistTrigger` now falls back to
`ytd-reel-player-header-renderer ytd-menu-renderer button` when the
existing `/watch`-page selector finds nothing, to cover the Shorts
standalone player (`/shorts/<id>`). This is derived only from a captured
Shorts-page JSON snapshot's renderer names (`reelPlayerHeaderRenderer`,
`menuRenderer`, both confirming a "Save to playlist" item) plus this
codebase's own already-confirmed `<FooRenderer>` -> `ytd-foo-renderer`
naming convention — it has **not** been checked against a live/rendered
Shorts DOM (no working headless browser in this sandbox this session
either; see `docs/probes/save-trigger-dom-findings.md` follow-up section
for the exact reasoning and what would falsify it).
**Default:** ship the selector above. If wrong, same failure mode as every
other best-effort selector in this file: `findSaveToPlaylistTrigger`
returns `null`, `openPlaylistOverlay` no-ops, safe no-op with no user-
visible error, just `Shift+P` staying silently unavailable on Shorts.
Please confirm the real selector live when convenient.
2 changes: 1 addition & 1 deletion docs/keyboard-quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ want via the extension's stored settings, same as any other override.
| `Ctrl+A` | Prefix — arms the next key as a command (default binding, configurable) | Global |
| `Ctrl+A` then `o` | Open jump overlay (labels clickable elements) | Global |
| `Ctrl+A` then `p` | Open queue overlay (labels videos with an "Add to queue" option; typing a label adds that video next) | Global |
| `Ctrl+A` then `Shift+P` | Open playlist picker for the current video (fuzzy-search playlists, checkbox multi-add, create new) — watch page only, requires being logged in | Watch page |
| `Ctrl+A` then `Shift+P` | Open playlist picker for the current video (fuzzy-search playlists, checkbox multi-add, create new) — requires being logged in | Watch page, and Shorts on a best-effort basis (see issue #20) |
| `Ctrl+A` then `y` | Go home (click YouTube logo, or navigate to configured home URL) | Global |
| `Ctrl+A` then `v` | Set playback speed to preset 1 (default `1×`) | Global |
| `Ctrl+A` then `b` | Set playback speed to preset 2 (default `1.5×`) | Global |
Expand Down
22 changes: 22 additions & 0 deletions docs/probes/add-to-queue-dom-findings.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,3 +292,25 @@ genuinely never replaces the node — it falls back to clicking whatever's there
pre-existing behavior rather than failing outright. Covered by two new unit tests in
`tests/unit/queue-overlay.test.js`: one simulating a poll that returns the stale node for a
couple of ticks before the fresh one appears, one simulating a node that never changes at all.

## Follow-up: YouTube moved `role="menuitem"` off the item wrapper (2026-08-16)

User reported the queue overlay regressing to a no-op: the "..." trigger still opened a popup
(visible only as a brief touch-feedback ripple), but no video was ever added. Captured a fresh
live DOM (`playwright` launched directly per [[feedback_firefox_testing]] — the `mcp__playwright__*`
tools still fail to launch a browser in this sandbox) against a real watch page.

Root cause: `yt-list-item-view-model`, which previously carried `role="menuitem"` itself, now
carries `role="presentation"` — the role moved to its inner interactive child
(`<button role="menuitem">` for "Add to queue", confirmed `<a role="menuitem">` for
"Save to playlist" when signed out). `QUEUE_MENU_ITEM_SELECTOR`'s view-model branch
(`yt-list-item-view-model[role="menuitem"]`) therefore matched nothing, so
`waitForFreshMenuItem` always timed out and `activateQueueTarget` returned `false` without
clicking anything — the popup opening/closing is genuine YouTube behavior, not our code doing
anything wrong up to that point.

Fix: select the descendant instead of the wrapper —
`yt-list-item-view-model [role="menuitem"]` (space, not attribute-on-self). Position-0 is still
"Add to queue" in the new shape; verified live end-to-end (trigger click → item found → item
click → `ytd-playlist-panel-renderer` appears). The legacy `ytd-menu-service-item-renderer`
branch is a different, unaffected component and was left as-is.
46 changes: 46 additions & 0 deletions docs/probes/save-trigger-dom-findings.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,49 @@ Live capture only (headless Firefox via the `playwright` devDependency,
run as a throwaway script, not committed) — no HTML fragment saved
alongside this doc since the page contains no personal/account data in the
logged-out state captured.

## Follow-up 2026-08-16: Shorts trigger, JSON-inferred only (issue #20)

Resolves the "Shorts / other layouts" gap flagged above. A captured live
JSON snapshot of a real Shorts URL (`docs/probes/*short_page.html`)
confirms Shorts genuinely offers "Save to playlist" — the JSON path
`overlay.reelPlayerOverlayRenderer.reelPlayerHeaderSupportedRenderers
.reelPlayerHeaderRenderer.menu.menuRenderer.items` contains a
`listItemViewModel` with `title.content == "Save to playlist"`, alongside
"Description", "Ambient mode", etc. This is the "..." menu near the
channel name/timestamp at the top of the Shorts player, not the right-hand
like/comment/share/remix action rail — that rail (`reelActionBarViewModel`
in the same JSON) only exposes those four actions and has no menu.

`reelPlayerHeaderRenderer` and the sibling `menuRenderer` are both
`Renderer`-suffixed (legacy, non-"ViewModel") JSON node types. This
codebase has already confirmed, independently, that this JSON node family
maps 1:1 to a `<ytd-kebab-case-name>` custom element (see
`ytd-menu-service-item-renderer`, `ytd-video-renderer` in
`src/ui/queue-overlay.js`, and `ytd-menu-renderer` in the "Action row
structure" section above). Combined with the "..." trigger being the
extremely common, well-established `ytd-menu-renderer button` pattern
already used elsewhere in this exact codebase (see above), the selector
shipped for `findSaveToPlaylistTrigger`'s Shorts fallback is:

```
ytd-reel-player-header-renderer ytd-menu-renderer button
```

**Still unverified — this is JSON-inferred, not live-DOM-confirmed.** No
headless browser worked in this sandbox for this session either (same
`navigator.webdriver` / hydration ceiling documented in
`add-to-queue-dom-findings.md`). What would falsify this:

- `ytd-reel-player-header-renderer` not existing as a real custom element
tag at all (the naming convention breaking for this particular renderer).
- The header's "..." trigger not being wrapped in `ytd-menu-renderer`, or
not being a plain `<button>` (e.g. a `yt-icon-button` with no inner
`<button>`, or an id-gated element like `#button` needing a more specific
selector).
- More than one `ytd-menu-renderer button` existing under the Shorts
header, making the selector ambiguous rather than simply wrong.

Self-heals to a missed trigger (`openPlaylistOverlay` no-ops, same failure
mode as every other best-effort selector in this codebase) if any of the
above turns out true. Flagged for K in `docs/ai/questions-for-K.md`.
150 changes: 82 additions & 68 deletions docs/specs/keyboard-shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,88 @@ Status: implemented 2026-07-17 (all editions; shared source). Playlist picker
(`Ctrl+A, Shift+P`) added 2026-08-07 (issue #16) — see below; blocked on two
unverified DOM assumptions, see `docs/ai/questions-for-K.md`.

## Configuration

Everything: prefix, chords, `shiftChords`, speeds, `homeUrl`, is
user-remappable via settings, no code change needed. The default chords are
deliberately chosen to never collide with YouTube's own single-key shortcuts
(`k`, `j`, `l`, `f`, `m`, digits, arrows, …, see `RESERVED_YOUTUBE_KEYS`):
they only ever fire as a **prefix + key combo**, so the same letter that's a
YouTube shortcut on its own can safely be a chord after the prefix. The
prefix's fallback also adapts to the platform: macOS resolves to `⌘A`
(`{ key: 'a', ctrl: false, meta: true }`) automatically, Windows/Linux to
`Ctrl+A`, detected via `isMacPlatform()` (`src/core/platform.js`) and only
applied when no `keymap.prefix` is already stored (an existing stored value,
valid or user-set, always wins; `DEFAULT_KEYMAP` itself stays OS-agnostic).

### Remap examples

Overrides live in `browser.storage.local` under
`videodefaults_settings.keymap`, sanitized by `normalizeKeymap()` on every
read, and merged onto the defaults key-by-key: a partial override only
touches what you set, everything else keeps working:

- **Change the prefix**, e.g. to `Ctrl+Space` instead of `Ctrl+A`:
```json
{ "prefix": { "key": " ", "ctrl": true, "meta": false } }
```
- **Remap a single chord**, e.g. move "go home" off `y` onto `g`, leaving
every other default chord (`o`, `p`, `v`, `b`, `n`, `h`) untouched:
```json
{ "chords": { "g": "go-home" } }
```
- **Non-QWERTY or additional keyboard**: `key` matches `evt.key`, the
character your active layout actually produces, not the physical key
position. On an AZERTY layout the physical `Ctrl+A` key types `q`, so
either set `prefix.key` to whatever character that key produces on your
layout, or point it at a different physical key entirely:
```json
{ "prefix": { "key": "q", "ctrl": true, "meta": false } }
```

Full validation rules: `prefix` must include `ctrl` or `meta` (a plain-key
prefix is rejected so single-key YouTube shortcuts can never be shadowed);
`chords`/`shiftChords` keys must be a single a-z0-9 character mapping to a
known command; `homeUrl` must be `https://*.youtube.com`; `speeds` values are
validated by `validateSpeed()` (`src/core/speed.js`) and fall back to that
command's default otherwise. Settings changes apply live (storage listener);
no reload needed.

### Defaults reference

`DEFAULT_KEYMAP` and the default speed-shortcut values are sourced from the
checked-in `src/core/shortcuts.config.json`, sanitized at module load through
the same `normalizeKeymap()` / `normalizeSpeedShortcuts()` functions used for
storage-provided overrides (`src/core/keyboard-shortcuts.js`): retuning a
default is a one-file edit:

```json
{
"prefix": { "key": "a", "ctrl": true, "meta": false },
"chords": {
"o": "show-jump-labels",
"p": "show-queue-labels",
"y": "go-home",
"v": "set-speed-1",
"b": "set-speed-2",
"n": "set-speed-3",
"h": "toggle-auto-apply"
},
"shiftChords": {
"p": "show-playlist-labels"
},
"homeUrl": "https://www.youtube.com/",
"speeds": {
"set-speed-1": 1,
"set-speed-2": 1.5,
"set-speed-3": 2.0
}
}
```

No options UI yet: edit via storage or wait for the options page
(see `docs/ai/questions-for-K.md` Q8).

## Model

A prefix chord, like tmux: press the **prefix** (default `Ctrl+A`), then a
Expand Down Expand Up @@ -182,74 +264,6 @@ real site today** (both flagged `UNVERIFIED` in source, see
shape in `driveCreateNewPlaylist` are best-effort assumptions, not
independently DOM-captured.

## Configuration

`DEFAULT_KEYMAP` and the default speed-shortcut values are sourced from the
checked-in `src/core/shortcuts.config.json`, sanitized at module load through
the same `normalizeKeymap()` / `normalizeSpeedShortcuts()` functions used for
storage-provided overrides (`src/core/keyboard-shortcuts.js`) — retuning a
shortcut key or a speed value is a one-file edit, no code change needed:

```json
{
"prefix": { "key": "a", "ctrl": true, "meta": false },
"chords": {
"o": "show-jump-labels",
"p": "show-queue-labels",
"y": "go-home",
"v": "set-speed-1",
"b": "set-speed-2",
"n": "set-speed-3",
"h": "toggle-auto-apply"
},
"shiftChords": {
"p": "show-playlist-labels"
},
"homeUrl": "https://www.youtube.com/",
"speeds": {
"set-speed-1": 1,
"set-speed-2": 1.5,
"set-speed-3": 2.0
}
}
```

The keymap can also be overridden per-install in settings
(`browser.storage.local`, key `videodefaults_settings.keymap`), sanitized by
the same `normalizeKeymap()` on every read:

- `prefix` must include `ctrl` or `meta` (macOS users can set
`{ "key": "a", "ctrl": false, "meta": true }` for `⌘A`); a plain-key prefix
is rejected so single-key YouTube shortcuts can never be shadowed.
`RESERVED_YOUTUBE_KEYS` documents YouTube's own bindings.
- `chords` maps single keys to known commands; unknown commands are dropped.
A stored override is merged onto the defaults key-by-key, not swapped in
wholesale — a partial override (e.g. only remapping `o`) keeps every other
default chord (`p`, `y`, `v`, `b`, `n`, `h`) working.
- `shiftChords` is a separate map, same merge/validation rules as `chords`,
looked up only when the chord key is pressed with `Shift` held — pressing
`p` with Shift does **not** fall back to the unshifted `chords.p`
(`show-queue-labels`) if no `shiftChords.p` entry exists; it's simply
unbound.
- `homeUrl` must be an `https://*.youtube.com` URL (blocks `javascript:` and
third-party redirect targets).
- `speeds` maps each `set-speed-*` command to a numeric value validated by
`validateSpeed()` (`src/core/speed.js`); out-of-range or non-numeric entries
fall back to that command's default (see table above) — exposed as
`SPEED_SHORTCUTS`.
- Settings changes apply live (storage listener); no reload needed.

The prefix's fallback default is platform-aware: on macOS it resolves to
`{ key: 'a', ctrl: false, meta: true }` (`⌘A`) instead of the OS-agnostic
`Ctrl+A`, detected via `isMacPlatform()` (`src/core/platform.js`) and threaded
through as the `isMac` parameter on `normalizeKeymap()` / `applyDefaults()` /
`migrateSettings()`. This only applies when no `keymap.prefix` is already
stored — an existing stored value (valid or user-set) always wins, and
`DEFAULT_KEYMAP` itself stays OS-agnostic (`isMac` defaults to `false`).

No options UI yet — edit via storage or wait for the options page
(see `docs/ai/questions-for-K.md` Q8).

## Verification

- Unit: `tests/unit/keyboard-shortcuts.test.js` (state machine, keymap
Expand Down
19 changes: 18 additions & 1 deletion src/site-adapters/youtube/youtube-site-adapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,25 @@ export function isLoggedIn(document) {
// #flexible-item-buttons — untested against a logged-in session or Shorts
// layout, where another button (e.g. Clip) might share that wrapper.
// Self-heals to a missed trigger (openPlaylistOverlay no-ops) if wrong.
//
// ponytail: the Shorts fallback below (`ytd-reel-player-header-renderer
// ytd-menu-renderer button`) is derived from a captured Shorts-page JSON
// snapshot's renderer names, not a live/rendered DOM — this sandbox has no
// working headless browser (see docs/probes/save-trigger-dom-findings.md).
// The JSON confirms `overlay.reelPlayerOverlayRenderer
// .reelPlayerHeaderSupportedRenderers.reelPlayerHeaderRenderer` sits next to
// a sibling `menu.menuRenderer` whose items include "Save to playlist", and
// this codebase's own established `<FooRenderer>` -> `ytd-foo-renderer`
// naming convention (see `ytd-menu-service-item-renderer`, `ytd-video-renderer`
// in src/ui/queue-overlay.js) is the sole basis for the DOM tag names used
// here. Best-effort, self-heals to a missed trigger (openPlaylistOverlay
// no-ops) if wrong. Needs live confirmation — see issue #20 and
// docs/ai/questions-for-K.md.
const SHORTS_SAVE_TRIGGER_SELECTOR = 'ytd-reel-player-header-renderer ytd-menu-renderer button';

export function findSaveToPlaylistTrigger(document) {
return document.querySelector('#flexible-item-buttons > yt-button-view-model button[aria-label]');
return document.querySelector('#flexible-item-buttons > yt-button-view-model button[aria-label]')
?? document.querySelector(SHORTS_SAVE_TRIGGER_SELECTOR);
}

export function createYouTubeSiteAdapter(document, window) {
Expand Down
Loading