Skip to content
Closed
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html)

## [Unreleased]

### 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
2 changes: 1 addition & 1 deletion 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 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`.
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
7 changes: 5 additions & 2 deletions src/ui/queue-overlay.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,14 @@ const LEGACY_CARD_BUTTON_SELECTOR = '#menu button';
const LEGACY_HOVER_SETTLE_MS = 300;
// ponytail: legacy search-result cards (`ytd-video-renderer`) open a Polymer popup shaped
// differently from the view-model system's — items are `ytd-menu-service-item-renderer`
// under `#items`, not `yt-list-item-view-model[role="menuitem"]`. Confirmed via a real XPath
// under `#items`, not `yt-list-item-view-model [role="menuitem"]`. Confirmed via a real XPath
// captured from a live browser. Position 0 is "Add to queue" here too, same as the
// view-model shape.
// YouTube moved `role="menuitem"` off `yt-list-item-view-model` itself and onto its inner
// `<button>`/`<a>` (confirmed live, 2026-08-16) — the wrapper is now `role="presentation"`.
// Selecting the descendant instead of the wrapper keeps position-0 = "Add to queue".
const QUEUE_MENU_ITEM_SELECTOR = [
'ytd-popup-container yt-list-item-view-model[role="menuitem"]',
'ytd-popup-container yt-list-item-view-model [role="menuitem"]',
'ytd-popup-container ytd-menu-service-item-renderer',
].join(', ');
const MENU_WAIT_TIMEOUT_MS = 1500;
Expand Down
29 changes: 29 additions & 0 deletions tests/fixtures/youtube-shorts-basic.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>YouTube Shorts — Fixture</title>
</head>
<body>
<div id="page-manager">
<ytd-shorts>
<div id="shorts-player">
<video id="movie_player" playbackRate="1" readyState="4" src="about:blank"></video>
</div>
<ytd-reel-player-header-renderer>
<ytd-menu-renderer>
<button aria-label="More actions">More actions</button>
</ytd-menu-renderer>
</ytd-reel-player-header-renderer>
<ytd-reel-player-overlay-renderer>
<ytd-reel-action-bar-renderer>
<button id="like-button">Like</button>
<button id="comments-button">Comments</button>
<button id="share-button">Share</button>
<button id="remix-button">Remix</button>
</ytd-reel-action-bar-renderer>
</ytd-reel-player-overlay-renderer>
</ytd-shorts>
</div>
</body>
</html>
4 changes: 3 additions & 1 deletion tests/fixtures/youtube-watch-basic.html
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ <h1 class="title">Fixture video title</h1>
</div>
</div>
<ytd-popup-container>
<yt-list-item-view-model role="menuitem" id="add-to-queue-item">Add to queue</yt-list-item-view-model>
<yt-list-item-view-model role="presentation">
<button role="menuitem" id="add-to-queue-item">Add to queue</button>
</yt-list-item-view-model>
</ytd-popup-container>
</body>
</html>
2 changes: 1 addition & 1 deletion tests/unit/queue-overlay.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ describe('activateQueueTarget', () => {
querySelector: (sel) => {
assert.equal(
sel,
'ytd-popup-container yt-list-item-view-model[role="menuitem"], '
'ytd-popup-container yt-list-item-view-model [role="menuitem"], '
+ 'ytd-popup-container ytd-menu-service-item-renderer',
);
queried = true;
Expand Down
24 changes: 23 additions & 1 deletion tests/unit/youtube-site-adapter.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,17 +105,39 @@ describe('isLoggedIn', () => {

describe('findSaveToPlaylistTrigger', () => {
const SAVE_SELECTOR = '#flexible-item-buttons > yt-button-view-model button[aria-label]';
const SHORTS_SELECTOR = 'ytd-reel-player-header-renderer ytd-menu-renderer button';

it('returns the button matching the Save wrapper selector', () => {
const btn = {};
const doc = { querySelector: (sel) => (sel === SAVE_SELECTOR ? btn : null) };
assert.equal(findSaveToPlaylistTrigger(doc), btn);
});

it('returns null when the Save wrapper is absent', () => {
it('returns null when neither the watch-page nor the Shorts selector matches', () => {
const doc = { querySelector: () => null };
assert.equal(findSaveToPlaylistTrigger(doc), null);
});

it('prefers the watch-page selector over the Shorts fallback when both match', () => {
const watchBtn = {};
const shortsBtn = {};
const doc = {
querySelector: (sel) => {
if (sel === SAVE_SELECTOR) return watchBtn;
if (sel === SHORTS_SELECTOR) return shortsBtn;
return null;
},
};
assert.equal(findSaveToPlaylistTrigger(doc), watchBtn);
});

it('falls back to the Shorts header menu button when the watch-page selector finds nothing', () => {
const shortsBtn = {};
const doc = {
querySelector: (sel) => (sel === SHORTS_SELECTOR ? shortsBtn : null),
};
assert.equal(findSaveToPlaylistTrigger(doc), shortsBtn);
});
});

function makeWindow(href) {
Expand Down