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.
|
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.
|
| Key | Action |
|
||||||
- **↑/↓:** 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.
|
| `K` / `Space` | Toggle playback |
|
||||||
- **Double-click video**, **F**, or **⌃⌘F:** toggle native fullscreen. **Esc:** leave fullscreen.
|
| `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.
|
- **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.
|
- 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)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(model.state.status == PLAYER_PLAYING ? "Pause" : "Play")
|
.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)
|
.disabled(!model.canTogglePlayback)
|
||||||
iconButton("forward.fill", "Seek Forward 5 Seconds") { model.seekRelative(5) }
|
iconButton("forward.fill", "Seek Forward 5 Seconds") { model.seekRelative(5) }
|
||||||
.disabled(!model.canSeek)
|
.disabled(!model.canSeek)
|
||||||
@@ -685,6 +685,35 @@ private struct WindowChrome: NSViewRepresentable {
|
|||||||
}
|
}
|
||||||
required init?(coder: NSCoder) { nil }
|
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() {
|
override func viewDidMoveToWindow() {
|
||||||
super.viewDidMoveToWindow()
|
super.viewDidMoveToWindow()
|
||||||
guard let window else { detach(); return }
|
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 == .keyDown && event.keyCode == 48 { self.model.revealControls() }
|
||||||
if event.type == .mouseMoved || event.type == .leftMouseDown {
|
if event.type == .mouseMoved || event.type == .leftMouseDown {
|
||||||
self.model.revealControls()
|
self.model.revealControls()
|
||||||
} else if event.type == .keyDown && !(event.window?.firstResponder is NSSlider) &&
|
} else if event.type == .keyDown {
|
||||||
!(event.window?.firstResponder is NSTextView) {
|
return self.handleKeyEvent(event)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -797,18 +817,20 @@ struct PlayerApp: App {
|
|||||||
}
|
}
|
||||||
CommandMenu("Playback") {
|
CommandMenu("Playback") {
|
||||||
Button(model.state.status == PLAYER_PLAYING ? "Pause" : "Play", action: model.togglePlayback)
|
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) }
|
Button("Seek Back 5 Seconds") { model.seekRelative(-5) }
|
||||||
.keyboardShortcut(.leftArrow, modifiers: []).disabled(!model.canSeek)
|
.keyboardShortcut(.leftArrow, modifiers: []).disabled(!model.canSeek)
|
||||||
Button("Seek Forward 5 Seconds") { model.seekRelative(5) }
|
Button("Seek Forward 5 Seconds") { model.seekRelative(5) }
|
||||||
.keyboardShortcut(.rightArrow, modifiers: []).disabled(!model.canSeek)
|
.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()
|
Divider()
|
||||||
Button("Volume Up") { model.setVolume(min(1, model.volume + 0.05)) }
|
Button("Volume Up") { model.setVolume(min(1, model.volume + 0.05)) }
|
||||||
.keyboardShortcut(.upArrow, modifiers: []).disabled(!model.canSetVolume)
|
.keyboardShortcut(.upArrow, modifiers: []).disabled(!model.canSetVolume)
|
||||||
Button("Volume Down") { model.setVolume(max(0, model.volume - 0.05)) }
|
Button("Volume Down") { model.setVolume(max(0, model.volume - 0.05)) }
|
||||||
.keyboardShortcut(.downArrow, modifiers: []).disabled(!model.canSetVolume)
|
.keyboardShortcut(.downArrow, modifiers: []).disabled(!model.canSetVolume)
|
||||||
Button(model.volume == 0 ? "Unmute" : "Mute", action: model.toggleMute)
|
Button(model.volume == 0 ? "Unmute" : "Mute", action: model.toggleMute)
|
||||||
.keyboardShortcut("m", modifiers: []).disabled(!model.canSetVolume)
|
.disabled(!model.canSetVolume)
|
||||||
}
|
}
|
||||||
CommandGroup(after: .windowArrangement) {
|
CommandGroup(after: .windowArrangement) {
|
||||||
Button(model.isFullScreen ? "Exit Full Screen" : "Enter Full Screen", action: model.toggleFullScreen)
|
Button(model.isFullScreen ? "Exit Full Screen" : "Enter Full Screen", action: model.toggleFullScreen)
|
||||||
|
|||||||
Reference in New Issue
Block a user