feat: add familiar single-key playback controls
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user