From 255e49080d8d9931e1c1e91149f9b86bd7a156fc Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:37:31 +0300 Subject: [PATCH 1/7] refactor(player): move volume key handling into PlayerHardwareController --- .../java/io/github/aedev/flow/MainActivity.kt | 29 +++---------------- .../flow/player/PlayerHardwareController.kt | 22 +++++++++++++- 2 files changed, 25 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/io/github/aedev/flow/MainActivity.kt b/app/src/main/java/io/github/aedev/flow/MainActivity.kt index a9e0801e3f..ae83038ddd 100644 --- a/app/src/main/java/io/github/aedev/flow/MainActivity.kt +++ b/app/src/main/java/io/github/aedev/flow/MainActivity.kt @@ -3,7 +3,6 @@ package io.github.aedev.flow import android.content.ContentResolver import android.content.Context import android.content.Intent -import android.media.AudioManager import android.net.Uri import android.os.Build import android.os.Bundle @@ -508,31 +507,11 @@ class MainActivity : ComponentActivity() { ): Boolean { if ( (keyCode == KeyEvent.KEYCODE_VOLUME_UP || keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) && - io.github.aedev.flow.player.PlayerHardwareController.fullscreenVideoActive.value + io.github.aedev.flow.player.PlayerHardwareController.fullscreenVideoActive.value && + io.github.aedev.flow.player.PlayerHardwareController + .adjustVolume(this, raise = keyCode == KeyEvent.KEYCODE_VOLUME_UP) ) { - val audioManager = getSystemService(Context.AUDIO_SERVICE) as? AudioManager - if (audioManager != null) { - val direction = - if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) { - AudioManager.ADJUST_RAISE - } else { - AudioManager.ADJUST_LOWER - } - audioManager.adjustStreamVolume( - AudioManager.STREAM_MUSIC, - direction, - if (io.github.aedev.flow.player.PlayerHardwareController.inAppVolumeOverlayEnabled.value) { - 0 - } else { - AudioManager.FLAG_SHOW_UI - }, - ) - if (io.github.aedev.flow.player.PlayerHardwareController.inAppVolumeOverlayEnabled.value) { - io.github.aedev.flow.player.PlayerHardwareController - .notifyVolumeKey() - } - return true - } + return true } return super.onKeyDown(keyCode, event) } diff --git a/app/src/main/java/io/github/aedev/flow/player/PlayerHardwareController.kt b/app/src/main/java/io/github/aedev/flow/player/PlayerHardwareController.kt index bbdb832333..fe9e8eaaee 100644 --- a/app/src/main/java/io/github/aedev/flow/player/PlayerHardwareController.kt +++ b/app/src/main/java/io/github/aedev/flow/player/PlayerHardwareController.kt @@ -1,11 +1,12 @@ package io.github.aedev.flow.player +import android.content.Context +import android.media.AudioManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow object PlayerHardwareController { - private val _fullscreenVideoActive = MutableStateFlow(false) val fullscreenVideoActive: StateFlow = _fullscreenVideoActive.asStateFlow() @@ -26,4 +27,23 @@ object PlayerHardwareController { fun notifyVolumeKey() { _volumeKeySignal.value = _volumeKeySignal.value + 1 } + + /** + * One step of the media volume, shown on the fullscreen player's own overlay when that is up + * and on the system's otherwise. False when the device has no audio service. + */ + fun adjustVolume( + context: Context, + raise: Boolean, + ): Boolean { + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return false + val inAppOverlay = fullscreenVideoActive.value && inAppVolumeOverlayEnabled.value + audioManager.adjustStreamVolume( + AudioManager.STREAM_MUSIC, + if (raise) AudioManager.ADJUST_RAISE else AudioManager.ADJUST_LOWER, + if (inAppOverlay) 0 else AudioManager.FLAG_SHOW_UI, + ) + if (inAppOverlay) notifyVolumeKey() + return true + } } From 8a64fb031b7f20d68bec3dc129c0509fcf90a002 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:46:22 +0300 Subject: [PATCH 2/7] feat(player): add a media keyboard shortcut mapper --- .../shared/MediaKeyboardShortcuts.kt | 145 ++++++++++++++++++ .../components/shared/MediaKeyMapperTest.kt | 76 +++++++++ 2 files changed, 221 insertions(+) create mode 100644 app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaKeyboardShortcuts.kt create mode 100644 app/src/test/java/io/github/aedev/flow/ui/components/shared/MediaKeyMapperTest.kt diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaKeyboardShortcuts.kt b/app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaKeyboardShortcuts.kt new file mode 100644 index 0000000000..d4bf64d28e --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaKeyboardShortcuts.kt @@ -0,0 +1,145 @@ +package io.github.aedev.flow.ui.components.shared + +import android.content.res.Configuration +import android.view.KeyEvent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusTarget +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isAltPressed +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.nativeKeyCode +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalConfiguration + +/** What a player does for a key on a physical keyboard. */ +sealed interface MediaKeyAction { + data object TogglePlayback : MediaKeyAction + + data object SeekBack : MediaKeyAction + + data object SeekForward : MediaKeyAction + + data object VolumeUp : MediaKeyAction + + data object VolumeDown : MediaKeyAction + + data object ToggleFullscreen : MediaKeyAction + + data object ExitFullscreen : MediaKeyAction + + data object ToggleCaptions : MediaKeyAction + + data object Next : MediaKeyAction + + data object Previous : MediaKeyAction + + data object SpeedUp : MediaKeyAction + + data object SpeedDown : MediaKeyAction + + /** Jump to [tenths] × 10 % of the video. */ + data class JumpTo( + val tenths: Int, + ) : MediaKeyAction + + /** Held keys repeat only the actions that step a value; a held Space must not flicker play and pause. */ + val repeats: Boolean + get() = this is SeekBack || this is SeekForward || this is VolumeUp || this is VolumeDown || this is SpeedUp || this is SpeedDown +} + +/** + * The keys Flow Desktop and YouTube use. Keys held with Ctrl, Alt or Meta stay with the system, and + * the media keys are left to the media session. + */ +object MediaKeyMapper { + fun map( + keyCode: Int, + shift: Boolean, + otherModifier: Boolean, + ): MediaKeyAction? { + if (otherModifier) return null + if (shift) { + return when (keyCode) { + KeyEvent.KEYCODE_N -> MediaKeyAction.Next + KeyEvent.KEYCODE_P -> MediaKeyAction.Previous + KeyEvent.KEYCODE_PERIOD -> MediaKeyAction.SpeedUp + KeyEvent.KEYCODE_COMMA -> MediaKeyAction.SpeedDown + else -> null + } + } + return when (keyCode) { + KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_K -> MediaKeyAction.TogglePlayback + KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_J -> MediaKeyAction.SeekBack + KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_L -> MediaKeyAction.SeekForward + KeyEvent.KEYCODE_DPAD_UP -> MediaKeyAction.VolumeUp + KeyEvent.KEYCODE_DPAD_DOWN -> MediaKeyAction.VolumeDown + KeyEvent.KEYCODE_F -> MediaKeyAction.ToggleFullscreen + KeyEvent.KEYCODE_ESCAPE -> MediaKeyAction.ExitFullscreen + KeyEvent.KEYCODE_C -> MediaKeyAction.ToggleCaptions + in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9 -> MediaKeyAction.JumpTo(keyCode - KeyEvent.KEYCODE_0) + in KeyEvent.KEYCODE_NUMPAD_0..KeyEvent.KEYCODE_NUMPAD_9 -> MediaKeyAction.JumpTo(keyCode - KeyEvent.KEYCODE_NUMPAD_0) + else -> null + } + } +} + +/** The next preset in [options] above or below [current], or null at either end. */ +fun stepPlaybackSpeed( + options: List, + current: Float, + faster: Boolean, +): Float? = if (faster) options.firstOrNull { it > current + SPEED_EPSILON } else options.lastOrNull { it < current - SPEED_EPSILON } + +private const val SPEED_EPSILON = 0.001f + +/** + * Runs [onAction] for the keys of a physical keyboard while [enabled] and a hardware keyboard is + * attached. Compose sends a key only into the focused subtree and nothing in a player holds focus on + * a touch screen, so this takes focus when it turns on, as a bare focus target that TalkBack skips. + * Keys reach it after the focused child, so a focused button or text field keeps its own, and an + * action [onAction] reports as not applied falls through. + */ +@Composable +fun Modifier.mediaKeyboardShortcuts( + enabled: Boolean, + onAction: (MediaKeyAction) -> Boolean, +): Modifier { + val configuration = LocalConfiguration.current + val active = + enabled && + configuration.keyboard == Configuration.KEYBOARD_QWERTY && + configuration.hardKeyboardHidden == Configuration.HARDKEYBOARDHIDDEN_NO + val focusRequester = remember { FocusRequester() } + LaunchedEffect(active) { + if (active) focusRequester.requestFocus() + } + if (!active) return this + return focusRequester(focusRequester) + .focusTarget() + .onKeyEvent { event -> + val action = + MediaKeyMapper.map( + keyCode = event.key.nativeKeyCode, + shift = event.isShiftPressed, + otherModifier = event.isCtrlPressed || event.isAltPressed || event.isMetaPressed, + ) ?: return@onKeyEvent false + when (event.type) { + KeyEventType.KeyDown -> { + if (event.nativeKeyEvent.repeatCount > 0 && !action.repeats) true else onAction(action) + } + + else -> { + false + } + } + } +} diff --git a/app/src/test/java/io/github/aedev/flow/ui/components/shared/MediaKeyMapperTest.kt b/app/src/test/java/io/github/aedev/flow/ui/components/shared/MediaKeyMapperTest.kt new file mode 100644 index 0000000000..4073c0c968 --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/ui/components/shared/MediaKeyMapperTest.kt @@ -0,0 +1,76 @@ +package io.github.aedev.flow.ui.components.shared + +import android.view.KeyEvent +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class MediaKeyMapperTest { + private fun map( + keyCode: Int, + shift: Boolean = false, + otherModifier: Boolean = false, + ) = MediaKeyMapper.map(keyCode, shift, otherModifier) + + @Test + fun `desktop keys map to player actions`() { + assertThat(map(KeyEvent.KEYCODE_SPACE)).isEqualTo(MediaKeyAction.TogglePlayback) + assertThat(map(KeyEvent.KEYCODE_K)).isEqualTo(MediaKeyAction.TogglePlayback) + assertThat(map(KeyEvent.KEYCODE_J)).isEqualTo(MediaKeyAction.SeekBack) + assertThat(map(KeyEvent.KEYCODE_DPAD_LEFT)).isEqualTo(MediaKeyAction.SeekBack) + assertThat(map(KeyEvent.KEYCODE_L)).isEqualTo(MediaKeyAction.SeekForward) + assertThat(map(KeyEvent.KEYCODE_DPAD_RIGHT)).isEqualTo(MediaKeyAction.SeekForward) + assertThat(map(KeyEvent.KEYCODE_DPAD_UP)).isEqualTo(MediaKeyAction.VolumeUp) + assertThat(map(KeyEvent.KEYCODE_DPAD_DOWN)).isEqualTo(MediaKeyAction.VolumeDown) + assertThat(map(KeyEvent.KEYCODE_F)).isEqualTo(MediaKeyAction.ToggleFullscreen) + assertThat(map(KeyEvent.KEYCODE_ESCAPE)).isEqualTo(MediaKeyAction.ExitFullscreen) + assertThat(map(KeyEvent.KEYCODE_C)).isEqualTo(MediaKeyAction.ToggleCaptions) + } + + @Test + fun `shifted keys step the queue and the speed`() { + assertThat(map(KeyEvent.KEYCODE_N, shift = true)).isEqualTo(MediaKeyAction.Next) + assertThat(map(KeyEvent.KEYCODE_P, shift = true)).isEqualTo(MediaKeyAction.Previous) + assertThat(map(KeyEvent.KEYCODE_PERIOD, shift = true)).isEqualTo(MediaKeyAction.SpeedUp) + assertThat(map(KeyEvent.KEYCODE_COMMA, shift = true)).isEqualTo(MediaKeyAction.SpeedDown) + assertThat(map(KeyEvent.KEYCODE_N)).isNull() + assertThat(map(KeyEvent.KEYCODE_SPACE, shift = true)).isNull() + } + + @Test + fun `digits jump to tenths on both rows`() { + assertThat(map(KeyEvent.KEYCODE_0)).isEqualTo(MediaKeyAction.JumpTo(0)) + assertThat(map(KeyEvent.KEYCODE_7)).isEqualTo(MediaKeyAction.JumpTo(7)) + assertThat(map(KeyEvent.KEYCODE_NUMPAD_9)).isEqualTo(MediaKeyAction.JumpTo(9)) + } + + @Test + fun `system and media keys are left alone`() { + assertThat(map(KeyEvent.KEYCODE_SPACE, otherModifier = true)).isNull() + assertThat(map(KeyEvent.KEYCODE_C, otherModifier = true)).isNull() + assertThat(map(KeyEvent.KEYCODE_TAB)).isNull() + assertThat(map(KeyEvent.KEYCODE_ENTER)).isNull() + assertThat(map(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE)).isNull() + assertThat(map(KeyEvent.KEYCODE_M)).isNull() + } + + @Test + fun `only stepping actions repeat while held`() { + assertThat(MediaKeyAction.SeekForward.repeats).isTrue() + assertThat(MediaKeyAction.VolumeDown.repeats).isTrue() + assertThat(MediaKeyAction.SpeedUp.repeats).isTrue() + assertThat(MediaKeyAction.TogglePlayback.repeats).isFalse() + assertThat(MediaKeyAction.ToggleFullscreen.repeats).isFalse() + assertThat(MediaKeyAction.JumpTo(3).repeats).isFalse() + } + + @Test + fun `speed steps to the neighbouring preset and stops at the ends`() { + val options = listOf(0.5f, 1f, 1.5f, 2f) + assertThat(stepPlaybackSpeed(options, 1f, faster = true)).isEqualTo(1.5f) + assertThat(stepPlaybackSpeed(options, 1f, faster = false)).isEqualTo(0.5f) + assertThat(stepPlaybackSpeed(options, 1.2f, faster = true)).isEqualTo(1.5f) + assertThat(stepPlaybackSpeed(options, 1.2f, faster = false)).isEqualTo(1f) + assertThat(stepPlaybackSpeed(options, 2f, faster = true)).isNull() + assertThat(stepPlaybackSpeed(options, 0.5f, faster = false)).isNull() + } +} From 9cb5cd16885005e48cc8b85b5b88d3f82949c093 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:46:22 +0300 Subject: [PATCH 3/7] feat(player): keyboard shortcuts in the video player --- .../player/stage/VideoStageControls.kt | 5 +- .../player/stage/VideoStageKeyboard.kt | 136 ++++++++++++++++++ 2 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/io/github/aedev/flow/ui/screens/player/stage/VideoStageKeyboard.kt diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/player/stage/VideoStageControls.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/player/stage/VideoStageControls.kt index e0552b3768..eae65e625c 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/player/stage/VideoStageControls.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/player/stage/VideoStageControls.kt @@ -247,7 +247,10 @@ internal fun VideoStageControls( state = controlsState, actions = controlsActions, currentPosition = { screenState.currentPosition }, - modifier = Modifier.placedWhen(expandedSurfacesPlaced), + modifier = + Modifier + .placedWhen(expandedSurfacesPlaced) + .videoStageKeyboardShortcuts(session, controlsActions, controlsState.hasPrevious, controlsState.hasNext), bufferedPercentage = bufferedFraction, windowInsets = WindowInsets(0, 0, 0, 0), isLayerPlaced = expandedSurfacesPlaced, diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/player/stage/VideoStageKeyboard.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/player/stage/VideoStageKeyboard.kt new file mode 100644 index 0000000000..69f2d6c85c --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/player/stage/VideoStageKeyboard.kt @@ -0,0 +1,136 @@ +package io.github.aedev.flow.ui.screens.player.stage + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.media3.common.util.UnstableApi +import io.github.aedev.flow.data.local.PlayerPreferences +import io.github.aedev.flow.player.EnhancedPlayerManager +import io.github.aedev.flow.player.PlayerHardwareController +import io.github.aedev.flow.ui.components.shared.MediaKeyAction +import io.github.aedev.flow.ui.components.shared.mediaKeyboardShortcuts +import io.github.aedev.flow.ui.components.shared.playbackSpeedOptions +import io.github.aedev.flow.ui.components.shared.stepPlaybackSpeed +import io.github.aedev.flow.ui.components.videoplayer.controls.PlayerControlActions +import io.github.aedev.flow.ui.screens.player.saveChosenPlaybackSpeed +import io.github.aedev.flow.ui.screens.player.state.PlayerSheet + +/** + * Keyboard shortcuts for the expanded video player, through the same actions as its buttons. They + * stand down under the sheets that hold a text field, which draw inside the player's window. + */ +@UnstableApi +@Composable +internal fun Modifier.videoStageKeyboardShortcuts( + session: VideoPlayerStageSession, + actions: PlayerControlActions, + hasPrevious: Boolean, + hasNext: Boolean, +): Modifier { + val screenState = session.screenState + val playerState = session.playerState + val playerPreferences = remember(session.context) { PlayerPreferences(session.context) } + val customSpeedsEnabled by playerPreferences.customSpeedsEnabled.collectAsState(initial = false) + val customSpeedPresets by playerPreferences.customSpeedPresets.collectAsState(initial = "") + val sheet = screenState.activeSheet + val enabled = !screenState.isTouchLocked && sheet != PlayerSheet.Transcript && sheet != PlayerSheet.SleepTimer + + fun seekBy(forward: Boolean) { + val manager = EnhancedPlayerManager.getInstance() + val player = manager.getPlayer() + val step = session.prefs.doubleTapSeekSeconds * 1000L + val position = player?.currentPosition ?: screenState.currentPosition + val target = if (forward) (position + step).coerceAtMost(screenState.duration) else (position - step).coerceAtLeast(0L) + if (playerState.isLive || player?.isCurrentMediaItemLive == true) manager.seekToLiveTimeline(target) else manager.seekTo(target) + val stepSeconds = session.prefs.doubleTapSeekSeconds + val continuing = if (forward) screenState.showSeekForwardAnimation else screenState.showSeekBackAnimation + screenState.seekAccumulation = if (continuing) screenState.seekAccumulation + stepSeconds else stepSeconds + screenState.showSeekForwardAnimation = forward + screenState.showSeekBackAnimation = !forward + } + + fun stepSpeed(faster: Boolean): Boolean { + val options = playbackSpeedOptions(customSpeedsEnabled, customSpeedPresets) + val speed = stepPlaybackSpeed(options, playerState.playbackSpeed, faster) ?: return false + EnhancedPlayerManager.getInstance().setPlaybackSpeed(speed) + screenState.normalSpeed = speed + screenState.speedSwipeLevel = speed + screenState.showSpeedSwipeOverlay = true + session.scope.saveChosenPlaybackSpeed( + playerPreferences = playerPreferences, + speed = speed, + rememberPlaybackSpeed = session.prefs.rememberPlaybackSpeed, + channelId = session.uiState.cachedVideo?.channelId, + ) + return true + } + + return mediaKeyboardShortcuts(enabled) { action -> + screenState.onInteraction() + when (action) { + MediaKeyAction.TogglePlayback -> { + screenState.showControls = true + actions.onPlayPause() + true + } + + MediaKeyAction.SeekBack, MediaKeyAction.SeekForward -> { + val seekable = playerState.isLive || screenState.duration > 0 + if (seekable) seekBy(forward = action == MediaKeyAction.SeekForward) + seekable + } + + MediaKeyAction.VolumeUp -> { + PlayerHardwareController.adjustVolume(session.context, raise = true) + } + + MediaKeyAction.VolumeDown -> { + PlayerHardwareController.adjustVolume(session.context, raise = false) + } + + MediaKeyAction.ToggleFullscreen -> { + actions.onFullscreenClick() + true + } + + MediaKeyAction.ExitFullscreen -> { + screenState.isFullscreen.also { if (it) actions.onFullscreenClick() } + } + + MediaKeyAction.ToggleCaptions -> { + val hasCaptions = playerState.availableSubtitles.isNotEmpty() || playerState.selectedSubtitleUrl != null + if (hasCaptions) actions.onSubtitleClick() + hasCaptions + } + + MediaKeyAction.Next -> { + if (hasNext) actions.onNext() + hasNext + } + + MediaKeyAction.Previous -> { + if (hasPrevious) actions.onPrevious() + hasPrevious + } + + MediaKeyAction.SpeedUp -> { + stepSpeed(faster = true) + } + + MediaKeyAction.SpeedDown -> { + stepSpeed(faster = false) + } + + is MediaKeyAction.JumpTo -> { + val canJump = !playerState.isLive && screenState.duration > 0 + if (canJump) { + screenState.showControls = true + EnhancedPlayerManager.getInstance().seekTo(screenState.duration * action.tenths / 10) + } + canJump + } + } + } +} From 2c2df8b4c20f8c1d9c71bf68960acfd5dcdcb31c Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:46:22 +0300 Subject: [PATCH 4/7] feat(music): keyboard shortcuts in the music player --- .../full/FullMusicPlayerContent.kt | 2 +- .../musicplayer/full/MusicPlayerKeyboard.kt | 71 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/full/MusicPlayerKeyboard.kt diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/full/FullMusicPlayerContent.kt b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/full/FullMusicPlayerContent.kt index 44acb58208..278c1ec4cc 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/full/FullMusicPlayerContent.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/full/FullMusicPlayerContent.kt @@ -217,7 +217,7 @@ internal fun FullMusicPlayerContent( } BoxWithConstraints( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().musicPlayerKeyboardShortcuts(isPlayerSheetExpanded && !showSleepTimer, viewModel), ) { val navBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/full/MusicPlayerKeyboard.kt b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/full/MusicPlayerKeyboard.kt new file mode 100644 index 0000000000..60ee298503 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/components/musicplayer/full/MusicPlayerKeyboard.kt @@ -0,0 +1,71 @@ +package io.github.aedev.flow.ui.components.musicplayer.full + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import io.github.aedev.flow.data.local.PlayerPreferences +import io.github.aedev.flow.player.PlayerHardwareController +import io.github.aedev.flow.ui.components.shared.MediaKeyAction +import io.github.aedev.flow.ui.components.shared.mediaKeyboardShortcuts +import io.github.aedev.flow.ui.screens.music.MusicPlayerViewModel + +/** Keyboard shortcuts for the open music player. Seeks step by the double-tap seek setting, as on video. */ +@Composable +internal fun Modifier.musicPlayerKeyboardShortcuts( + enabled: Boolean, + viewModel: MusicPlayerViewModel, +): Modifier { + val context = LocalContext.current + val playerPreferences = remember(context) { PlayerPreferences(context) } + val seekSeconds by playerPreferences.doubleTapSeekSeconds.collectAsState(initial = 10) + + return mediaKeyboardShortcuts(enabled) { action -> + val duration = viewModel.uiState.value.duration + when (action) { + MediaKeyAction.TogglePlayback -> { + viewModel.togglePlayPause() + true + } + + MediaKeyAction.SeekBack -> { + viewModel.seekTo(viewModel.currentPositionMs.value - seekSeconds * 1000L) + true + } + + MediaKeyAction.SeekForward -> { + viewModel.seekTo(viewModel.currentPositionMs.value + seekSeconds * 1000L) + true + } + + MediaKeyAction.VolumeUp -> { + PlayerHardwareController.adjustVolume(context, raise = true) + } + + MediaKeyAction.VolumeDown -> { + PlayerHardwareController.adjustVolume(context, raise = false) + } + + MediaKeyAction.Next -> { + viewModel.skipToNext() + true + } + + MediaKeyAction.Previous -> { + viewModel.skipToPrevious() + true + } + + is MediaKeyAction.JumpTo -> { + if (duration > 0) viewModel.seekTo(duration * action.tenths / 10) + duration > 0 + } + + else -> { + false + } + } + } +} From ed2034b33dd1224ed699b1745cd5db1bda5b6a7a Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:46:22 +0300 Subject: [PATCH 5/7] feat(shorts): keyboard shortcuts in Shorts --- .../flow/ui/screens/shorts/ShortsKeyboard.kt | 70 +++++++++++++++++++ .../flow/ui/screens/shorts/ShortsScreen.kt | 8 ++- 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/io/github/aedev/flow/ui/screens/shorts/ShortsKeyboard.kt diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/shorts/ShortsKeyboard.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/shorts/ShortsKeyboard.kt new file mode 100644 index 0000000000..b02cc45228 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/shorts/ShortsKeyboard.kt @@ -0,0 +1,70 @@ +package io.github.aedev.flow.ui.screens.shorts + +import androidx.compose.foundation.pager.PagerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import io.github.aedev.flow.data.local.PlayerPreferences +import io.github.aedev.flow.player.shorts.ShortsPlayerPool +import io.github.aedev.flow.ui.components.shared.MediaKeyAction +import io.github.aedev.flow.ui.components.shared.mediaKeyboardShortcuts +import kotlinx.coroutines.launch + +/** + * Keyboard shortcuts for the Shorts reel. Up and Down page between Shorts, as on the web, so the + * volume stays with the device's own keys here. + */ +@Composable +internal fun Modifier.shortsKeyboardShortcuts( + enabled: Boolean, + pagerState: PagerState, + shortIdAt: (Int) -> String?, + playerPreferences: PlayerPreferences, +): Modifier { + val scope = rememberCoroutineScope() + val seekSeconds by playerPreferences.doubleTapSeekSeconds.collectAsState(initial = 10) + + fun page(offset: Int): Boolean { + val target = pagerState.currentPage + offset + if (target !in 0 until pagerState.pageCount) return false + scope.launch { pagerState.animateScrollToPage(target) } + return true + } + + return mediaKeyboardShortcuts(enabled) { action -> + val pool = ShortsPlayerPool.getInstance() + val player = pool.ownedPlayer(pagerState.currentPage, shortIdAt(pagerState.currentPage)) + when (action) { + MediaKeyAction.TogglePlayback -> { + pool.togglePlayPause() + true + } + + MediaKeyAction.SeekBack, MediaKeyAction.SeekForward -> { + val step = if (action == MediaKeyAction.SeekForward) seekSeconds * 1000L else -seekSeconds * 1000L + player?.let { pool.seekTo((it.currentPosition + step).coerceIn(0L, it.duration.coerceAtLeast(0L))) } + player != null + } + + MediaKeyAction.VolumeUp, MediaKeyAction.Previous -> { + page(-1) + } + + MediaKeyAction.VolumeDown, MediaKeyAction.Next -> { + page(1) + } + + is MediaKeyAction.JumpTo -> { + val duration = player?.duration?.takeIf { it > 0 } + duration?.let { pool.seekTo(it * action.tenths / 10) } + duration != null + } + + else -> { + false + } + } + } +} diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/shorts/ShortsScreen.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/shorts/ShortsScreen.kt index ce6bab30f5..17a3f5fa5f 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/shorts/ShortsScreen.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/shorts/ShortsScreen.kt @@ -182,7 +182,13 @@ fun ShortsScreen( VerticalPager( state = pagerState, - modifier = Modifier.fillMaxSize(), + modifier = + Modifier.fillMaxSize().shortsKeyboardShortcuts( + enabled = !screenSheetOpen && !isInPip, + pagerState = pagerState, + shortIdAt = { uiState.shorts.getOrNull(it)?.id }, + playerPreferences = playerPreferences, + ), beyondViewportPageCount = 1, key = { uiState.shorts[it].id }, ) { page -> From 0a93aff59ac20c00608638ea0045d4b3c59aa8df Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:46:22 +0300 Subject: [PATCH 6/7] feat(player): list the shortcuts in the keyboard helper --- .../java/io/github/aedev/flow/MainActivity.kt | 12 +++++++ .../platform/PlayerKeyboardShortcutHelp.kt | 36 +++++++++++++++++++ app/src/main/res/values/strings.xml | 9 +++++ 3 files changed, 57 insertions(+) create mode 100644 app/src/main/java/io/github/aedev/flow/platform/PlayerKeyboardShortcutHelp.kt diff --git a/app/src/main/java/io/github/aedev/flow/MainActivity.kt b/app/src/main/java/io/github/aedev/flow/MainActivity.kt index ae83038ddd..d347180d55 100644 --- a/app/src/main/java/io/github/aedev/flow/MainActivity.kt +++ b/app/src/main/java/io/github/aedev/flow/MainActivity.kt @@ -9,6 +9,8 @@ import android.os.Bundle import android.os.PowerManager import android.util.Log import android.view.KeyEvent +import android.view.KeyboardShortcutGroup +import android.view.Menu import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.SystemBarStyle @@ -42,6 +44,7 @@ import io.github.aedev.flow.platform.AppIconController import io.github.aedev.flow.platform.AppUiMode import io.github.aedev.flow.platform.AppUiRoot import io.github.aedev.flow.platform.DeviceFormFactorDetector +import io.github.aedev.flow.platform.PlayerKeyboardShortcutHelp import io.github.aedev.flow.player.BackgroundPlaybackPolicy import io.github.aedev.flow.player.EnhancedPlayerManager import io.github.aedev.flow.player.GlobalPlayerState @@ -516,6 +519,15 @@ class MainActivity : ComponentActivity() { return super.onKeyDown(keyCode, event) } + override fun onProvideKeyboardShortcuts( + data: MutableList, + menu: Menu?, + deviceId: Int, + ) { + super.onProvideKeyboardShortcuts(data, menu, deviceId) + data += PlayerKeyboardShortcutHelp.group(this) + } + override fun onKeyUp( keyCode: Int, event: KeyEvent, diff --git a/app/src/main/java/io/github/aedev/flow/platform/PlayerKeyboardShortcutHelp.kt b/app/src/main/java/io/github/aedev/flow/platform/PlayerKeyboardShortcutHelp.kt new file mode 100644 index 0000000000..7e959d424c --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/platform/PlayerKeyboardShortcutHelp.kt @@ -0,0 +1,36 @@ +package io.github.aedev.flow.platform + +import android.content.Context +import android.view.KeyEvent +import android.view.KeyboardShortcutGroup +import android.view.KeyboardShortcutInfo +import io.github.aedev.flow.R + +/** The player's keys as the system lists them under Meta + /, matching [io.github.aedev.flow.ui.components.shared.MediaKeyMapper]. */ +object PlayerKeyboardShortcutHelp { + fun group(context: Context): KeyboardShortcutGroup { + fun key( + label: Int, + keyCode: Int, + modifiers: Int = 0, + ) = KeyboardShortcutInfo(context.getString(label), keyCode, modifiers) + + return KeyboardShortcutGroup( + context.getString(R.string.keyboard_shortcuts_player), + listOf( + key(R.string.keyboard_shortcut_play_pause, KeyEvent.KEYCODE_SPACE), + key(R.string.keyboard_shortcut_seek_back, KeyEvent.KEYCODE_DPAD_LEFT), + key(R.string.keyboard_shortcut_seek_forward, KeyEvent.KEYCODE_DPAD_RIGHT), + key(R.string.keyboard_shortcut_volume_up, KeyEvent.KEYCODE_DPAD_UP), + key(R.string.keyboard_shortcut_volume_down, KeyEvent.KEYCODE_DPAD_DOWN), + key(R.string.fullscreen, KeyEvent.KEYCODE_F), + key(R.string.keyboard_shortcut_exit_fullscreen, KeyEvent.KEYCODE_ESCAPE), + key(R.string.captions, KeyEvent.KEYCODE_C), + key(R.string.next, KeyEvent.KEYCODE_N, KeyEvent.META_SHIFT_ON), + key(R.string.previous, KeyEvent.KEYCODE_P, KeyEvent.META_SHIFT_ON), + key(R.string.keyboard_shortcut_faster, KeyEvent.KEYCODE_PERIOD, KeyEvent.META_SHIFT_ON), + key(R.string.keyboard_shortcut_slower, KeyEvent.KEYCODE_COMMA, KeyEvent.META_SHIFT_ON), + ), + ) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 484956e4a8..d162f14fa7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4347,4 +4347,13 @@ Could not read your top artists Discover with Last.fm Picked by Last.fm listeners from your on repeat + Player + Play or pause + Back + Forward + Volume up + Volume down + Exit fullscreen + Faster + Slower From 965378dfbd34f74174014d4711641aac86071d0e Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:46:22 +0300 Subject: [PATCH 7/7] docs(nightly): note keyboard shortcuts on tablets --- .../nightly-notes/2026-10-22-feat-player-keyboard-shortcuts.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .github/nightly-notes/2026-10-22-feat-player-keyboard-shortcuts.md diff --git a/.github/nightly-notes/2026-10-22-feat-player-keyboard-shortcuts.md b/.github/nightly-notes/2026-10-22-feat-player-keyboard-shortcuts.md new file mode 100644 index 0000000000..461c78df25 --- /dev/null +++ b/.github/nightly-notes/2026-10-22-feat-player-keyboard-shortcuts.md @@ -0,0 +1,2 @@ +## New +- Keyboard shortcuts in the video, music and Shorts players for tablets with a keyboard: Space, arrows, J, K, L, F, C and more (#1401)