From 138f43b6c9b44b90d38ec4c12969635e613ff754 Mon Sep 17 00:00:00 2001 From: kirill Date: Mon, 5 Oct 2026 18:29:48 +0200 Subject: [PATCH 01/13] feat: add YouTube-style keyboard shortcuts to video player Space/K play-pause, J/L and arrows seek, up/down volume, M mute, 0-9 percent seek, Home/End, Shift+,/. speed, Shift+N/P episode, I picture-in-picture. --- .../makd/afinity/ui/player/PlayerActivity.kt | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt b/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt index f064c201..e202e06b 100644 --- a/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt @@ -12,9 +12,11 @@ import android.content.pm.ActivityInfo import android.content.pm.PackageManager import android.content.res.Configuration import android.graphics.drawable.Icon +import android.media.AudioManager import android.os.Bundle import android.os.Process import android.util.Rational +import android.view.KeyEvent import android.view.WindowManager import androidx.activity.compose.setContent import androidx.activity.viewModels @@ -40,6 +42,7 @@ import com.makd.afinity.ui.theme.AFinityTheme import dagger.hilt.android.AndroidEntryPoint import java.util.UUID import javax.inject.Inject +import kotlin.math.roundToInt import timber.log.Timber @UnstableApi @@ -52,6 +55,20 @@ class PlayerActivity : AppCompatActivity() { private var wasPip: Boolean = false + private val audioManager by lazy { getSystemService(AudioManager::class.java) } + + private var volumeStepBeforeMute = 1 + + private val repeatableShortcutKeys = + setOf( + KeyEvent.KEYCODE_J, + KeyEvent.KEYCODE_L, + KeyEvent.KEYCODE_DPAD_LEFT, + KeyEvent.KEYCODE_DPAD_RIGHT, + KeyEvent.KEYCODE_DPAD_UP, + KeyEvent.KEYCODE_DPAD_DOWN, + ) + companion object { private const val ACTION_PLAY_PAUSE = "com.makd.afinity.action.PLAY_PAUSE" private const val REQUEST_PLAY_PAUSE = 1001 @@ -190,6 +207,130 @@ class PlayerActivity : AppCompatActivity() { } } + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (event.isCtrlPressed || event.isAltPressed || event.isMetaPressed) { + return super.dispatchKeyEvent(event) + } + val shortcut = keyboardShortcut(event) ?: return super.dispatchKeyEvent(event) + if ( + event.action == KeyEvent.ACTION_DOWN && + (event.repeatCount == 0 || event.keyCode in repeatableShortcutKeys) + ) { + shortcut() + } + return true + } + + // Mirrors the YouTube web player's keyboard shortcuts. + private fun keyboardShortcut(event: KeyEvent): (() -> Unit)? { + val player = viewModel.player + val shift = event.isShiftPressed + return when (event.keyCode) { + KeyEvent.KEYCODE_SPACE, + KeyEvent.KEYCODE_K -> { + { + viewModel.handlePlayerEvent( + if (player.isPlaying) PlayerEvent.Pause else PlayerEvent.Play + ) + } + } + KeyEvent.KEYCODE_J -> { + { viewModel.handlePlayerEvent(PlayerEvent.SeekRelative(-10_000)) } + } + KeyEvent.KEYCODE_L -> { + { viewModel.handlePlayerEvent(PlayerEvent.SeekRelative(10_000)) } + } + KeyEvent.KEYCODE_DPAD_LEFT -> { + { viewModel.handlePlayerEvent(PlayerEvent.SeekRelative(-5_000)) } + } + KeyEvent.KEYCODE_DPAD_RIGHT -> { + { viewModel.handlePlayerEvent(PlayerEvent.SeekRelative(5_000)) } + } + KeyEvent.KEYCODE_DPAD_UP -> { + { setVolumeStep(currentVolumeStep() + 1) } + } + KeyEvent.KEYCODE_DPAD_DOWN -> { + { setVolumeStep(currentVolumeStep() - 1) } + } + KeyEvent.KEYCODE_M -> { + { + val step = currentVolumeStep() + if (step > 0) { + volumeStepBeforeMute = step + setVolumeStep(0) + } else { + setVolumeStep(volumeStepBeforeMute) + } + } + } + in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9, + in KeyEvent.KEYCODE_NUMPAD_0..KeyEvent.KEYCODE_NUMPAD_9 -> + if (shift) null + else { + { + val digit = + if (event.keyCode <= KeyEvent.KEYCODE_9) { + event.keyCode - KeyEvent.KEYCODE_0 + } else { + event.keyCode - KeyEvent.KEYCODE_NUMPAD_0 + } + if (player.duration > 0) { + viewModel.handlePlayerEvent( + PlayerEvent.Seek(player.duration * digit / 10) + ) + } + } + } + KeyEvent.KEYCODE_MOVE_HOME -> { + { viewModel.handlePlayerEvent(PlayerEvent.Seek(0)) } + } + KeyEvent.KEYCODE_MOVE_END -> { + { + if (player.duration > 0) { + viewModel.handlePlayerEvent(PlayerEvent.Seek(player.duration)) + } + } + } + KeyEvent.KEYCODE_PERIOD -> + if (shift) { + { changePlaybackSpeed(0.25f) } + } else null + KeyEvent.KEYCODE_COMMA -> + if (shift) { + { changePlaybackSpeed(-0.25f) } + } else null + KeyEvent.KEYCODE_N -> + if (shift) { + { viewModel.onNextEpisode() } + } else null + KeyEvent.KEYCODE_P -> + if (shift) { + { viewModel.onPreviousEpisode() } + } else null + KeyEvent.KEYCODE_I -> { + { viewModel.handlePlayerEvent(PlayerEvent.EnterPictureInPicture) } + } + else -> null + } + } + + private fun changePlaybackSpeed(delta: Float) { + val speed = (viewModel.uiState.value.playbackSpeed + delta).coerceIn(0.25f, 2f) + viewModel.handlePlayerEvent(PlayerEvent.SetPlaybackSpeed(speed)) + viewModel.showControls() + } + + private fun currentVolumeStep(): Int = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) + + private fun setVolumeStep(step: Int) { + val maxStep = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + val target = step.coerceIn(0, maxStep) + // SetVolume takes a percentage and truncates it back to a stream step; aiming at the + // middle of the step keeps float rounding from landing one step short. + val percent = if (target == 0) 0 else ((target + 0.5f) * 100f / maxStep).roundToInt() + viewModel.handlePlayerEvent(PlayerEvent.SetVolume(percent.coerceAtMost(100))) + } + private fun hideSystemUI() { WindowCompat.getInsetsController(window, window.decorView).apply { systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE From d0f832d728570ae24141319f7846a37e1cfb9d53 Mon Sep 17 00:00:00 2001 From: kirill Date: Mon, 5 Oct 2026 18:39:28 +0200 Subject: [PATCH 02/13] fix: gate player keyboard shortcuts on player state Skip shortcuts before the player is ready, while casting and while controls are locked; disable seeking and episode skips for live TV and intros; only skip episodes when a neighbour exists; match < and > by character so speed keys work on non-US layouts. --- .../makd/afinity/ui/player/PlayerActivity.kt | 92 +++++++++++-------- 1 file changed, 52 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt b/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt index e202e06b..6ff96ad9 100644 --- a/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt @@ -208,10 +208,20 @@ class PlayerActivity : AppCompatActivity() { } override fun dispatchKeyEvent(event: KeyEvent): Boolean { - if (event.isCtrlPressed || event.isAltPressed || event.isMetaPressed) { + val state = viewModel.uiState.value + // viewModel.player is unset until isPlayerReady; while casting, the cast controller owns + // playback, and the controls lock must block keyboard input like it blocks gestures. + if ( + !state.isPlayerReady || + state.isCasting || + state.isControlsLocked || + event.isCtrlPressed || + event.isAltPressed || + event.isMetaPressed + ) { return super.dispatchKeyEvent(event) } - val shortcut = keyboardShortcut(event) ?: return super.dispatchKeyEvent(event) + val shortcut = keyboardShortcut(event, state) ?: return super.dispatchKeyEvent(event) if ( event.action == KeyEvent.ACTION_DOWN && (event.repeatCount == 0 || event.keyCode in repeatableShortcutKeys) @@ -222,9 +232,14 @@ class PlayerActivity : AppCompatActivity() { } // Mirrors the YouTube web player's keyboard shortcuts. - private fun keyboardShortcut(event: KeyEvent): (() -> Unit)? { + private fun keyboardShortcut( + event: KeyEvent, + state: PlayerViewModel.PlayerUiState, + ): (() -> Unit)? { val player = viewModel.player val shift = event.isShiftPressed + val canSeek = !state.isLiveChannel && !state.isPlayingIntro + val playlist = viewModel.playlistState.value return when (event.keyCode) { KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_K -> { @@ -234,18 +249,10 @@ class PlayerActivity : AppCompatActivity() { ) } } - KeyEvent.KEYCODE_J -> { - { viewModel.handlePlayerEvent(PlayerEvent.SeekRelative(-10_000)) } - } - KeyEvent.KEYCODE_L -> { - { viewModel.handlePlayerEvent(PlayerEvent.SeekRelative(10_000)) } - } - KeyEvent.KEYCODE_DPAD_LEFT -> { - { viewModel.handlePlayerEvent(PlayerEvent.SeekRelative(-5_000)) } - } - KeyEvent.KEYCODE_DPAD_RIGHT -> { - { viewModel.handlePlayerEvent(PlayerEvent.SeekRelative(5_000)) } - } + KeyEvent.KEYCODE_J -> if (canSeek) seekBy(-10_000) else null + KeyEvent.KEYCODE_L -> if (canSeek) seekBy(10_000) else null + KeyEvent.KEYCODE_DPAD_LEFT -> if (canSeek) seekBy(-5_000) else null + KeyEvent.KEYCODE_DPAD_RIGHT -> if (canSeek) seekBy(5_000) else null KeyEvent.KEYCODE_DPAD_UP -> { { setVolumeStep(currentVolumeStep() + 1) } } @@ -265,7 +272,7 @@ class PlayerActivity : AppCompatActivity() { } in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9, in KeyEvent.KEYCODE_NUMPAD_0..KeyEvent.KEYCODE_NUMPAD_9 -> - if (shift) null + if (shift || !canSeek) null else { { val digit = @@ -274,46 +281,51 @@ class PlayerActivity : AppCompatActivity() { } else { event.keyCode - KeyEvent.KEYCODE_NUMPAD_0 } - if (player.duration > 0) { - viewModel.handlePlayerEvent( - PlayerEvent.Seek(player.duration * digit / 10) - ) - } - } - } - KeyEvent.KEYCODE_MOVE_HOME -> { - { viewModel.handlePlayerEvent(PlayerEvent.Seek(0)) } - } - KeyEvent.KEYCODE_MOVE_END -> { - { - if (player.duration > 0) { - viewModel.handlePlayerEvent(PlayerEvent.Seek(player.duration)) + if (player.duration > 0) seekTo(player.duration * digit / 10) } } - } - KeyEvent.KEYCODE_PERIOD -> - if (shift) { - { changePlaybackSpeed(0.25f) } + KeyEvent.KEYCODE_MOVE_HOME -> + if (canSeek) { + { seekTo(0) } } else null - KeyEvent.KEYCODE_COMMA -> - if (shift) { - { changePlaybackSpeed(-0.25f) } + KeyEvent.KEYCODE_MOVE_END -> + if (canSeek) { + { if (player.duration > 0) seekTo(player.duration) } } else null KeyEvent.KEYCODE_N -> - if (shift) { + if (shift && canSeek && playlist.hasNext) { { viewModel.onNextEpisode() } } else null KeyEvent.KEYCODE_P -> - if (shift) { + if (shift && canSeek && playlist.hasPrevious) { { viewModel.onPreviousEpisode() } } else null KeyEvent.KEYCODE_I -> { { viewModel.handlePlayerEvent(PlayerEvent.EnterPictureInPicture) } } - else -> null + // '<' and '>' sit on different physical keys across layouts (e.g. QWERTZ). + else -> + when (event.unicodeChar.toChar()) { + '>' -> { + { changePlaybackSpeed(0.25f) } + } + '<' -> { + { changePlaybackSpeed(-0.25f) } + } + else -> null + } } } + private fun seekBy(deltaMs: Long): () -> Unit = { + viewModel.handlePlayerEvent(PlayerEvent.SeekRelative(deltaMs)) + } + + private fun seekTo(positionMs: Long) { + viewModel.handlePlayerEvent(PlayerEvent.Seek(positionMs)) + viewModel.showControls() + } + private fun changePlaybackSpeed(delta: Float) { val speed = (viewModel.uiState.value.playbackSpeed + delta).coerceIn(0.25f, 2f) viewModel.handlePlayerEvent(PlayerEvent.SetPlaybackSpeed(speed)) From 8378358f96e64696ae8f0e9cca7258ebdab97ecd Mon Sep 17 00:00:00 2001 From: kirill Date: Mon, 5 Oct 2026 18:45:19 +0200 Subject: [PATCH 03/13] fix: let focused player controls handle keys before shortcuts Track keyboard focus on the player root and dispatch keys to a focused control first, so panel lists, sliders and buttons keep arrow/Space navigation. Also toggle play/pause from UI state, map volume steps exactly, respect NumLock for numpad digits and ignore speed keys during a long-press speed-up. --- .../makd/afinity/ui/player/PlayerActivity.kt | 39 ++++++++++++++----- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt b/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt index 6ff96ad9..376ae5e3 100644 --- a/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt @@ -21,12 +21,14 @@ import android.view.WindowManager import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat @@ -42,7 +44,6 @@ import com.makd.afinity.ui.theme.AFinityTheme import dagger.hilt.android.AndroidEntryPoint import java.util.UUID import javax.inject.Inject -import kotlin.math.roundToInt import timber.log.Timber @UnstableApi @@ -59,6 +60,8 @@ class PlayerActivity : AppCompatActivity() { private var volumeStepBeforeMute = 1 + private var playerUiHasFocus = false + private val repeatableShortcutKeys = setOf( KeyEvent.KEYCODE_J, @@ -200,7 +203,10 @@ class PlayerActivity : AppCompatActivity() { channelName = channelName, liveStreamUrl = liveStreamUrl, onBackPressed = { finish() }, - modifier = Modifier.fillMaxSize(), + modifier = + Modifier.fillMaxSize() + .onFocusChanged { playerUiHasFocus = it.hasFocus } + .focusGroup(), ) } } @@ -222,6 +228,9 @@ class PlayerActivity : AppCompatActivity() { return super.dispatchKeyEvent(event) } val shortcut = keyboardShortcut(event, state) ?: return super.dispatchKeyEvent(event) + // A focused control (panel list, slider, button) keeps its own key handling; shortcuts + // only take the keys it leaves unused. + if (playerUiHasFocus && super.dispatchKeyEvent(event)) return true if ( event.action == KeyEvent.ACTION_DOWN && (event.repeatCount == 0 || event.keyCode in repeatableShortcutKeys) @@ -245,7 +254,7 @@ class PlayerActivity : AppCompatActivity() { KeyEvent.KEYCODE_K -> { { viewModel.handlePlayerEvent( - if (player.isPlaying) PlayerEvent.Pause else PlayerEvent.Play + if (state.isPlaying) PlayerEvent.Pause else PlayerEvent.Play ) } } @@ -272,7 +281,12 @@ class PlayerActivity : AppCompatActivity() { } in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9, in KeyEvent.KEYCODE_NUMPAD_0..KeyEvent.KEYCODE_NUMPAD_9 -> - if (shift || !canSeek) null + if ( + shift || + !canSeek || + (event.keyCode >= KeyEvent.KEYCODE_NUMPAD_0 && !event.isNumLockOn) + ) + null else { { val digit = @@ -305,7 +319,7 @@ class PlayerActivity : AppCompatActivity() { } // '<' and '>' sit on different physical keys across layouts (e.g. QWERTZ). else -> - when (event.unicodeChar.toChar()) { + when (if (state.isSpeedingUp) null else event.unicodeChar.toChar()) { '>' -> { { changePlaybackSpeed(0.25f) } } @@ -337,10 +351,17 @@ class PlayerActivity : AppCompatActivity() { private fun setVolumeStep(step: Int) { val maxStep = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) val target = step.coerceIn(0, maxStep) - // SetVolume takes a percentage and truncates it back to a stream step; aiming at the - // middle of the step keeps float rounding from landing one step short. - val percent = if (target == 0) 0 else ((target + 0.5f) * 100f / maxStep).roundToInt() - viewModel.handlePlayerEvent(PlayerEvent.SetVolume(percent.coerceAtMost(100))) + // SetVolume takes a percentage that VolumeManager truncates back to a stream step; pick + // the percentage that lands on the target under that same conversion. With more than + // 100 steps not every step is reachable, so move to the nearest one in that direction. + val stepAt = { percent: Int -> ((percent.toFloat() / 100f) * maxStep).toInt() } + val percent = + if (target < currentVolumeStep()) { + (100 downTo 0).firstOrNull { stepAt(it) <= target } ?: 0 + } else { + (0..100).firstOrNull { stepAt(it) >= target } ?: 100 + } + viewModel.handlePlayerEvent(PlayerEvent.SetVolume(percent)) } private fun hideSystemUI() { From dbb32b684f9f64dd3fa53073bd0597ae00bca84b Mon Sep 17 00:00:00 2001 From: kirill Date: Mon, 5 Oct 2026 18:52:03 +0200 Subject: [PATCH 04/13] fix: don't repeat held seek shortcuts inside a SyncPlay group Each repeated seek is forwarded to the SyncPlay server and re-syncs every member, so held J/L/arrow keys only seek once per press while in a group. --- .../makd/afinity/ui/player/PlayerActivity.kt | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt b/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt index 376ae5e3..b4ffe05f 100644 --- a/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt @@ -52,6 +52,9 @@ class PlayerActivity : AppCompatActivity() { private val viewModel: PlayerViewModel by viewModels() + // Same activity-scoped instance PlayerScreen gets from hiltViewModel(). + private val syncPlayViewModel: SyncPlayViewModel by viewModels() + @Inject lateinit var preferencesRepository: PreferencesRepository private var wasPip: Boolean = false @@ -62,14 +65,14 @@ class PlayerActivity : AppCompatActivity() { private var playerUiHasFocus = false - private val repeatableShortcutKeys = + private val repeatableVolumeKeys = setOf(KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN) + + private val repeatableSeekKeys = setOf( KeyEvent.KEYCODE_J, KeyEvent.KEYCODE_L, KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT, - KeyEvent.KEYCODE_DPAD_UP, - KeyEvent.KEYCODE_DPAD_DOWN, ) companion object { @@ -231,10 +234,7 @@ class PlayerActivity : AppCompatActivity() { // A focused control (panel list, slider, button) keeps its own key handling; shortcuts // only take the keys it leaves unused. if (playerUiHasFocus && super.dispatchKeyEvent(event)) return true - if ( - event.action == KeyEvent.ACTION_DOWN && - (event.repeatCount == 0 || event.keyCode in repeatableShortcutKeys) - ) { + if (event.action == KeyEvent.ACTION_DOWN && isShortcutRepeatAllowed(event)) { shortcut() } return true @@ -331,6 +331,13 @@ class PlayerActivity : AppCompatActivity() { } } + // Every repeated seek in a SyncPlay group is sent to the server and re-syncs all members. + private fun isShortcutRepeatAllowed(event: KeyEvent): Boolean = + event.repeatCount == 0 || + event.keyCode in repeatableVolumeKeys || + (event.keyCode in repeatableSeekKeys && + !syncPlayViewModel.syncPlayState.value.isInGroup) + private fun seekBy(deltaMs: Long): () -> Unit = { viewModel.handlePlayerEvent(PlayerEvent.SeekRelative(deltaMs)) } From ecef478080852fe2654f744df3324d2b1c580a10 Mon Sep 17 00:00:00 2001 From: kirill Date: Mon, 5 Oct 2026 19:01:59 +0200 Subject: [PATCH 05/13] fix: swallow disabled seek keys and pause while buffering On live TV and intros, seek shortcuts are consumed as no-ops instead of falling through to focus navigation. Space/K pause while buffering, and shortcuts stay off while a cast session is connected. --- .../makd/afinity/ui/player/PlayerActivity.kt | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt b/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt index b4ffe05f..2bca6e87 100644 --- a/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt @@ -223,6 +223,7 @@ class PlayerActivity : AppCompatActivity() { if ( !state.isPlayerReady || state.isCasting || + viewModel.castManager.castState.value.isConnected || state.isControlsLocked || event.isCtrlPressed || event.isAltPressed || @@ -248,20 +249,24 @@ class PlayerActivity : AppCompatActivity() { val player = viewModel.player val shift = event.isShiftPressed val canSeek = !state.isLiveChannel && !state.isPlayingIntro + // Seek keys are swallowed rather than passed on when seeking is off, so they don't + // silently turn into focus navigation on live TV or intros. + val noOp: () -> Unit = {} val playlist = viewModel.playlistState.value return when (event.keyCode) { KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_K -> { { viewModel.handlePlayerEvent( - if (state.isPlaying) PlayerEvent.Pause else PlayerEvent.Play + if (state.isPlaying || state.playWhenReady) PlayerEvent.Pause + else PlayerEvent.Play ) } } - KeyEvent.KEYCODE_J -> if (canSeek) seekBy(-10_000) else null - KeyEvent.KEYCODE_L -> if (canSeek) seekBy(10_000) else null - KeyEvent.KEYCODE_DPAD_LEFT -> if (canSeek) seekBy(-5_000) else null - KeyEvent.KEYCODE_DPAD_RIGHT -> if (canSeek) seekBy(5_000) else null + KeyEvent.KEYCODE_J -> if (canSeek) seekBy(-10_000) else noOp + KeyEvent.KEYCODE_L -> if (canSeek) seekBy(10_000) else noOp + KeyEvent.KEYCODE_DPAD_LEFT -> if (canSeek) seekBy(-5_000) else noOp + KeyEvent.KEYCODE_DPAD_RIGHT -> if (canSeek) seekBy(5_000) else noOp KeyEvent.KEYCODE_DPAD_UP -> { { setVolumeStep(currentVolumeStep() + 1) } } @@ -281,13 +286,11 @@ class PlayerActivity : AppCompatActivity() { } in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9, in KeyEvent.KEYCODE_NUMPAD_0..KeyEvent.KEYCODE_NUMPAD_9 -> - if ( - shift || - !canSeek || - (event.keyCode >= KeyEvent.KEYCODE_NUMPAD_0 && !event.isNumLockOn) - ) + if (shift || (event.keyCode >= KeyEvent.KEYCODE_NUMPAD_0 && !event.isNumLockOn)) { null - else { + } else if (!canSeek) { + noOp + } else { { val digit = if (event.keyCode <= KeyEvent.KEYCODE_9) { @@ -301,11 +304,11 @@ class PlayerActivity : AppCompatActivity() { KeyEvent.KEYCODE_MOVE_HOME -> if (canSeek) { { seekTo(0) } - } else null + } else noOp KeyEvent.KEYCODE_MOVE_END -> if (canSeek) { { if (player.duration > 0) seekTo(player.duration) } - } else null + } else noOp KeyEvent.KEYCODE_N -> if (shift && canSeek && playlist.hasNext) { { viewModel.onNextEpisode() } From 5293db799725aaa77fdd78c3b09bab2d9279d1da Mon Sep 17 00:00:00 2001 From: kirill Date: Mon, 5 Oct 2026 19:12:07 +0200 Subject: [PATCH 06/13] fix: hand keys to open player panels and let OK reach the controls Shortcuts step aside while an in-window panel (tracks, quality, speed, episodes, chapters, sleep timer, version picker) is open, and until an item is loaded. With nothing focused, OK/Enter shows the controls and then moves focus into them, so remotes and gamepads without Tab can still reach every control. --- .../makd/afinity/ui/player/PlayerActivity.kt | 30 ++++++++++++++++++- .../makd/afinity/ui/player/PlayerScreen.kt | 8 +++++ .../makd/afinity/ui/player/PlayerViewModel.kt | 3 ++ .../ui/player/components/PlayerControls.kt | 14 +++++++++ 4 files changed, 54 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt b/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt index 2bca6e87..d9001bd7 100644 --- a/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt @@ -65,6 +65,9 @@ class PlayerActivity : AppCompatActivity() { private var playerUiHasFocus = false + private val confirmKeys = + setOf(KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER) + private val repeatableVolumeKeys = setOf(KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN) private val repeatableSeekKeys = @@ -219,9 +222,12 @@ class PlayerActivity : AppCompatActivity() { override fun dispatchKeyEvent(event: KeyEvent): Boolean { val state = viewModel.uiState.value // viewModel.player is unset until isPlayerReady; while casting, the cast controller owns - // playback, and the controls lock must block keyboard input like it blocks gestures. + // playback, the controls lock must block keyboard input like it blocks gestures, and an + // open in-window panel needs the keys for its own focus navigation. if ( !state.isPlayerReady || + state.currentItem == null || + viewModel.isOverlayPanelOpen || state.isCasting || viewModel.castManager.castState.value.isConnected || state.isControlsLocked || @@ -231,6 +237,28 @@ class PlayerActivity : AppCompatActivity() { ) { return super.dispatchKeyEvent(event) } + if (!playerUiHasFocus && event.keyCode in confirmKeys) { + // With nothing focused OK does nothing, and remotes/gamepads have no Tab key to reach + // the controls: the first press shows them, the next focuses the first one like Tab. + if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { + if (state.showControls) { + for (action in listOf(KeyEvent.ACTION_DOWN, KeyEvent.ACTION_UP)) { + super.dispatchKeyEvent( + KeyEvent( + event.downTime, + event.eventTime, + action, + KeyEvent.KEYCODE_TAB, + 0, + ) + ) + } + } else { + viewModel.showControls() + } + } + return true + } val shortcut = keyboardShortcut(event, state) ?: return super.dispatchKeyEvent(event) // A focused control (panel list, slider, button) keeps its own key handling; shortcuts // only take the keys it leaves unused. diff --git a/app/src/main/java/com/makd/afinity/ui/player/PlayerScreen.kt b/app/src/main/java/com/makd/afinity/ui/player/PlayerScreen.kt index 4654a7f8..fb788842 100644 --- a/app/src/main/java/com/makd/afinity/ui/player/PlayerScreen.kt +++ b/app/src/main/java/com/makd/afinity/ui/player/PlayerScreen.kt @@ -109,6 +109,13 @@ fun PlayerScreen( var dragStartVolume by remember { mutableIntStateOf(-1) } var dragStartBrightness by remember { mutableFloatStateOf(-1f) } var showVersionPicker by remember { mutableStateOf(false) } + var isControlsPanelOpen by remember { mutableStateOf(false) } + val isOverlayPanelOpen = + isControlsPanelOpen || (showVersionPicker && uiState.availableSources.size > 1) + DisposableEffect(isOverlayPanelOpen) { + viewModel.isOverlayPanelOpen = isOverlayPanelOpen + onDispose { viewModel.isOverlayPanelOpen = false } + } LocalLifecycleOwner.current LaunchedEffect(Unit) { viewModel.closePlayerEvent.collect { onBackPressed() } } @@ -369,6 +376,7 @@ fun PlayerScreen( syncPlayMembers = syncPlayState.members, syncPlayGroupName = syncPlayState.groupName, syncPlayMemberInfo = syncPlayMemberInfo, + onPanelOpenChange = { isControlsPanelOpen = it }, ) if (syncPlayState.isInGroup && syncPlayState.groupState == GroupStateType.WAITING) { diff --git a/app/src/main/java/com/makd/afinity/ui/player/PlayerViewModel.kt b/app/src/main/java/com/makd/afinity/ui/player/PlayerViewModel.kt index cc1b2711..fb826bd9 100644 --- a/app/src/main/java/com/makd/afinity/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/com/makd/afinity/ui/player/PlayerViewModel.kt @@ -185,6 +185,9 @@ constructor( var syncPlayInterceptor: SyncPlayInterceptor? = null + // Set by PlayerScreen while an in-window panel is open; keyboard shortcuts step aside then. + var isOverlayPanelOpen = false + private var hasStoppedPlayback = false private var currentSessionId: String? = null private var currentLivePlaybackInfo: LiveTvPlaybackInfo? = null diff --git a/app/src/main/java/com/makd/afinity/ui/player/components/PlayerControls.kt b/app/src/main/java/com/makd/afinity/ui/player/components/PlayerControls.kt index 51c6b94a..b1604354 100644 --- a/app/src/main/java/com/makd/afinity/ui/player/components/PlayerControls.kt +++ b/app/src/main/java/com/makd/afinity/ui/player/components/PlayerControls.kt @@ -60,6 +60,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.getValue @@ -163,6 +164,7 @@ fun PlayerControls( syncPlayMembers: List = emptyList(), syncPlayGroupName: String = "", syncPlayMemberInfo: Map = emptyMap(), + onPanelOpenChange: (Boolean) -> Unit = {}, ) { var showTrackPanel by remember { mutableStateOf(false) } var showQualityPanel by remember { mutableStateOf(false) } @@ -176,6 +178,18 @@ fun PlayerControls( if (uiState.sleepTimerExpired) showSleepTimerPanel = false } + val isPanelOpen = + showTrackPanel || + showQualityPanel || + showSpeedDialog || + showEpisodeSwitcher || + showChapterSwitcher || + showSleepTimerPanel + DisposableEffect(isPanelOpen) { + onPanelOpenChange(isPanelOpen) + onDispose { onPanelOpenChange(false) } + } + val sleepTimerEndOfItemRemainingMs: () -> Long = { if (uiState.isLiveChannel || uiState.duration <= 0L) { 0L From 6ce458bca6c8bc7c0a137f73c1a02007e7b12992 Mon Sep 17 00:00:00 2001 From: kirill Date: Mon, 5 Oct 2026 19:20:55 +0200 Subject: [PATCH 07/13] fix: limit player shortcuts to full keyboards Remotes, gamepads and D-pads keep master's plain focus navigation, so drop the OK-to-Tab focus entry (it landed focus on Back, where the next Space exited the player). Also treat the SyncPlay members popup as an open panel, and resume instead of pausing once playback has ended. --- .../makd/afinity/ui/player/PlayerActivity.kt | 47 +++++++------------ .../ui/player/components/PlayerControls.kt | 1 + 2 files changed, 17 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt b/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt index d9001bd7..d1670947 100644 --- a/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt @@ -16,6 +16,7 @@ import android.media.AudioManager import android.os.Bundle import android.os.Process import android.util.Rational +import android.view.InputDevice import android.view.KeyEvent import android.view.WindowManager import androidx.activity.compose.setContent @@ -34,6 +35,7 @@ import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import com.makd.afinity.R import com.makd.afinity.data.models.player.PlayerEvent @@ -65,9 +67,6 @@ class PlayerActivity : AppCompatActivity() { private var playerUiHasFocus = false - private val confirmKeys = - setOf(KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER) - private val repeatableVolumeKeys = setOf(KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN) private val repeatableSeekKeys = @@ -221,11 +220,13 @@ class PlayerActivity : AppCompatActivity() { override fun dispatchKeyEvent(event: KeyEvent): Boolean { val state = viewModel.uiState.value - // viewModel.player is unset until isPlayerReady; while casting, the cast controller owns - // playback, the controls lock must block keyboard input like it blocks gestures, and an - // open in-window panel needs the keys for its own focus navigation. + // Only full keyboards get shortcuts: remotes, gamepads and D-pads keep plain focus + // navigation. viewModel.player is unset until isPlayerReady; while casting, the cast + // controller owns playback, the controls lock must block keyboard input like it blocks + // gestures, and an open in-window panel needs the keys for its own focus navigation. if ( - !state.isPlayerReady || + event.device?.keyboardType != InputDevice.KEYBOARD_TYPE_ALPHABETIC || + !state.isPlayerReady || state.currentItem == null || viewModel.isOverlayPanelOpen || state.isCasting || @@ -237,28 +238,6 @@ class PlayerActivity : AppCompatActivity() { ) { return super.dispatchKeyEvent(event) } - if (!playerUiHasFocus && event.keyCode in confirmKeys) { - // With nothing focused OK does nothing, and remotes/gamepads have no Tab key to reach - // the controls: the first press shows them, the next focuses the first one like Tab. - if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { - if (state.showControls) { - for (action in listOf(KeyEvent.ACTION_DOWN, KeyEvent.ACTION_UP)) { - super.dispatchKeyEvent( - KeyEvent( - event.downTime, - event.eventTime, - action, - KeyEvent.KEYCODE_TAB, - 0, - ) - ) - } - } else { - viewModel.showControls() - } - } - return true - } val shortcut = keyboardShortcut(event, state) ?: return super.dispatchKeyEvent(event) // A focused control (panel list, slider, button) keeps its own key handling; shortcuts // only take the keys it leaves unused. @@ -286,8 +265,14 @@ class PlayerActivity : AppCompatActivity() { KeyEvent.KEYCODE_K -> { { viewModel.handlePlayerEvent( - if (state.isPlaying || state.playWhenReady) PlayerEvent.Pause - else PlayerEvent.Play + if ( + state.isPlaying || + (state.playWhenReady && player.playbackState != Player.STATE_ENDED) + ) { + PlayerEvent.Pause + } else { + PlayerEvent.Play + } ) } } diff --git a/app/src/main/java/com/makd/afinity/ui/player/components/PlayerControls.kt b/app/src/main/java/com/makd/afinity/ui/player/components/PlayerControls.kt index b1604354..04923aaf 100644 --- a/app/src/main/java/com/makd/afinity/ui/player/components/PlayerControls.kt +++ b/app/src/main/java/com/makd/afinity/ui/player/components/PlayerControls.kt @@ -184,6 +184,7 @@ fun PlayerControls( showSpeedDialog || showEpisodeSwitcher || showChapterSwitcher || + showMembersPopup || showSleepTimerPanel DisposableEffect(isPanelOpen) { onPanelOpenChange(isPanelOpen) From 292473608f0046c42d89ae7dca71c294cb06dd8a Mon Sep 17 00:00:00 2001 From: kirill Date: Mon, 5 Oct 2026 19:28:56 +0200 Subject: [PATCH 08/13] fix: keep panel flag in sync and arrows with focus navigation Derive the open-panel flag from the same conditions the panels render under, so a panel that disappears with its data no longer disables shortcuts. When a control is focused, arrow keys stay with focus navigation even at its edge instead of seeking or changing volume. --- .../com/makd/afinity/ui/player/PlayerActivity.kt | 15 +++++++++++++-- .../ui/player/components/PlayerControls.kt | 7 ++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt b/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt index d1670947..8c0c0fb8 100644 --- a/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt @@ -67,6 +67,14 @@ class PlayerActivity : AppCompatActivity() { private var playerUiHasFocus = false + private val arrowKeys = + setOf( + KeyEvent.KEYCODE_DPAD_UP, + KeyEvent.KEYCODE_DPAD_DOWN, + KeyEvent.KEYCODE_DPAD_LEFT, + KeyEvent.KEYCODE_DPAD_RIGHT, + ) + private val repeatableVolumeKeys = setOf(KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN) private val repeatableSeekKeys = @@ -240,8 +248,11 @@ class PlayerActivity : AppCompatActivity() { } val shortcut = keyboardShortcut(event, state) ?: return super.dispatchKeyEvent(event) // A focused control (panel list, slider, button) keeps its own key handling; shortcuts - // only take the keys it leaves unused. - if (playerUiHasFocus && super.dispatchKeyEvent(event)) return true + // only take the keys it leaves unused. Arrows stay with focus navigation even at its edge. + if (playerUiHasFocus) { + if (super.dispatchKeyEvent(event)) return true + if (event.keyCode in arrowKeys) return false + } if (event.action == KeyEvent.ACTION_DOWN && isShortcutRepeatAllowed(event)) { shortcut() } diff --git a/app/src/main/java/com/makd/afinity/ui/player/components/PlayerControls.kt b/app/src/main/java/com/makd/afinity/ui/player/components/PlayerControls.kt index 04923aaf..0633fa15 100644 --- a/app/src/main/java/com/makd/afinity/ui/player/components/PlayerControls.kt +++ b/app/src/main/java/com/makd/afinity/ui/player/components/PlayerControls.kt @@ -178,13 +178,14 @@ fun PlayerControls( if (uiState.sleepTimerExpired) showSleepTimerPanel = false } + // Same conditions the panels render under, so a panel that vanished doesn't stay "open". val isPanelOpen = showTrackPanel || showQualityPanel || showSpeedDialog || - showEpisodeSwitcher || - showChapterSwitcher || - showMembersPopup || + (showEpisodeSwitcher && playlistQueue.isNotEmpty()) || + (showChapterSwitcher && uiState.chapters.isNotEmpty()) || + (showMembersPopup && isSyncPlay) || showSleepTimerPanel DisposableEffect(isPanelOpen) { onPanelOpenChange(isPanelOpen) From f28ac7faaa2e0674bbc98185918618aebaa75322 Mon Sep 17 00:00:00 2001 From: kirill Date: Mon, 5 Oct 2026 19:37:22 +0200 Subject: [PATCH 09/13] fix: exclude virtual and HDMI-CEC input from player shortcuts CEC remote keys arrive via the virtual alphabetic keyboard, so require a physical keyboard and skip SOURCE_HDMI. Gate on left Alt only so AltGr layouts can still type < and >. --- .../com/makd/afinity/ui/player/PlayerActivity.kt | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt b/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt index 8c0c0fb8..4c0abbfb 100644 --- a/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt @@ -228,12 +228,15 @@ class PlayerActivity : AppCompatActivity() { override fun dispatchKeyEvent(event: KeyEvent): Boolean { val state = viewModel.uiState.value - // Only full keyboards get shortcuts: remotes, gamepads and D-pads keep plain focus - // navigation. viewModel.player is unset until isPlayerReady; while casting, the cast - // controller owns playback, the controls lock must block keyboard input like it blocks - // gestures, and an open in-window panel needs the keys for its own focus navigation. + // Only physical full keyboards get shortcuts: remotes, gamepads and D-pads keep plain + // focus navigation (HDMI-CEC remote keys arrive via the virtual alphabetic keyboard). + // viewModel.player is unset until isPlayerReady; while casting, the cast controller owns + // playback, the controls lock must block keyboard input like it blocks gestures, and an + // open in-window panel needs the keys for its own focus navigation. if ( event.device?.keyboardType != InputDevice.KEYBOARD_TYPE_ALPHABETIC || + event.device?.isVirtual != false || + event.isFromSource(InputDevice.SOURCE_HDMI) || !state.isPlayerReady || state.currentItem == null || viewModel.isOverlayPanelOpen || @@ -241,7 +244,8 @@ class PlayerActivity : AppCompatActivity() { viewModel.castManager.castState.value.isConnected || state.isControlsLocked || event.isCtrlPressed || - event.isAltPressed || + // Right Alt is AltGr, which some layouts need to type < and >. + (event.metaState and KeyEvent.META_ALT_LEFT_ON) != 0 || event.isMetaPressed ) { return super.dispatchKeyEvent(event) From 5b67a6e25f84843bb9d7a04b5fb886527da8118c Mon Sep 17 00:00:00 2001 From: kirill Date: Mon, 5 Oct 2026 20:40:14 +0200 Subject: [PATCH 10/13] fix: arm arrow shortcuts only on devices that have typed Some TV remotes report as full keyboards. Arrow keys now keep master's focus navigation until the same device presses a letter or Space in the player, after which they seek and change volume. --- .../com/makd/afinity/ui/player/PlayerActivity.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt b/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt index 4c0abbfb..9c5d0f0d 100644 --- a/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt @@ -67,6 +67,11 @@ class PlayerActivity : AppCompatActivity() { private var playerUiHasFocus = false + // Devices that pressed a letter or Space in this player. Some TV remotes report themselves + // as full keyboards (and have digit keys), so arrows only become seek/volume shortcuts on a + // device that has typed. + private val typingDeviceIds = mutableSetOf() + private val arrowKeys = setOf( KeyEvent.KEYCODE_DPAD_UP, @@ -250,6 +255,14 @@ class PlayerActivity : AppCompatActivity() { ) { return super.dispatchKeyEvent(event) } + if ( + event.keyCode in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z || + event.keyCode == KeyEvent.KEYCODE_SPACE + ) { + typingDeviceIds += event.deviceId + } else if (event.keyCode in arrowKeys && event.deviceId !in typingDeviceIds) { + return super.dispatchKeyEvent(event) + } val shortcut = keyboardShortcut(event, state) ?: return super.dispatchKeyEvent(event) // A focused control (panel list, slider, button) keeps its own key handling; shortcuts // only take the keys it leaves unused. Arrows stay with focus navigation even at its edge. From 2d852e0207cea2bd4e713df8b4951741d08521be Mon Sep 17 00:00:00 2001 From: kirill Date: Mon, 5 Oct 2026 20:49:18 +0200 Subject: [PATCH 11/13] fix: Space always toggles playback and shows controls; Enter skips Space no longer clicks a focused control (e.g. Skip Outro); it toggles play/pause and shows the controls. Enter presses the visible skip button. Digits and Home/End now also need a device that has typed, and the volume indicator reads 0 when muted. --- .../makd/afinity/ui/player/PlayerActivity.kt | 37 ++++++++++++++----- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt b/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt index 9c5d0f0d..29d89591 100644 --- a/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt @@ -68,7 +68,7 @@ class PlayerActivity : AppCompatActivity() { private var playerUiHasFocus = false // Devices that pressed a letter or Space in this player. Some TV remotes report themselves - // as full keyboards (and have digit keys), so arrows only become seek/volume shortcuts on a + // as full keyboards (and have digit keys), so typingGatedKeys only become shortcuts on a // device that has typed. private val typingDeviceIds = mutableSetOf() @@ -80,6 +80,12 @@ class PlayerActivity : AppCompatActivity() { KeyEvent.KEYCODE_DPAD_RIGHT, ) + private val typingGatedKeys = + arrowKeys + + (KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9) + + (KeyEvent.KEYCODE_NUMPAD_0..KeyEvent.KEYCODE_NUMPAD_9) + + setOf(KeyEvent.KEYCODE_MOVE_HOME, KeyEvent.KEYCODE_MOVE_END) + private val repeatableVolumeKeys = setOf(KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN) private val repeatableSeekKeys = @@ -260,13 +266,14 @@ class PlayerActivity : AppCompatActivity() { event.keyCode == KeyEvent.KEYCODE_SPACE ) { typingDeviceIds += event.deviceId - } else if (event.keyCode in arrowKeys && event.deviceId !in typingDeviceIds) { + } else if (event.keyCode in typingGatedKeys && event.deviceId !in typingDeviceIds) { return super.dispatchKeyEvent(event) } val shortcut = keyboardShortcut(event, state) ?: return super.dispatchKeyEvent(event) // A focused control (panel list, slider, button) keeps its own key handling; shortcuts // only take the keys it leaves unused. Arrows stay with focus navigation even at its edge. - if (playerUiHasFocus) { + // Space always plays/pauses instead of clicking the focused control. + if (playerUiHasFocus && event.keyCode != KeyEvent.KEYCODE_SPACE) { if (super.dispatchKeyEvent(event)) return true if (event.keyCode in arrowKeys) return false } @@ -302,8 +309,16 @@ class PlayerActivity : AppCompatActivity() { PlayerEvent.Play } ) + viewModel.showControls() } } + KeyEvent.KEYCODE_ENTER, + KeyEvent.KEYCODE_NUMPAD_ENTER -> { + val segment = state.currentSegment + if (state.showSkipButton && segment != null && !state.sleepTimerExpired) { + { viewModel.handlePlayerEvent(PlayerEvent.SkipSegment(segment)) } + } else null + } KeyEvent.KEYCODE_J -> if (canSeek) seekBy(-10_000) else noOp KeyEvent.KEYCODE_L -> if (canSeek) seekBy(10_000) else noOp KeyEvent.KEYCODE_DPAD_LEFT -> if (canSeek) seekBy(-5_000) else noOp @@ -403,15 +418,17 @@ class PlayerActivity : AppCompatActivity() { val maxStep = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) val target = step.coerceIn(0, maxStep) // SetVolume takes a percentage that VolumeManager truncates back to a stream step; pick - // the percentage that lands on the target under that same conversion. With more than - // 100 steps not every step is reachable, so move to the nearest one in that direction. + // the lowest percentage that lands on the target under that same conversion, so the + // indicator reads 0 when muted. With more than 100 steps not every step is reachable, so + // move to the nearest one in that direction. val stepAt = { percent: Int -> ((percent.toFloat() / 100f) * maxStep).toInt() } val percent = - if (target < currentVolumeStep()) { - (100 downTo 0).firstOrNull { stepAt(it) <= target } ?: 0 - } else { - (0..100).firstOrNull { stepAt(it) >= target } ?: 100 - } + (0..100).firstOrNull { stepAt(it) == target } + ?: if (target < currentVolumeStep()) { + (100 downTo 0).firstOrNull { stepAt(it) <= target } ?: 0 + } else { + (0..100).firstOrNull { stepAt(it) >= target } ?: 100 + } viewModel.handlePlayerEvent(PlayerEvent.SetVolume(percent)) } From 13d113175194992662bdb20d64a48dd9d601e998 Mon Sep 17 00:00:00 2001 From: kirill Date: Mon, 5 Oct 2026 20:57:24 +0200 Subject: [PATCH 12/13] fix: pair consumed key DOWN/UP and skip I without PiP support Consume the UP of every DOWN a shortcut took, even if state changed in between, so Compose never sees an unpaired UP. Leave I unhandled when picture-in-picture isn't supported. --- .../makd/afinity/ui/player/PlayerActivity.kt | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt b/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt index 29d89591..29b4ca3d 100644 --- a/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt @@ -67,6 +67,8 @@ class PlayerActivity : AppCompatActivity() { private var playerUiHasFocus = false + private val consumedDownKeyCodes = mutableSetOf() + // Devices that pressed a letter or Space in this player. Some TV remotes report themselves // as full keyboards (and have digit keys), so typingGatedKeys only become shortcuts on a // device that has typed. @@ -238,6 +240,11 @@ class PlayerActivity : AppCompatActivity() { } override fun dispatchKeyEvent(event: KeyEvent): Boolean { + // State can change between a key's DOWN and UP (e.g. Enter hides the skip button), so the + // UP of a consumed DOWN is consumed too rather than reaching Compose unpaired. + if (event.action == KeyEvent.ACTION_UP && consumedDownKeyCodes.remove(event.keyCode)) { + return true + } val state = viewModel.uiState.value // Only physical full keyboards get shortcuts: remotes, gamepads and D-pads keep plain // focus navigation (HDMI-CEC remote keys arrive via the virtual alphabetic keyboard). @@ -277,8 +284,9 @@ class PlayerActivity : AppCompatActivity() { if (super.dispatchKeyEvent(event)) return true if (event.keyCode in arrowKeys) return false } - if (event.action == KeyEvent.ACTION_DOWN && isShortcutRepeatAllowed(event)) { - shortcut() + if (event.action == KeyEvent.ACTION_DOWN) { + consumedDownKeyCodes += event.keyCode + if (isShortcutRepeatAllowed(event)) shortcut() } return true } @@ -373,9 +381,10 @@ class PlayerActivity : AppCompatActivity() { if (shift && canSeek && playlist.hasPrevious) { { viewModel.onPreviousEpisode() } } else null - KeyEvent.KEYCODE_I -> { - { viewModel.handlePlayerEvent(PlayerEvent.EnterPictureInPicture) } - } + KeyEvent.KEYCODE_I -> + if (isPipSupported) { + { viewModel.handlePlayerEvent(PlayerEvent.EnterPictureInPicture) } + } else null // '<' and '>' sit on different physical keys across layouts (e.g. QWERTZ). else -> when (if (state.isSpeedingUp) null else event.unicodeChar.toChar()) { From 8f85afe5e938f3c2a28f3e4a84f79bbf9d9ef512 Mon Sep 17 00:00:00 2001 From: kirill Date: Mon, 5 Oct 2026 21:06:54 +0200 Subject: [PATCH 13/13] fix: keep arrow shortcuts working while a control has focus The skip-segment button can hold focus as the only focusable control, which swallowed arrow keys. Once a keyboard has typed, arrows (like Space) always seek and change volume; other keys still reach a focused control first. --- .../com/makd/afinity/ui/player/PlayerActivity.kt | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt b/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt index 29b4ca3d..bbdf0dec 100644 --- a/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/makd/afinity/ui/player/PlayerActivity.kt @@ -277,12 +277,16 @@ class PlayerActivity : AppCompatActivity() { return super.dispatchKeyEvent(event) } val shortcut = keyboardShortcut(event, state) ?: return super.dispatchKeyEvent(event) - // A focused control (panel list, slider, button) keeps its own key handling; shortcuts - // only take the keys it leaves unused. Arrows stay with focus navigation even at its edge. - // Space always plays/pauses instead of clicking the focused control. - if (playerUiHasFocus && event.keyCode != KeyEvent.KEYCODE_SPACE) { - if (super.dispatchKeyEvent(event)) return true - if (event.keyCode in arrowKeys) return false + // A focused control (e.g. Enter on a Tab-focused button) keeps its own key handling. + // Space and the arrows stay shortcuts: a lone focused control such as the skip button + // would otherwise swallow them. + if ( + playerUiHasFocus && + event.keyCode != KeyEvent.KEYCODE_SPACE && + event.keyCode !in arrowKeys && + super.dispatchKeyEvent(event) + ) { + return true } if (event.action == KeyEvent.ACTION_DOWN) { consumedDownKeyCodes += event.keyCode