Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)
41 changes: 16 additions & 25 deletions app/src/main/java/io/github/aedev/flow/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ 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
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
Expand Down Expand Up @@ -43,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
Expand Down Expand Up @@ -508,35 +510,24 @@ 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)
}

override fun onProvideKeyboardShortcuts(
data: MutableList<KeyboardShortcutGroup>,
menu: Menu?,
deviceId: Int,
) {
super.onProvideKeyboardShortcuts(data, menu, deviceId)
data += PlayerKeyboardShortcutHelp.group(this)
}

override fun onKeyUp(
keyCode: Int,
event: KeyEvent,
Expand Down
Original file line number Diff line number Diff line change
@@ -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),
),
)
}
}
Original file line number Diff line number Diff line change
@@ -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<Boolean> = _fullscreenVideoActive.asStateFlow()

Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Float>,
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
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading