diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index ebdcfd92..6513a58c 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -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 + } } ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt index 8a5b6730..94be9c47 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt @@ -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?): Map { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index fc24fba4..8ca16bc7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -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 = { diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt index 4c627dc2..5da86210 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt @@ -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() } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt index 2877b04c..ee3d7e47 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt @@ -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) + } } } diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index 06779ac2..79a52f9b 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -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, 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)