mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-16 23:12:12 +00:00
feat: Hardware Keyboard Shortcuts for Player
This commit is contained in:
parent
11a1cf7ba9
commit
1e64574b61
6 changed files with 144 additions and 2 deletions
|
|
@ -415,6 +415,14 @@ actual fun PlatformPlayerSurface(
|
|||
currentSubtitleStyle = 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
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ interface PlayerEngineController {
|
|||
fun clearExternalSubtitle()
|
||||
fun clearExternalSubtitleAndSelect(trackIndex: Int)
|
||||
fun applySubtitleStyle(style: SubtitleStyleState) {}
|
||||
fun setKeyboardModalState(isAnyModalVisible: Boolean) {}
|
||||
fun setExitCallback(callback: (() -> Unit)?) {}
|
||||
}
|
||||
|
||||
internal fun sanitizePlaybackHeaders(headers: Map<String, String>?): Map<String, String> {
|
||||
|
|
|
|||
|
|
@ -27,9 +27,18 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
|
|
@ -158,6 +167,11 @@ fun PlayerScreen(
|
|||
WatchProgressRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
|
|
@ -437,6 +451,28 @@ fun PlayerScreen(
|
|||
val addonSubtitles by SubtitleRepository.addonSubtitles.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() {
|
||||
val ctrl = playerController ?: return
|
||||
audioTracks = ctrl.getAudioTracks()
|
||||
|
|
@ -1116,6 +1152,10 @@ fun PlayerScreen(
|
|||
playerController?.applySubtitleStyle(subtitleStyle)
|
||||
}
|
||||
|
||||
LaunchedEffect(playerController, onBackWithProgress) {
|
||||
playerController?.setExitCallback(onBackWithProgress)
|
||||
}
|
||||
|
||||
LaunchedEffect(showSubtitleModal, activeSubtitleTab, contentType, activeVideoId) {
|
||||
if (!showSubtitleModal || activeSubtitleTab != SubtitleTab.Addons) return@LaunchedEffect
|
||||
if (!isLoadingAddonSubtitles && addonSubtitles.isEmpty()) {
|
||||
|
|
@ -1384,6 +1424,33 @@ fun PlayerScreen(
|
|||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.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) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ interface NuvioPlayerBridge {
|
|||
fun getBufferedMs(): Long
|
||||
fun getPlaybackSpeed(): Float
|
||||
fun getErrorMessage(): String
|
||||
fun setKeyboardModalState(isAnyModalVisible: Boolean)
|
||||
fun setExitCallback(callback: (() -> Unit)?)
|
||||
fun destroy()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -205,6 +205,14 @@ actual fun PlatformPlayerSurface(
|
|||
subPos = style.toMpvSubtitlePosition(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun setKeyboardModalState(isAnyModalVisible: Boolean) {
|
||||
bridge.setKeyboardModalState(isAnyModalVisible)
|
||||
}
|
||||
|
||||
override fun setExitCallback(callback: (() -> Unit)?) {
|
||||
bridge.setExitCallback(callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge {
|
|||
func seekTo(positionMs: Int64) { playerVC?.seekToMs(positionMs) }
|
||||
func seekBy(offsetMs: Int64) { playerVC?.seekByMs(offsetMs) }
|
||||
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 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 var recentPlaybackLogs: [String] = []
|
||||
private var activeRequestHeaders: [String: String] = [:]
|
||||
private var isAnyModalVisible: Bool = false
|
||||
private var exitCallback: (() -> Void)? = nil
|
||||
|
||||
// Cached track lists
|
||||
var audioTracks: [TrackInfo] = []
|
||||
|
|
@ -411,13 +415,13 @@ final class MPVPlayerViewController: UIViewController {
|
|||
func seekToMs(_ ms: Int64) {
|
||||
guard mpv != nil else { return }
|
||||
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) {
|
||||
guard mpv != nil else { return }
|
||||
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() {
|
||||
|
|
@ -887,6 +891,57 @@ final class MPVPlayerViewController: UIViewController {
|
|||
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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue