This commit is contained in:
Corey Garst 2026-05-16 05:27:12 +00:00 committed by GitHub
commit cd9d06dbab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 151 additions and 2 deletions

View file

@ -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
}
}
)
}

View file

@ -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> {

View file

@ -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
@ -164,6 +173,11 @@ fun PlayerScreen(
WatchProgressRepository.uiState
}.collectAsStateWithLifecycle()
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
BoxWithConstraints(
modifier = modifier
.fillMaxSize()
@ -464,6 +478,28 @@ fun PlayerScreen(
mutableStateOf<String?>(null)
}
// 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()
@ -1217,6 +1253,10 @@ fun PlayerScreen(
playerController?.applySubtitleStyle(subtitleStyle)
}
LaunchedEffect(playerController, onBackWithProgress) {
playerController?.setExitCallback(onBackWithProgress)
}
LaunchedEffect(activeSourceUrl, addonSubtitleFetchKey) {
val fetchKey = addonSubtitleFetchKey ?: return@LaunchedEffect
if (autoFetchedAddonSubtitlesForKey == fetchKey) return@LaunchedEffect
@ -1224,6 +1264,13 @@ fun PlayerScreen(
fetchAddonSubtitlesForActiveItem()
}
LaunchedEffect(showSubtitleModal, activeSubtitleTab, contentType, activeVideoId) {
if (!showSubtitleModal || activeSubtitleTab != SubtitleTab.Addons) return@LaunchedEffect
if (!isLoadingAddonSubtitles && addonSubtitles.isEmpty()) {
fetchAddonSubtitlesForActiveItem()
}
}
LaunchedEffect(playbackSnapshot.isLoading, playerController) {
if (!playbackSnapshot.isLoading && playerController != null) {
refreshTracks()
@ -1485,6 +1532,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 = {

View file

@ -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()
}

View file

@ -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)
}
}
}

View file

@ -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)