mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 08:56:59 +01:00
improved android client, feature parity with desktop
This commit is contained in:
1 parent
352fdf1933
commit
f1b3ad9637
56 files changed
+9727
-1799
No files matched your search
@@ -1,72 +1,151 @@
|
||||
package io.narl.protonstream
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.os.Build
|
||||
import android.app.PendingIntent
|
||||
import android.app.PictureInPictureParams
|
||||
import android.app.RemoteAction
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.ServiceConnection
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Icon
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.util.Rational
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import io.narl.protonstream.ui.ProtonStreamApp
|
||||
import io.narl.protonstream.ui.theme.ProtonStreamTheme
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import io.narl.protonstream.playback.MpvPlaybackState
|
||||
import io.narl.protonstream.playback.NativeMpvHost
|
||||
import io.narl.protonstream.playback.PlaybackService
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
import io.narl.protonstream.ui.ProtonStreamApp
|
||||
import io.narl.protonstream.ui.theme.ProtonStreamTheme
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val playerHost = mutableStateOf<NativeMpvHost?>(null)
|
||||
private val inPictureInPicture = mutableStateOf(false)
|
||||
/**
|
||||
* Whether a bind is outstanding, which is what has to be unbound.
|
||||
*
|
||||
* Set when `bindService` is called rather than when the connection arrives:
|
||||
* an activity destroyed while the bind is still pending owes the system an
|
||||
* `unbindService` just the same, and `onServiceConnected` may never run.
|
||||
*/
|
||||
private var bound = false
|
||||
private val playbackConnection = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
playerHost.value = (service as PlaybackService.PlaybackBinder).host
|
||||
bound = true
|
||||
}
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
playerHost.value = null
|
||||
bound = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The transport behind the buttons Android draws over the PiP window.
|
||||
*
|
||||
* A broadcast rather than a service intent: PiP is one of the states in
|
||||
* which a background service start is refused, and the receiver is
|
||||
* registered for as long as the activity that owns the window exists.
|
||||
*/
|
||||
private val pictureInPictureControls = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
val host = playerHost.value ?: return
|
||||
when (intent?.getStringExtra(EXTRA_CONTROL)) {
|
||||
CONTROL_PLAY -> host.setPaused(false)
|
||||
CONTROL_PAUSE -> host.setPaused(true)
|
||||
CONTROL_NEXT -> host.onSkipNext?.invoke()
|
||||
CONTROL_PREVIOUS -> host.onSkipPrevious?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val notificationPermission = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { /* Downloads remain usable; Android suppresses their notifications if denied. */ }
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
bindService(Intent(this, PlaybackService::class.java), playbackConnection, Context.BIND_AUTO_CREATE)
|
||||
bound = bindService(
|
||||
Intent(this, PlaybackService::class.java),
|
||||
playbackConnection,
|
||||
Context.BIND_AUTO_CREATE,
|
||||
)
|
||||
ContextCompat.registerReceiver(
|
||||
this,
|
||||
pictureInPictureControls,
|
||||
IntentFilter(ACTION_PIP_CONTROL),
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
if (
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
// Auto-enter has to be armed *before* the gesture that would use it:
|
||||
// building the params inside onUserLeaveHint is too late for the swipe
|
||||
// home that gesture navigation sends, which never calls it. Re-register
|
||||
// whenever the picture's shape or the transport's does.
|
||||
lifecycleScope.launch {
|
||||
snapshotFlow { playerHost.value }
|
||||
.flatMapLatest { host -> host?.state ?: flowOf(null) }
|
||||
.map { state -> PictureInPictureShape.of(state) }
|
||||
.distinctUntilChanged()
|
||||
.collect { shape -> runCatching { setPictureInPictureParams(shape.params()) } }
|
||||
}
|
||||
setContent {
|
||||
ProtonStreamTheme {
|
||||
ProtonStreamApp(playerHost.value)
|
||||
ProtonStreamApp(playerHost.value, inPictureInPicture.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUserLeaveHint() {
|
||||
super.onUserLeaveHint()
|
||||
// The backstop for launchers that still send this instead of arming
|
||||
// auto-enter. Entering twice is not an error; not entering at all is.
|
||||
if (playerHost.value?.hasActivePlayback == true && !isInPictureInPictureMode) {
|
||||
enterPictureInPictureMode(
|
||||
PictureInPictureParams.Builder().setAspectRatio(Rational(16, 9)).setAutoEnterEnabled(true).build(),
|
||||
)
|
||||
runCatching {
|
||||
enterPictureInPictureMode(PictureInPictureShape.of(playerHost.value?.state?.value).params())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
|
||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
|
||||
// The window is now a thumbnail with its own controls. Anything the
|
||||
// player would draw over it — the transport, the title bar, the skip
|
||||
// offer — is unreadable at that size and covers the picture.
|
||||
inPictureInPicture.value = isInPictureInPictureMode
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (bound) unbindService(playbackConnection)
|
||||
runCatching { unregisterReceiver(pictureInPictureControls) }
|
||||
if (bound) {
|
||||
unbindService(playbackConnection)
|
||||
bound = false
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@@ -80,4 +159,81 @@ class MainActivity : ComponentActivity() {
|
||||
playerHost.value?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
/** Everything the PiP window's shape depends on, and nothing that polls. */
|
||||
private data class PictureInPictureShape(
|
||||
val active: Boolean,
|
||||
val paused: Boolean,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
) {
|
||||
companion object {
|
||||
fun of(state: MpvPlaybackState?): PictureInPictureShape = PictureInPictureShape(
|
||||
active = state != null && state.duration > 0.0 && !state.ended,
|
||||
paused = state?.paused != false,
|
||||
width = state?.videoWidth?.toInt() ?: 0,
|
||||
height = state?.videoHeight?.toInt() ?: 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun PictureInPictureShape.params(): PictureInPictureParams =
|
||||
PictureInPictureParams.Builder()
|
||||
.setAspectRatio(aspectRatio(width, height))
|
||||
.setAutoEnterEnabled(active)
|
||||
.setSeamlessResizeEnabled(false)
|
||||
// The player owns the whole window, so the picture's rectangle is
|
||||
// the window's. Without it the transition into PiP cross-fades from
|
||||
// a screenshot instead of shrinking the video that is already there.
|
||||
.setSourceRectHint(windowBounds())
|
||||
.setActions(
|
||||
listOf(
|
||||
remoteAction(android.R.drawable.ic_media_previous, "Previous episode", CONTROL_PREVIOUS, 11),
|
||||
if (paused) remoteAction(android.R.drawable.ic_media_play, "Play", CONTROL_PLAY, 12)
|
||||
else remoteAction(android.R.drawable.ic_media_pause, "Pause", CONTROL_PAUSE, 13),
|
||||
remoteAction(android.R.drawable.ic_media_next, "Next episode", CONTROL_NEXT, 14),
|
||||
),
|
||||
)
|
||||
.build()
|
||||
|
||||
private fun windowBounds(): Rect = window.decorView.let { Rect(0, 0, it.width, it.height) }
|
||||
|
||||
private fun remoteAction(icon: Int, label: String, control: String, requestCode: Int): RemoteAction =
|
||||
RemoteAction(
|
||||
Icon.createWithResource(this, icon),
|
||||
label,
|
||||
label,
|
||||
PendingIntent.getBroadcast(
|
||||
this,
|
||||
requestCode,
|
||||
Intent(ACTION_PIP_CONTROL).setPackage(packageName).putExtra(EXTRA_CONTROL, control),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
),
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val ACTION_PIP_CONTROL = "io.narl.protonstream.PIP_CONTROL"
|
||||
const val EXTRA_CONTROL = "control"
|
||||
const val CONTROL_PLAY = "play"
|
||||
const val CONTROL_PAUSE = "pause"
|
||||
const val CONTROL_NEXT = "next"
|
||||
const val CONTROL_PREVIOUS = "previous"
|
||||
|
||||
/**
|
||||
* Android refuses a window narrower than 1:2.39 or taller than 2.39:1,
|
||||
* and refuses the request outright rather than clamping it — so a
|
||||
* 2.40:1 scope release would take PiP away entirely.
|
||||
*/
|
||||
fun aspectRatio(width: Int, height: Int): Rational {
|
||||
if (width <= 0 || height <= 0) return Rational(16, 9)
|
||||
val ratio = width.toDouble() / height
|
||||
return when {
|
||||
ratio > MAX_RATIO -> Rational(239, 100)
|
||||
ratio < 1 / MAX_RATIO -> Rational(100, 239)
|
||||
else -> Rational(width, height)
|
||||
}
|
||||
}
|
||||
|
||||
const val MAX_RATIO = 2.39
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user