From 826db09136d1935b48262e75cea65b8869220c4f Mon Sep 17 00:00:00 2001 From: Arvin Date: Sun, 5 Apr 2026 15:18:37 +0200 Subject: [PATCH] feat: Fire TV and Bluetooth media remote keys in the player (#68 part) Reported: "On Fire Stick the remote button rewind/ffwd/play/pause buttons don't work with the player." Root cause: Only `Key.MediaPlayPause` / `MediaPlay` / `MediaPause` was handled in PlayerScreen.kt, and only inside the `if (showSubtitleMenu)` branch \u2014 so media keys did nothing unless the user had already opened the audio/subtitle menu. `Key.MediaRewind`, `MediaFastForward`, `MediaNext`, `MediaPrevious`, and `MediaStop` were not handled at all, anywhere. Fire TV Stick remotes ship with dedicated FF/RW/Play buttons and Amazon explicitly recommends apps handle them via the standard KeyEvent constants. Bluetooth A2DP headsets and keyboards also produce these keys. ARVIO was dropping all of them on the floor. Fix: add a top-level media-key dispatch at the very start of the player `.onKeyEvent` handler, BEFORE the error / menu / overlay branches. Each key returns `true` immediately so it always wins regardless of whether a menu is open. Handles: - `Key.MediaPlayPause` \u2192 toggle play/pause, show controls. - `Key.MediaPlay` \u2192 play, show controls. - `Key.MediaPause` \u2192 pause, show controls. - `Key.MediaStop` \u2192 pause and exit the player (equivalent to Back). - `Key.MediaRewind` \u2192 seek back 10 seconds via existing queueControlsSeek. - `Key.MediaFastForward` \u2192 seek forward 10 seconds. - `Key.MediaNext` \u2192 jump to the next episode (TV only). Uses the same onPlayNext lambda the existing Next Episode button uses, so the binge-group / source-preservation logic is identical. - `Key.MediaPrevious` \u2192 jump to the previous episode (TV only, guarded by `episodeNumber > 1`). The existing `Key.MediaPlayPause` branch inside the subtitle menu remains in place as a harmless safety net \u2014 it becomes unreachable in practice because the top-level dispatch already returns true, but leaving it means a future change to the top handler can't accidentally break media keys inside the subtitle menu. Closes the Fire TV remote part of #68. The other parts of #68 (subtitle timing sync, in-player episode picker) are scoped separately. Subtitle offset is already being shipped in PR #108. --- .../tv/ui/screens/player/PlayerScreen.kt | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt index fdfe35b07..5011b68f1 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt @@ -1390,6 +1390,75 @@ fun PlayerScreen( ) .onKeyEvent { event -> if (event.type == KeyEventType.KeyDown) { + // Fire TV / Bluetooth media remote keys. These must be handled at the + // top of the key handler so they work regardless of which overlay + // (error, menus, post-episode prompt) is currently visible. Previously + // only Key.MediaPlayPause was handled, and only when the subtitle menu + // was open \u2014 useless for the common case of watching with a Fire TV + // stick remote that has dedicated FF/RW/Play buttons. Issue #68 (part). + when (event.key) { + Key.MediaPlayPause -> { + if (exoPlayer.isPlaying) exoPlayer.pause() else exoPlayer.play() + showControls = true + return@onKeyEvent true + } + Key.MediaPlay -> { + exoPlayer.play() + showControls = true + return@onKeyEvent true + } + Key.MediaPause -> { + exoPlayer.pause() + showControls = true + return@onKeyEvent true + } + Key.MediaStop -> { + exoPlayer.pause() + onBack() + return@onKeyEvent true + } + Key.MediaRewind -> { + queueControlsSeek(-10_000L) + showControls = true + return@onKeyEvent true + } + Key.MediaFastForward -> { + queueControlsSeek(10_000L) + showControls = true + return@onKeyEvent true + } + Key.MediaNext -> { + // Jump to next episode if this is a TV series and we have a + // current episode. No-op for movies (there is no next). + if (mediaType == MediaType.TV && seasonNumber != null && episodeNumber != null) { + val selected = uiState.selectedStream + onPlayNext( + seasonNumber, + episodeNumber + 1, + selected?.addonId?.takeIf { it.isNotBlank() }, + selected?.source?.takeIf { it.isNotBlank() }, + selected?.behaviorHints?.bingeGroup?.takeIf { it.isNotBlank() } + ) + return@onKeyEvent true + } + } + Key.MediaPrevious -> { + // Jump to previous episode for TV series. Movies: no-op. + if (mediaType == MediaType.TV && seasonNumber != null && episodeNumber != null && episodeNumber > 1) { + val selected = uiState.selectedStream + onPlayNext( + seasonNumber, + episodeNumber - 1, + selected?.addonId?.takeIf { it.isNotBlank() }, + selected?.source?.takeIf { it.isNotBlank() }, + selected?.behaviorHints?.bingeGroup?.takeIf { it.isNotBlank() } + ) + return@onKeyEvent true + } + } + else -> Unit // fall through to normal handling + } + // Handle error modal if (uiState.error != null) { val maxButtons = if (uiState.isSetupError) 0 else 1 // setup=1 button, error=2 buttons