Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
8df2a63
docs(probes): capture Save-to-playlist popup DOM structure
Kotmin Aug 7, 2026
254a379
docs(probes): add selected-state capture, find aria-pressed signal
Kotmin Aug 7, 2026
649be2c
docs(playlist): link overflow-scroll follow-up to issue #17
Kotmin Aug 7, 2026
3285399
feat(shortcuts): add shift-modifier chord namespace for Ctrl+A,Shift+P
Kotmin Aug 7, 2026
c995a7a
feat(core): add fuzzy-match utility for the playlist picker
Kotmin Aug 7, 2026
bf866a7
feat(ui): add native Save-to-playlist popup driver
Kotmin Aug 7, 2026
c68959e
feat(core): add cross-tab playlist catalog cache
Kotmin Aug 7, 2026
0394b9b
feat(ui): add playlist overlay state machine and DOM renderer
Kotmin Aug 7, 2026
beb7d22
feat(content): wire playlist picker into content.js keyboard handler
Kotmin Aug 7, 2026
d09e5bf
docs(playlist): document picker, changelog entry, open questions
Kotmin Aug 7, 2026
3a20bb1
feat(playlist): resolve native Save-to-playlist trigger selector
Kotmin Aug 7, 2026
c4e6ac6
feat(playlist): show sign-in badge for logged-out playlist chord
Kotmin Aug 7, 2026
7273a3c
fix(playlist): hide native Save-to-playlist sheet while it's driven
Kotmin Aug 7, 2026
08af8cc
fix(playlist): pre-check existing playlists and support removal
Kotmin Aug 7, 2026
e299c31
fix(playlist): restore native sheet visibility on close
Kotmin Aug 7, 2026
e386ab9
fix(playlist): verify native sheet close, fall back to trigger re-click
Kotmin Aug 7, 2026
8b1d122
perf(playlist): stop reopening the native popup per playlist row
Kotmin Aug 7, 2026
80904c1
feat(playlist): configurable toggle key, arrow check/uncheck, follow …
Kotmin Aug 7, 2026
97721b3
fix(playlist): drive the real create-playlist dialog, not a phantom f…
Kotmin Aug 7, 2026
6d4e561
fix(playlist): close the native create-playlist dialog after submit
Kotmin Aug 7, 2026
8cde89c
fix(playlist): click the dialog's Cancel button, not Escape, to close it
Kotmin Aug 7, 2026
16efd3d
fix(playlist): skip redundant sheet close after create-new success
Kotmin Aug 7, 2026
7e2c667
refactor(playlist): extract orchestration into a testable controller
Kotmin Aug 10, 2026
8eb2cb2
docs(playlist): add picker shortcuts to README, document controller p…
Kotmin Aug 10, 2026
c75ac17
docs(release): document semver bump policy
Kotmin Aug 10, 2026
49fee68
chore(release): bump to 0.3.0
Kotmin Aug 10, 2026
10151ad
docs(keyboard): lead spec with Configuration, add remap examples
Kotmin Aug 10, 2026
6dcc04b
docs(readme): rename Status to Browser Support, link Edge/Arc listings
Kotmin Aug 10, 2026
0962040
docs(readme): star-annotate Edge/Arc status, move caveat out of table
Kotmin Aug 10, 2026
ceb3d02
fix(queue-overlay): match menuitem role on popup item's inner button
Kotmin Aug 16, 2026
cbd91d2
feat(playlist): add best-effort Shorts fallback for save-to-playlist …
Kotmin Aug 16, 2026
52dfef3
docs(changelog): note queue-overlay and Shorts save-trigger fixes
Kotmin Aug 16, 2026
756dd65
chore(release): bump to 0.3.1
Kotmin Aug 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ 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
- Playlist picker: `Ctrl+A, Shift+P` opens a fuzzy-search overlay to add the current watch-page video to one or more playlists, or create a new one, without leaving the keyboard. Gated to logged-in users; catalog is cached across tabs. Pre-checks playlists the video already belongs to and supports removing it from them in the same session. Toggle/check/uncheck keys and arrow-driven scroll-follow are configurable.
- Unit coverage for the playlist orchestration logic (`src/ui/playlist-controller.js`), extracted from `content.js`'s previously untested closure so the open/close sequencing, idempotent toggling, and create-then-add flow are verified by `node --test` instead of only by live-site manual testing.

### Fixed
- Native "Save to playlist" sheet is hidden while driven, verified closed (falling back to a trigger re-click), and restored on close, instead of flashing visibly through each scripted step.
- Create-new-playlist sub-dialog now drives the real native dialog (typing, submit, Cancel-button close) instead of a phantom field, and no longer performs a redundant close after a successful create.

## [0.2.0] - 2026-08-06

### Added
Expand Down
9 changes: 9 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ Both must pass before opening a PR.

Conventional commits: `type(scope): description`, types `feat fix docs test refactor chore ci build perf`. Keep commits atomic — one logical change per commit, only the files it actually touches.

## Versioning

