Skip to content

fix(player): stop hidden WebView intercepting Space and arrow key equivalents - #444

Open
tsibog wants to merge 4 commits into
sozercan:mainfrom
tsibog:fix/spacebar-playback-focus-405
Open

fix(player): stop hidden WebView intercepting Space and arrow key equivalents#444
tsibog wants to merge 4 commits into
sozercan:mainfrom
tsibog:fix/spacebar-playback-focus-405

Conversation

@tsibog

@tsibog tsibog commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Description

The singleton WKWebView that plays audio lives in the main window's view hierarchy even during audio-only playback (1×1, opacity 0, allowsHitTesting(false)). NSWindow.performKeyEquivalent recursively searches the contentView's subviews, and a stock WKWebView returns true for Space and ⌘←/⌘→ because the YouTube Music page has its own handlers for those keys. The event is consumed before the SwiftUI command-menu shortcut fires, so Space never toggles play/pause.

Tab does not reliably fix it because the WebView remains in the view hierarchy regardless of who the first responder is — performKeyEquivalent can still reach it.

AI Prompt (Optional)

🤖 AI Prompt Used
Investigate GitHub issue #405: keyboard shortcuts (Space to pause, arrow keys) not being recognized in Kaset. The reporter says Tab sometimes fixes it. Find the root cause in the codebase and fix it, matching repo conventions.

AI Tool: Hermes Agent (GLM-5.2), reviewed by Claude Code (Sonnet 4)

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Related Issues

Fixes #405

Changes Made

  • Added KasetWebView, a WKWebView subclass that returns false from performKeyEquivalent when its bounds are 1×1 (hidden/audio-only), letting key equivalents fall through to the menu system. When the WebView has non-trivial bounds (video mode), it forwards keys normally.
  • The singleton player WebView (SingletonPlayerWebView.getWebView) now creates a KasetWebView instead of a plain WKWebView.
  • The behavior is derived from live self.bounds, not a manually-synced flag, matching the pattern in ScrollForwardingWebView (which derives from self.url). This avoids desync after WebView recreation or reparenting.

Testing

  • Unit tests pass (swift test --skip KasetUITests) — verified by CI: macOS Unit Tests (macos-15) ✓, macOS Unit Tests (macos-26) ✓
  • Manual testing performed
  • UI tested on macOS 26+ — verified by CI: macOS UI Tests (macos-15) ✓, macOS UI Tests (macos-26) ✓

Note: No Swift toolchain was available on the machine that produced this PR. Build, lint, and tests were verified by CI.

Manual checklist:

  • Play a track, press Space → playback pauses; press Space again → playback resumes
  • Play a track, press ⌘← / ⌘→ → previous/next track
  • Type in the search bar → Space inserts a space (existing TextInputFocusState guard still applies)
  • Open a YouTube video in the video window → Space and arrow keys control the video player directly

Checklist

  • My code follows the project's style guidelines
  • I have run swiftlint --strict && swiftformat .verified by CI
  • I have added tests that prove my fix/feature works
  • New and existing unit tests pass locally — verified by CI (macOS 15 ✓, macOS 26 ✓)
  • I have updated documentation if needed
  • I have checked for any performance implications
  • My changes generate no new warnings

Additional Notes

The fix is minimal: one new file (KasetWebView.swift, 24 lines) and one line changed in MiniPlayerWebView.swift (the WKWebViewKasetWebView type). No changes to SingletonPlayerWebView+VideoMode.swift are needed since the behavior is stateless.

The existing TextInputFocusState mechanism (PR #379) is orthogonal and still works: it disables the menu shortcut itself when a text field has focus. This PR fixes the other half — the WebView swallowing the event before the menu even sees it.

A Claude Code self-review identified and fixed three issues with the initial flag-based approach: flag desync after WebView recreation, a reparenting race between PersistentPlayerView and VideoWebViewContainer, and triplicated cast blocks. The stateless bounds-check eliminates all three.

tsibog added 3 commits August 13, 2026 16:05
…ivalents

The singleton WKWebView that plays audio lives in the main window's view
hierarchy even during audio-only playback (1x1, opacity 0, allowsHitTesting
false). NSWindow.performKeyEquivalent recursively searches the contentView's
subviews, and a stock WKWebView returns true for Space and Cmd-Left/Right
because the YouTube Music page has its own handlers for those keys. The event
is consumed before the SwiftUI command-menu shortcut fires, so Space never
toggles play/pause (issue sozercan#405). Tab does not reliably fix it because the
WebView remains in the view hierarchy regardless of who the first responder is.

Subclass WKWebView as KasetWebView with a shouldInterceptKeyEquivalents flag.
When false (audio-only/hidden/miniPlayer), performKeyEquivalent returns false
so key equivalents fall through to the menu system. In video mode the flag is
true and the WebView handles keys normally.

Fixes sozercan#405
Claude Code review found three issues with the manual flag approach:
1. Flag desyncs after WebView recreation (tearDown never resets displayMode)
2. Reparenting race between PersistentPlayerView and VideoWebViewContainer
3. Triplicated cast blocks with a dead .miniPlayer branch

Deriving from self.bounds (like ScrollForwardingWebView derives from
self.url) eliminates all three issues. No call-site changes needed.
@tsibog

tsibog commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

The Space shortcut is registered on the Playback menu item (keyEquiv=' ',
enabled, with a target) but AppKit never offers a modifier-less key
equivalent to the main menu, so it was never dispatched. Runtime tracing
showed the key window's whole view hierarchy declining Space
(performKeyEquivalent -> false, 18/18) while the main menu handled it every
time it was asked directly (25/25).

Add PlaybackSpaceKeyMonitor, a local key-down monitor that claims bare Space
only when the native UI holds focus. Text fields still type a space, and the
player WebView still gets Space when it holds focus, where the page's own
handler already toggles playback (claiming it there would toggle twice).

Extract performPlayPause() so the menu item and the monitor share one
implementation and cannot diverge.
@tsibog

tsibog commented Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

Runtime findings: the original fix did not resolve #405

Verified against a packaged build with file-based tracing (sandbox NSTemporaryDirectory()), audio-only playback, focus moved off the WebView.

The KasetWebView change works, but was not what was broken

KasetWebView.performKeyEquivalent behaves exactly as written — it is reached and correctly declines:

keyDown key=49  firstResponder=SwiftUIOutlineListView  menu='Playback>Pause' enabled=true target=true
KasetWebView.pKE SUPPRESSED bounds=(1.0, 1.0) key=49

But Space still did nothing, and the play/pause command never fired.

Reverting SingletonPlayerWebView.getWebView to a stock WKWebView (probes intact) gave an identical result — the window declines Space either way:

Build window.performKeyEquivalent
With KasetWebView false (18/18)
Stock WKWebView false (9/9)

So the hidden 1×1 WebView was not consuming the key equivalent, and the PR's stated mechanism did not reproduce.

Actual root cause

The menu item is fully wired — keyEquiv=' ', enabled=true, non-nil target:

MENUITEM 'Playback' > 'Play' keyEquiv=' ' mods=0 enabled=true target=<MenuItemCallback>

And the main menu handles Space every time it is asked directly (25/25 across runs):

PROBE mainMenu.performKeyEquivalent -> true   → PLAY/PAUSE COMMAND FIRED

AppKit simply never offers a modifier-less key equivalent to the main menu. The window's view hierarchy declines Space, nothing else claims it, and it dies before the menu is consulted. ⌘←/⌘→ are unaffected because they carry a modifier.

This also explains the "Tab sometimes fixes it" report in #405: Tab moves focus into the WebView, after which the YouTube Music page's own Space handler toggles playback. That is the page working, not the shortcut.

Fix in 6dec521

PlaybackSpaceKeyMonitor — a local key-down monitor that claims bare Space only when the native UI holds focus:

  • Text fields still type a space (isTextInputFocused)
  • Player WebView focused → left to the page, which already toggles playback; claiming it would toggle twice
  • Playback command disabled (no track) → not claimed
  • Space with any modifier → not claimed, so it stays available as a shortcut

performPlayPause() is extracted so the menu item and the monitor share one implementation and cannot diverge (arbiter routing to video vs music is preserved).

8 unit tests cover the decision boundaries.

What is still unverified

  • The new fix has not been confirmed at runtime. Automated key injection needs Accessibility permission, which isn't granted in this environment, and macOS will not give a background app a key window, so a synthetic NSEvent self-test could not reach the monitor. The root cause above is runtime-verified; the fix is verified only by unit tests and reasoning. Please confirm manually: play a track, click the sidebar, press Space.
  • KasetWebView is retained. It is provably not needed for Space, but ⌘←/⌘→ interception was never exercised, so its value there is untested. It may be worth dropping if it can't be shown to do anything.
  • The description and title still describe the original WebView theory and should be updated to match the actual cause.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: keyboard shortcuts (eg. space to pause) not being recognized

1 participant