mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 15:32:01 +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
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue