feat: Hardware Keyboard Shortcuts for Player

This commit is contained in:
Corey Garst 2026-05-08 16:22:26 -04:00
parent 11a1cf7ba9
commit 1e64574b61
6 changed files with 144 additions and 2 deletions

View file

@ -415,6 +415,14 @@ actual fun PlatformPlayerSurface(
currentSubtitleStyle = style currentSubtitleStyle = style
playerViewRef?.applySubtitleStyle(style) playerViewRef?.applySubtitleStyle(style)
} }
override fun setKeyboardModalState(isAnyModalVisible: Boolean) {
// No-op on Android; keyboard handling is done at Compose layer
}
override fun setExitCallback(callback: (() -> Unit)?) {
// No-op on Android; keyboard handling is done at Compose layer
}
} }
) )
} }

View file

@ -18,6 +18,8 @@ interface PlayerEngineController {
fun clearExternalSubtitle() fun clearExternalSubtitle()
fun clearExternalSubtitleAndSelect(trackIndex: Int) fun clearExternalSubtitleAndSelect(trackIndex: Int)
fun applySubtitleStyle(style: SubtitleStyleState) {} fun applySubtitleStyle(style: SubtitleStyleState) {}
fun setKeyboardModalState(isAnyModalVisible: Boolean) {}
fun setExitCallback(callback: (() -> Unit)?) {}
} }
internal fun sanitizePlaybackHeaders(headers: Map<String, String>?): Map<String, String> { internal fun sanitizePlaybackHeaders(headers: Map<String, String>?): Map<String, String> {

View file

@ -27,9 +27,18 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.foundation.focusable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
@ -158,6 +167,11 @@ fun PlayerScreen(
WatchProgressRepository.uiState WatchProgressRepository.uiState
}.collectAsStateWithLifecycle() }.collectAsStateWithLifecycle()
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
BoxWithConstraints( BoxWithConstraints(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
@ -437,6 +451,28 @@ fun PlayerScreen(
val addonSubtitles by SubtitleRepository.addonSubtitles.collectAsStateWithLifecycle() val addonSubtitles by SubtitleRepository.addonSubtitles.collectAsStateWithLifecycle()
val isLoadingAddonSubtitles by SubtitleRepository.isLoading.collectAsStateWithLifecycle() val isLoadingAddonSubtitles by SubtitleRepository.isLoading.collectAsStateWithLifecycle()
// Maintain focus when modals are dismissed or controls visibility changes
val isAnyModalVisible = showAudioModal || showSubtitleModal || showSourcesPanel || showEpisodesPanel || showSubmitIntroModal
LaunchedEffect(isAnyModalVisible, controlsVisible) {
if (!isAnyModalVisible) {
focusRequester.requestFocus()
}
// Notify iOS native code about modal state for keyboard handling
playerController?.setKeyboardModalState(isAnyModalVisible)
}
// Periodically re-request focus while playing to ensure hardware keyboard stays connected to player
LaunchedEffect(playbackSnapshot.isPlaying) {
if (playbackSnapshot.isPlaying) {
while (true) {
if (!isAnyModalVisible) {
focusRequester.requestFocus()
}
delay(5000)
}
}
}
fun refreshTracks() { fun refreshTracks() {
val ctrl = playerController ?: return val ctrl = playerController ?: return
audioTracks = ctrl.getAudioTracks() audioTracks = ctrl.getAudioTracks()
@ -1116,6 +1152,10 @@ fun PlayerScreen(
playerController?.applySubtitleStyle(subtitleStyle) playerController?.applySubtitleStyle(subtitleStyle)
} }
LaunchedEffect(playerController, onBackWithProgress) {
playerController?.setExitCallback(onBackWithProgress)
}
LaunchedEffect(showSubtitleModal, activeSubtitleTab, contentType, activeVideoId) { LaunchedEffect(showSubtitleModal, activeSubtitleTab, contentType, activeVideoId) {
if (!showSubtitleModal || activeSubtitleTab != SubtitleTab.Addons) return@LaunchedEffect if (!showSubtitleModal || activeSubtitleTab != SubtitleTab.Addons) return@LaunchedEffect
if (!isLoadingAddonSubtitles && addonSubtitles.isEmpty()) { if (!isLoadingAddonSubtitles && addonSubtitles.isEmpty()) {
@ -1384,6 +1424,33 @@ fun PlayerScreen(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.onSizeChanged { layoutSize = it } .onSizeChanged { layoutSize = it }
.focusRequester(focusRequester)
.focusable()
.onPreviewKeyEvent { event ->
val isAnyModalVisible = showAudioModal || showSubtitleModal || showSourcesPanel || showEpisodesPanel || showSubmitIntroModal
if (event.type == KeyEventType.KeyDown && !isAnyModalVisible) {
println("[NuvioPlayer] KeyDown: ${event.key}")
when (event.key) {
Key.Escape -> {
onBackWithProgress()
true
}
Key.Spacebar -> {
togglePlayback()
true
}
Key.DirectionLeft -> {
seekBy(-10_000L)
true
}
Key.DirectionRight -> {
seekBy(10_000L)
true
}
else -> false
}
} else false
}
.pointerInput(layoutSize) { .pointerInput(layoutSize) {
detectTapGestures( detectTapGestures(
onPress = { onPress = {

View file

@ -48,6 +48,8 @@ interface NuvioPlayerBridge {
fun getBufferedMs(): Long fun getBufferedMs(): Long
fun getPlaybackSpeed(): Float fun getPlaybackSpeed(): Float
fun getErrorMessage(): String fun getErrorMessage(): String
fun setKeyboardModalState(isAnyModalVisible: Boolean)
fun setExitCallback(callback: (() -> Unit)?)
fun destroy() fun destroy()
} }

View file

@ -205,6 +205,14 @@ actual fun PlatformPlayerSurface(
subPos = style.toMpvSubtitlePosition(), subPos = style.toMpvSubtitlePosition(),
) )
} }
override fun setKeyboardModalState(isAnyModalVisible: Boolean) {
bridge.setKeyboardModalState(isAnyModalVisible)
}
override fun setExitCallback(callback: (() -> Unit)?) {
bridge.setExitCallback(callback)
}
} }
} }

View file

@ -28,6 +28,8 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge {
func seekTo(positionMs: Int64) { playerVC?.seekToMs(positionMs) } func seekTo(positionMs: Int64) { playerVC?.seekToMs(positionMs) }
func seekBy(offsetMs: Int64) { playerVC?.seekByMs(offsetMs) } func seekBy(offsetMs: Int64) { playerVC?.seekByMs(offsetMs) }
func retry() { playerVC?.retryPlayback() } func retry() { playerVC?.retryPlayback() }
func setKeyboardModalState(isAnyModalVisible: Bool) { playerVC?.setKeyboardModalState(isAnyModalVisible) }
func setExitCallback(callback: (() -> Void)?) { playerVC?.setExitCallback(callback) }
func setPlaybackSpeed(speed: Float) { playerVC?.setSpeed(speed) } func setPlaybackSpeed(speed: Float) { playerVC?.setSpeed(speed) }
func setResizeMode(mode: Int32) { playerVC?.setResize(Int(mode)) } func setResizeMode(mode: Int32) { playerVC?.setResize(Int(mode)) }
@ -157,6 +159,8 @@ final class MPVPlayerViewController: UIViewController {
private lazy var eventQueue = DispatchQueue(label: "mpv-events", qos: .userInitiated) private lazy var eventQueue = DispatchQueue(label: "mpv-events", qos: .userInitiated)
private var recentPlaybackLogs: [String] = [] private var recentPlaybackLogs: [String] = []
private var activeRequestHeaders: [String: String] = [:] private var activeRequestHeaders: [String: String] = [:]
private var isAnyModalVisible: Bool = false
private var exitCallback: (() -> Void)? = nil
// Cached track lists // Cached track lists
var audioTracks: [TrackInfo] = [] var audioTracks: [TrackInfo] = []
@ -411,13 +415,13 @@ final class MPVPlayerViewController: UIViewController {
func seekToMs(_ ms: Int64) { func seekToMs(_ ms: Int64) {
guard mpv != nil else { return } guard mpv != nil else { return }
let seconds = Double(ms) / 1000.0 let seconds = Double(ms) / 1000.0
command("seek", args: [String(format: "%.3f", seconds), "absolute"]) command("seek", args: [String(format: "%.3f", seconds), "absolute+exact"])
} }
func seekByMs(_ ms: Int64) { func seekByMs(_ ms: Int64) {
guard mpv != nil else { return } guard mpv != nil else { return }
let seconds = Double(ms) / 1000.0 let seconds = Double(ms) / 1000.0
command("seek", args: [String(format: "%.3f", seconds), "relative"]) command("seek", args: [String(format: "%.3f", seconds), "relative+exact"])
} }
func retryPlayback() { func retryPlayback() {
@ -887,6 +891,57 @@ final class MPVPlayerViewController: UIViewController {
currentParent = controller.parent currentParent = controller.parent
} }
} }
// MARK: - Keyboard Input Handling
func setKeyboardModalState(_ isModalVisible: Bool) {
self.isAnyModalVisible = isModalVisible
}
func setExitCallback(_ callback: (() -> Void)?) {
self.exitCallback = callback
}
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
var didHandleEvent = false
// Don't handle keyboard input if any modal is visible
guard !isAnyModalVisible else {
super.pressesBegan(presses, with: event)
return
}
for press in presses {
guard let key = press.key else { continue }
if key.charactersIgnoringModifiers == UIKeyCommand.inputEscape {
print("[NuvioPlayer] Escape -> exit player")
exitCallback?()
didHandleEvent = true
} else if key.charactersIgnoringModifiers == UIKeyCommand.inputLeftArrow {
print("[NuvioPlayer] Left -> seekBy -10s")
seekByMs(-10_000)
didHandleEvent = true
} else if key.charactersIgnoringModifiers == UIKeyCommand.inputRightArrow {
print("[NuvioPlayer] Right -> seekBy +10s")
seekByMs(10_000)
didHandleEvent = true
} else if key.characters == " " {
print("[NuvioPlayer] Space -> togglePlayback")
if isPlayerPlaying {
pausePlayback()
} else {
playPlayback()
}
didHandleEvent = true
}
}
if !didHandleEvent {
// Didn't handle this key press, so pass the event to the next responder.
super.pressesBegan(presses, with: event)
}
}
} }
// MARK: - Bridge Creator (implements Kotlin protocol) // MARK: - Bridge Creator (implements Kotlin protocol)