feat(android): install on Android TV, and take a remote's keys in the player

The app is listed in the TV launcher with a banner, and neither a touch
screen nor leanback is required. While the controls are down the player
takes D-pad left and right as a ten-second seek, the middle button as
bringing the controls up, and play/pause and space as they say.
This commit is contained in:
nvrl committed 2026-10-13 21:37:40 +02:00
1 parent d4e9628199
commit f0402e74f9
5 files changed
+84

No files matched your search

+9
View File
@@ -7,6 +7,13 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<!--
Android TV: listed in its launcher, and installable without a touch
screen. Neither is required, so phones are offered the same build.
-->
<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<application
android:name=".ProtonStreamApplication"
android:allowBackup="false"
@@ -14,6 +21,7 @@
android:enableOnBackInvokedCallback="true"
tools:targetApi="33"
android:icon="@mipmap/ic_launcher"
android:banner="@drawable/tv_banner"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.ProtonStream">
@@ -39,6 +47,7 @@
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<!--
Proton Drive public links. autoVerify="false", said explicitly:
@@ -68,6 +68,14 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.foundation.focusable
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.semantics
@@ -444,12 +452,46 @@ fun PlayerScreen(
}
}
val current by rememberUpdatedState(state)
// Where a remote's keys land while the controls are down. Once they are
// up, the D-pad moves between their buttons as it does anywhere else.
val keys = remember { FocusRequester() }
LaunchedEffect(showControls) { if (!showControls) runCatching { keys.requestFocus() } }
if (minimized) return
Box(
Modifier
.fillMaxSize()
.background(Color.Black)
// A TV remote, a keyboard: left and right seek as a double tap
// does, the middle button brings the controls up, and play/pause
// and space do what they say.
.focusRequester(keys)
.onKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onKeyEvent false
when (event.key) {
Key.DirectionLeft, Key.DirectionRight -> {
if (showControls) return@onKeyEvent false
val seconds = if (event.key == Key.DirectionLeft) -DOUBLE_TAP_SECONDS else DOUBLE_TAP_SECONDS
host.seek((current.position + seconds).coerceAtLeast(0.0))
nudge = seconds to (nudge?.second ?: 0) + 1
true
}
Key.DirectionCenter, Key.Enter, Key.NumPadEnter, Key.DirectionUp, Key.DirectionDown -> {
if (showControls) return@onKeyEvent false
showControls = true
true
}
Key.MediaPlayPause, Key.Spacebar -> {
host.setPaused(!current.paused)
showControls = true
true
}
Key.MediaPlay -> { host.setPaused(false); true }
Key.MediaPause -> { host.setPaused(true); true }
else -> false
}
}
.focusable()
// Double-tap a side to seek ten seconds, as every phone video
// player has taught; the middle plays or pauses. A single tap
// waits out the double-tap timeout, which is the price of both.
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB