diff --git a/README.md b/README.md index 0a7c8ec..46d4d91 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,20 @@ The layout follows [IINA's video-first window](https://iina.io/img/screenshot.pn Opening video fits the window to its display aspect ratio, including non-square pixels, and constrains subsequent window resizing. Fullscreen retains the normal letterboxed renderer; audio-only playback and close release the aspect constraint. Extreme ratios that cannot fit both the screen and usable controls keep a freely resizable letterboxed window. -- **Space:** play/pause. **←/→:** seek five seconds. The timeline slider seeks on release and holds the requested position until the core finishes queued seeks; old playback snapshots cannot pull the thumb back. -- **↑/↓:** adjust volume by five percent. **M:** mute/unmute, restoring the previous audible level. The volume thumb is opaque light gray, independent of the frosted panel behind it. -- Both slider tracks span the native thumb-center travel range; the orange volume fill ends at the current thumb center. -- **Double-click video**, **F**, or **⌃⌘F:** toggle native fullscreen. **Esc:** leave fullscreen. +| Key | Action | +|---|---| +| `K` / `Space` | Toggle playback | +| `M` | Toggle mute, restoring the previous audible level | +| `J` / `L` | Seek backward / forward ten seconds | +| `F` | Toggle fullscreen | +| `P` | Toggle keep-on-top (windowed mode) | +| `O` | Open the file picker to switch files | +| `Q` | Quit through normal application shutdown | + +Single-key shortcuts apply only to the playback window and do not intercept text editing, file-picker input, or Command/Control/Option combinations. Held toggle keys do not repeatedly toggle; held `J`/`L` can continue seeking. `,` / `.` frame stepping and `C` captions are unbound until supported. + +- Existing shortcuts remain: **⌘O** opens files, **←/→** seeks five seconds, **↑/↓** adjusts volume by five percent, **⌃⌘F** toggles fullscreen, and **Esc** leaves fullscreen. Double-clicking video also toggles fullscreen. +- The timeline slider seeks on release and holds the requested position until queued seeking finishes. Both slider tracks span the native thumb-center travel range; the orange fill ends at the current volume thumb center. The volume thumb is opaque light gray. - **Single-click video:** hide controls. Mouse movement reveals them; they fade after 2.5 seconds, including while paused. Hovering the titlebar/controller, dragging, seeking, an inactive window, errors, or VoiceOver keep them visible. - Click the right-hand time label to switch between total and remaining time. The titlebar pin keeps the window on top. diff --git a/Sources/App/PlayerApp.swift b/Sources/App/PlayerApp.swift index f3e3092..2979147 100644 --- a/Sources/App/PlayerApp.swift +++ b/Sources/App/PlayerApp.swift @@ -492,7 +492,7 @@ private struct PlayerContent: View { } .buttonStyle(.plain) .accessibilityLabel(model.state.status == PLAYER_PLAYING ? "Pause" : "Play") - .help(model.state.status == PLAYER_PLAYING ? "Pause (Space)" : "Play (Space)") + .help(model.state.status == PLAYER_PLAYING ? "Pause (K / Space)" : "Play (K / Space)") .disabled(!model.canTogglePlayback) iconButton("forward.fill", "Seek Forward 5 Seconds") { model.seekRelative(5) } .disabled(!model.canSeek) @@ -685,6 +685,35 @@ private struct WindowChrome: NSViewRepresentable { } required init?(coder: NSCoder) { nil } + func handleKeyEvent(_ event: NSEvent) -> Bool { + guard event.type == .keyDown, let window = configuredWindow, event.window === window, + !(window.firstResponder is NSTextView), + event.modifierFlags.intersection([.command, .control, .option]).isEmpty else { return false } + if event.keyCode == 53 && model.isFullScreen { + if !event.isARepeat { model.toggleFullScreen() } + return true + } + let key = event.charactersIgnoringModifiers?.lowercased() + switch key { + case "j": model.seekRelative(-10) + case "l": model.seekRelative(10) + case "k", " ": + if !event.isARepeat { model.togglePlayback() } + case "m": + if !event.isARepeat { model.toggleMute() } + case "f": + if !event.isARepeat { model.toggleFullScreen() } + case "p": + if !event.isARepeat { model.toggleOnTop() } + case "o": + if !event.isARepeat { model.openPanel() } + case "q": + if !event.isARepeat { NSApp.terminate(nil) } + default: return false + } + return true + } + override func viewDidMoveToWindow() { super.viewDidMoveToWindow() guard let window else { detach(); return } @@ -713,17 +742,8 @@ private struct WindowChrome: NSViewRepresentable { if event.type == .keyDown && event.keyCode == 48 { self.model.revealControls() } if event.type == .mouseMoved || event.type == .leftMouseDown { self.model.revealControls() - } else if event.type == .keyDown && !(event.window?.firstResponder is NSSlider) && - !(event.window?.firstResponder is NSTextView) { - if event.keyCode == 53 && self.model.isFullScreen { - self.model.toggleFullScreen() - return true - } - if event.modifierFlags.intersection([.command, .control, .option]).isEmpty && - event.charactersIgnoringModifiers?.lowercased() == "f" { - self.model.toggleFullScreen() - return true - } + } else if event.type == .keyDown { + return self.handleKeyEvent(event) } return false } @@ -797,18 +817,20 @@ struct PlayerApp: App { } CommandMenu("Playback") { Button(model.state.status == PLAYER_PLAYING ? "Pause" : "Play", action: model.togglePlayback) - .keyboardShortcut(.space, modifiers: []).disabled(!model.canTogglePlayback) + .disabled(!model.canTogglePlayback) Button("Seek Back 5 Seconds") { model.seekRelative(-5) } .keyboardShortcut(.leftArrow, modifiers: []).disabled(!model.canSeek) Button("Seek Forward 5 Seconds") { model.seekRelative(5) } .keyboardShortcut(.rightArrow, modifiers: []).disabled(!model.canSeek) + Button("Seek Back 10 Seconds") { model.seekRelative(-10) }.disabled(!model.canSeek) + Button("Seek Forward 10 Seconds") { model.seekRelative(10) }.disabled(!model.canSeek) Divider() Button("Volume Up") { model.setVolume(min(1, model.volume + 0.05)) } .keyboardShortcut(.upArrow, modifiers: []).disabled(!model.canSetVolume) Button("Volume Down") { model.setVolume(max(0, model.volume - 0.05)) } .keyboardShortcut(.downArrow, modifiers: []).disabled(!model.canSetVolume) Button(model.volume == 0 ? "Unmute" : "Mute", action: model.toggleMute) - .keyboardShortcut("m", modifiers: []).disabled(!model.canSetVolume) + .disabled(!model.canSetVolume) } CommandGroup(after: .windowArrangement) { Button(model.isFullScreen ? "Exit Full Screen" : "Enter Full Screen", action: model.toggleFullScreen)