feat: add familiar single-key playback controls

This commit is contained in:
2026-10-10 17:30:48 +02:00
parent bdae024c7f
commit 310be5aa61
2 changed files with 50 additions and 18 deletions
+14 -4
View File
@@ -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.
+36 -14
View File
@@ -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)