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_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <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 <application
android:name=".ProtonStreamApplication" android:name=".ProtonStreamApplication"
android:allowBackup="false" android:allowBackup="false"
@@ -14,6 +21,7 @@
android:enableOnBackInvokedCallback="true" android:enableOnBackInvokedCallback="true"
tools:targetApi="33" tools:targetApi="33"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:banner="@drawable/tv_banner"
android:label="@string/app_name" android:label="@string/app_name"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.ProtonStream"> android:theme="@style/Theme.ProtonStream">
@@ -39,6 +47,7 @@
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter> </intent-filter>
<!-- <!--
Proton Drive public links. autoVerify="false", said explicitly: 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.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.foundation.gestures.detectTapGestures 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.input.pointer.pointerInput
import androidx.compose.ui.semantics.onClick import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
@@ -444,12 +452,46 @@ fun PlayerScreen(
} }
} }
val current by rememberUpdatedState(state) 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 if (minimized) return
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black) .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 // Double-tap a side to seek ten seconds, as every phone video
// player has taught; the middle plays or pauses. A single tap // player has taught; the middle plays or pauses. A single tap
// waits out the double-tap timeout, which is the price of both. // waits out the double-tap timeout, which is the price of both.
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+12
View File
@@ -7,6 +7,18 @@ screens, while UniFFI exposes the existing catalog, share, streaming, watch-stat
and offline logic. Keep reusable behavior in the Rust crates rather than and offline logic. Keep reusable behavior in the Rust crates rather than
reimplementing it in Kotlin. reimplementing it in Kotlin.
## Android TV
The same build installs on Android TV: it is listed in the TV launcher
(`LEANBACK_LAUNCHER`, with the banner `scripts/render-tv-banner.sh` renders)
and requires neither a touch screen nor leanback. Every screen is Compose
`clickable`s, which the D-pad already moves between. The player takes a
remote's keys while its controls are down: left and right seek ten seconds,
the middle button brings the controls up, and play/pause works as on a
keyboard. There is no TV-specific layout yet, and none of it has been run on a
TV. Signing in to an account, so that shares arrive through sync, is the way
to set one up without typing a share link on a remote.
## Toolchain ## Toolchain
Install JDK 17, Android SDK Platform/Build Tools 36, NDK Install JDK 17, Android SDK Platform/Build Tools 36, NDK
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Render the Android TV launcher banner, 320 by 180 dp, from the iOS icon: its
# canvas widened to 16:9 with the face gradient run across the whole width,
# and the play mark a little smaller, since a banner is shown as is rather
# than cut down by a mask. Twice the size, in drawable-nodpi: one file for
# every screen, which the launcher scales to its slot, sharp on a 4K one.
set -euo pipefail
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
icon="$script_dir/../ios/ProtonStream/Resources/AppIcon.svg"
out="$script_dir/../android/app/src/main/res/drawable-nodpi/tv_banner.png"
command -v rsvg-convert >/dev/null || { echo "rsvg-convert is required (librsvg)" >&2; exit 1; }
command -v magick >/dev/null || { echo "magick is required (ImageMagick)" >&2; exit 1; }
mkdir -p "$(dirname "$out")"
sed -e 's/width="1024" height="1024" viewBox="0 0 108 108"/width="320" height="180" viewBox="-42 0 192 108"/' \
-e 's/<rect width="108" height="108"/<rect x="-42" width="192" height="108"/' \
-e 's/x1="0" y1="0" x2="108" y2="108"/x1="-42" y1="0" x2="150" y2="108"/' \
-e 's/scale(1.5)/scale(1.15)/' \
"$icon" | rsvg-convert -w 640 -h 360 | magick - -alpha off -strip "$out"
echo "wrote $out"