[Semantic Versioning](https://semver.org/). Default bump for a release PR to `main`:

- **Minor** (`0.X.0`) — a feature, new shortcut, layout change, or other user-visible addition.
- **Patch** (`0.0.X`) — a fix or other change with no new user-facing capability.

## Keyboard shortcuts

If you add or change a shortcut:
Expand All @@ -36,3 +43,5 @@ If you add or change a shortcut:
## Tests

Unit tests run via Node's built-in test runner (`node --test tests/unit/*.test.js`, no test framework dependency). Add or update tests for any behavior change in `src/`.

Code that drives the live page (native popups, DOM scraping, keyboard dispatch) must be a plain function/factory in `src/` taking `document`/`window` as parameters — never written directly inside `apps/shared/src/content/content.js`'s closure. That closure isn't imported by any test, so anything left in it is untested by definition; see `src/ui/playlist-controller.js` (tested in `tests/unit/playlist-controller.test.js` with fake `doc`/`win`/DOM-node objects) for the pattern, and keep `content.js` itself down to wiring — importing modules, constructing them, and forwarding events.
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ Press `Ctrl+A`, then a command key (tmux-style prefix — outside that window ev
| Chord | Action |
| --- | --- |
| `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, 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 @@ -25,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.2.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.2.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.2.0",
"version": "0.3.1",
"description": "Preserves default video playback settings, starting with YouTube playback speed.",
"permissions": [
"storage"
Expand Down
38 changes: 35 additions & 3 deletions apps/shared/src/content/content.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,31 @@
} = await import(browser.runtime.getURL('lib/core/playback-state.js'));
const { MESSAGE_TYPES, validateMessage } = await import(browser.runtime.getURL('lib/core/validation.js'));
const { debounce } = await import(browser.runtime.getURL('lib/core/debounce.js'));
const { isYouTubeWatchPage, findVideoElement, createYouTubeSiteAdapter } =
await import(browser.runtime.getURL('lib/site-adapters/youtube/youtube-site-adapter.js'));
const {
isYouTubeWatchPage, findVideoElement, createYouTubeSiteAdapter,
isLoggedIn, findSaveToPlaylistTrigger,
} = await import(browser.runtime.getURL('lib/site-adapters/youtube/youtube-site-adapter.js'));
const { createPlayerAdapter } = await import(browser.runtime.getURL('lib/player-adapters/html5-video-player-adapter.js'));
const {
COMMANDS, SPEED_SHORTCUTS, createShortcutController, generateLabels, filterLabelPairs,
COMMANDS, SPEED_SHORTCUTS, PLAYLIST_KEYS, createShortcutController, generateLabels, filterLabelPairs,
} = await import(browser.runtime.getURL('lib/core/keyboard-shortcuts.js'));
const { collectJumpTargets, createJumpOverlay } =
await import(browser.runtime.getURL('lib/ui/jump-overlay.js'));
const { collectQueueTargets, activateQueueTarget, showQueueConfirmation } =
await import(browser.runtime.getURL('lib/ui/queue-overlay.js'));
const {
openSaveToPlaylistPopup, togglePlaylistRow, driveCreateNewPlaylist, closeSaveToPlaylistPopup,
} = await import(browser.runtime.getURL('lib/ui/playlist-popup-driver.js'));
const { createPlaylistCache } = await import(browser.runtime.getURL('lib/core/playlist-cache.js'));
const {
createOverlayState, moveHighlight, typeChar, backspace, toggleHighlighted, checkHighlighted, uncheckHighlighted,
resolveEnter, openCreateDialog, typeInCreateDialog, backspaceInCreateDialog, closeCreateDialog, commitCreatedPlaylist,
resolveCreatedPlaylistChanges,
} = await import(browser.runtime.getURL('lib/ui/playlist-overlay-state.js'));
const {
createPlaylistOverlay, showPlaylistProgress, finishPlaylistProgress, showNotLoggedInBadge,
} = await import(browser.runtime.getURL('lib/ui/playlist-overlay.js'));
const { createPlaylistController } = await import(browser.runtime.getURL('lib/ui/playlist-controller.js'));

const isMac = isMacPlatform(navigator);
let settings = null;
Expand Down Expand Up @@ -143,6 +158,18 @@
labelState = { pairs, typed: '', mode: 'queue' };
}

const playlistController = createPlaylistController(document, window, {
findVideoElement, findSaveToPlaylistTrigger, isLoggedIn,
playlistCache: createPlaylistCache(browser),
playlistOverlay: createPlaylistOverlay(document),
openSaveToPlaylistPopup, togglePlaylistRow, driveCreateNewPlaylist, closeSaveToPlaylistPopup,
showPlaylistProgress, finishPlaylistProgress, showNotLoggedInBadge,
createOverlayState, moveHighlight, typeChar, backspace, toggleHighlighted, checkHighlighted, uncheckHighlighted,
resolveEnter, openCreateDialog, typeInCreateDialog, backspaceInCreateDialog, closeCreateDialog, commitCreatedPlaylist,
resolveCreatedPlaylistChanges,
playlistKeys: PLAYLIST_KEYS,
});

function handleLabelKey(e) {
e.preventDefault();
e.stopPropagation();
Expand Down Expand Up @@ -178,6 +205,10 @@
if (!['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) handleLabelKey(e);
return;
}
if (playlistController.isOpen()) {
if (!['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) playlistController.handleKey(e);
return;
}

const t = e.target;
const isEditable = t != null && (t.isContentEditable === true
Expand All @@ -197,6 +228,7 @@
if (result.command === COMMANDS.GO_HOME) goHome();
if (result.command === COMMANDS.SHOW_JUMP_LABELS) openOverlay();
if (result.command === COMMANDS.SHOW_QUEUE_LABELS) openQueueOverlay();
if (result.command === COMMANDS.SHOW_PLAYLIST_LABELS) playlistController.open();
if (result.command in SPEED_SHORTCUTS) setDefaultSpeedFromShortcut(SPEED_SHORTCUTS[result.command]);
if (result.command === COMMANDS.TOGGLE_AUTO_APPLY) toggleAutoApply();
}, true);
Expand Down
77 changes: 77 additions & 0 deletions docs/ai/questions-for-K.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,80 @@ Chrome/Edge. Needs your go/no-go per browser before any research or
implementation starts (Opera is cheap to explore, Safari is a materially
different pipeline — macOS runner, paid Apple account, Xcode project).
**Default:** no work started on either until you answer in that issue.

---

Dated 2026-08-07. Surfaced while implementing issue #16 (playlist picker,
built with your sign-off from this batch). Same convention: default is what
I proceeded with, code is flagged `UNVERIFIED`/`ponytail:` at each spot.

## Q13 — native "Save to playlist" trigger button on the watch page (RESOLVED 2026-08-07)

Live DOM probe (headless Firefox, logged out) found the trigger:
`#flexible-item-buttons > yt-button-view-model button[aria-label]` — Save is
the sole `yt-button-view-model`-wrapped child of `#flexible-item-buttons`;
Download uses a different wrapper. `findSaveToPlaylistTrigger` now returns
this instead of the `null` stub, so `Ctrl+A, Shift+P` is live. Details and
remaining gaps (untested logged-in session, untested Shorts layout — a
"Clip" button there might share the same wrapper and break the "sole child"
assumption) in `docs/probes/save-trigger-dom-findings.md`. Flag if this
turns out wrong in real use.

## Q14 — login detection (`isLoggedIn`)

`isLoggedIn` checks for `#avatar-btn` in the masthead (present when signed
in; signed-out shows a "Sign in" link instead) — a reasonable, structurally-
grounded guess, but never independently confirmed against a captured
signed-out DOM.
**Default:** ship as best-effort; if wrong, the picker either never opens
for a logged-in user (safe, just annoying) or attempts to open for a
signed-out one and then fails harmlessly at the trigger-button stub (Q13)
either way, so the failure mode is safe regardless.

## Q15 — create-new post-click UI shape

Per your "best-effort, ponytail-flagged" answer: `driveCreateNewPlaylist`
(`src/ui/playlist-popup-driver.js`) assumes clicking the footer "create new"
button reveals an inline text input/contenteditable inside the same sheet,
and submits by setting its value and dispatching an `Enter` keydown. This
was never captured — could be a separate dialog, a different submit
mechanism (dedicated button vs. Enter), or something else entirely.
**Default:** shipped as described; self-heals to a no-op (returns `false`,
overlay state left untouched) if no field appears within 1.5 s. Needs
verification during real-browser testing, same as Q13.

## Q16 — playlist identity when names collide

The picker keys playlists by name (no stable DOM id exists on the row —
only the `aria-label` text). Two playlists with the same name (YouTube
allows this) would be indistinguishable to fuzzy search, checkbox state, and
the add sequence (`rows.find((r) => r.name === name)` would always resolve
to whichever matches first).
**Default:** accepted as a known limitation, not fixed — no id-bearing DOM
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.
8 changes: 8 additions & 0 deletions docs/keyboard-quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +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) — 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 All @@ -22,5 +23,12 @@ want via the extension's stored settings, same as any other override.
| Two-char label (e.g. `AA`) | Filter/select the labelled target; typing the full label activates it | Jump/queue overlay open |
| `Backspace` | Remove last typed label character | Jump/queue overlay open |
| `Esc` | Close overlay | Jump/queue overlay open |
| Type letters | Fuzzy-filter playlists by name | Playlist picker open |
| `↑`/`↓` | Move highlight (wraps, includes "+ Create new" as the last row) | Playlist picker open |
| `Space` | Toggle a checkbox on the highlighted playlist (local only, up to 5, configurable via `src/core/shortcuts.config.json`'s `playlistKeys.toggle`) | Playlist picker open |
| `→` | Check the highlighted playlist (configurable via `playlistKeys.check`) | Playlist picker open |
| `←` | Uncheck the highlighted playlist (configurable via `playlistKeys.uncheck`) | Playlist picker open |
| `Enter` | Add to checked playlists, or the highlighted one if none checked, or open create-new | Playlist picker open |
| `Esc` | Close create-new sub-dialog, or the picker if none open | Playlist picker open |

Pending prefix auto-cancels after 2 seconds if no chord key follows.
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.
Loading