mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
d4e9628199
commit
f0402e74f9
5 files changed
+84
No files matched your search
@@ -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 |
@@ -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
|
||||
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
|
||||
|
||||
Install JDK 17, Android SDK Platform/Build Tools 36, NDK
|
||||
|
||||
Executable
+21
@@ -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"
|
||||
Reference in new issue
Block a user