diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cbeecb..9f4bca4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [Unreleased] +## [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..0c498e9 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 only, 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`) | diff --git a/apps/chrome-extension/manifest.json b/apps/chrome-extension/manifest.json index a93b3f1..587633b 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.0", "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..587633b 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.0", "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..7ebd826 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.0", "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..1bcce8e 100644 --- a/docs/ai/questions-for-K.md +++ b/docs/ai/questions-for-K.md @@ -109,3 +109,56 @@ 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). diff --git a/docs/keyboard-quickstart.md b/docs/keyboard-quickstart.md index fa115e4..5e68569 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) — watch page only, requires being logged in | Watch page | | `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/save-to-playlist-dom-findings.md b/docs/probes/save-to-playlist-dom-findings.md new file mode 100644 index 0000000..a0f3838 --- /dev/null +++ b/docs/probes/save-to-playlist-dom-findings.md @@ -0,0 +1,174 @@ +# "Save to playlist" popup — DOM findings + +Captured 2026-08-07 by K from a real logged-in YouTube account (watch page, +video's ⋮ menu → "Save to..."), across two sessions: one with nothing checked +(`save-to-playlist-popup.html`), one with the video already saved to "Watch +later" (`save-to-playlist-popup-selected.html`), diffed to find the selected- +state signal below. Source for both: a full "Save as complete" page save; +only the relevant fragment was kept (the `` subtree) — +the rest of each saved page and its ~19MB asset folder were discarded as +unnecessary bulk, and every real playlist name in the kept fragments was +replaced with a generic `Playlist A`..`Playlist P` placeholder (`Watch later` +and `Shorts`, YouTube's own system playlists, were left as-is). Locale of the +source account is Polish; only the alphabet/text differs from other locales, +not the structure. + +## Component + +Newer view-model "sheet" system, not the legacy `ytd-add-to-playlist-renderer` +Polymer popup: + +``` +yt-sheet-view-model[slot="dropdown-content"] + yt-contextual-sheet-layout + div.ytContextualSheetLayoutHeaderContainer + yt-panel-header-view-model[aria-label="Zapisz film na playliście..."] (~"Save video to playlist...") + h2 > span.ytAttributedStringHost → "Zapisz na…" (~"Save to…") + div.ytContextualSheetLayoutContentContainer + yt-list-view-model.ytListViewModelHost[role="menu"][style*="max-height: 220px"] + toggleable-list-item-view-model.toggleableListItemViewModelHost (repeated, one per playlist) + yt-list-item-view-model.ytListItemViewModelHost[role="menuitem"][aria-label="{name}, {visibility}, {selected-state}"] + div.ytContextualSheetLayoutFooterContainer + yt-panel-footer-view-model + ...button[aria-label="Utwórz nową playlistę"] (~"Create new playlist") +``` + +## Playlist rows + +- One `toggleable-list-item-view-model` > `yt-list-item-view-model` per + playlist, `role="menuitem"`. +- **Selected/unselected state resolved** — K captured a second probe + (`save-to-playlist-popup-selected.html`, watch page for a video already + saved to "Watch later") and diffing the two captures' identical row found + the real signal: the row's inner + `