Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 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
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.
2 changes: 2 additions & 0 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 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`) |
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.0",
"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.0",
"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.0",
"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
53 changes: 53 additions & 0 deletions docs/ai/questions-for-K.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
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) — 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 |
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.
Loading