diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cbeecb..1e5e214 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 47c8a94..0974b23 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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: @@ -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. diff --git a/README.md b/README.md index 80e745e..5cea55e 100644 --- a/README.md +++ b/README.md @@ -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`) | @@ -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 diff --git a/apps/chrome-extension/manifest.json b/apps/chrome-extension/manifest.json index a93b3f1..936f9b1 100644 --- a/apps/chrome-extension/manifest.json +++ b/apps/chrome-extension/manifest.json @@ -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" diff --git a/apps/edge-extension/manifest.json b/apps/edge-extension/manifest.json index a93b3f1..936f9b1 100644 --- a/apps/edge-extension/manifest.json +++ b/apps/edge-extension/manifest.json @@ -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" diff --git a/apps/firefox-extension/manifest.json b/apps/firefox-extension/manifest.json index cb2294b..8ed9195 100644 --- a/apps/firefox-extension/manifest.json +++ b/apps/firefox-extension/manifest.json @@ -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" diff --git a/apps/shared/src/content/content.js b/apps/shared/src/content/content.js index e16a132..8feeb97 100644 --- a/apps/shared/src/content/content.js +++ b/apps/shared/src/content/content.js @@ -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; @@ -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(); @@ -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 @@ -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); diff --git a/docs/ai/questions-for-K.md b/docs/ai/questions-for-K.md index 1c9e55b..4649fbe 100644 --- a/docs/ai/questions-for-K.md +++ b/docs/ai/questions-for-K.md @@ -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/`). 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 `` -> `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. diff --git a/docs/keyboard-quickstart.md b/docs/keyboard-quickstart.md index fa115e4..ee38a33 100644 --- a/docs/keyboard-quickstart.md +++ b/docs/keyboard-quickstart.md @@ -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 | @@ -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. diff --git a/docs/probes/add-to-queue-dom-findings.md b/docs/probes/add-to-queue-dom-findings.md index 9ae9ab6..53dd2e8 100644 --- a/docs/probes/add-to-queue-dom-findings.md +++ b/docs/probes/add-to-queue-dom-findings.md @@ -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 +(`