improved android client, feature parity with desktop

This commit is contained in:
nvrl committed 2026-08-09 16:40:37 +02:00
1 parent 352fdf1933
commit f1b3ad9637
56 files changed
+9727 -1799

No files matched your search

@@ -1,343 +0,0 @@
package io.narl.protonstream.playback
import android.view.Surface
import android.view.SurfaceHolder
import android.view.SurfaceView
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.clickable
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.Fullscreen
import androidx.compose.material.icons.filled.FullscreenExit
import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material.icons.filled.VolumeOff
import androidx.compose.material.icons.filled.Subtitles
import androidx.compose.material.icons.filled.Audiotrack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import io.narl.protonstream.native.NativeRuntime
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import uniffi.pstr_android.AndroidEngine
import uniffi.pstr_android.AndroidStream
import uniffi.pstr_android.EpisodeRecord
import kotlin.math.roundToInt
/** Exact seekable stream contract a bundled libmpv JNI adapter must consume. */
class RustStreamSession(
private val episode: EpisodeRecord,
) {
private var engine: AndroidEngine? = null
private var stream: AndroidStream? = null
private var handle: ULong? = null
private var streamSize: ULong? = null
suspend fun open(): RustStreamSession = withContext(Dispatchers.IO) {
val openedEngine = NativeRuntime.engine()
engine = openedEngine
stream = openedEngine.openStream(episode.shareId, episode.volumeId, episode.linkId).also {
streamSize = it.size()
handle = it.nativeHandle()
}
this@RustStreamSession
}
fun nativeHandle(): ULong = checkNotNull(handle) { "stream is not published" }
fun size(): ULong = checkNotNull(streamSize) { "stream is not open" }
fun releaseNative(host: LibmpvHost) {
handle?.let(host::releaseNativeStream)
handle = null
streamSize = null
stream = null
}
/** The native player now owns the published token until stop/end/close. */
fun transferToPlayer() {
handle = null
streamSize = null
stream = null
}
suspend fun closeEngine() = withContext(Dispatchers.IO) {
engine?.releaseStream(episode.shareId, episode.volumeId, episode.linkId)
engine = null
}
}
/** Implement only in the variant that bundles a pinned libmpv native artifact. */
interface LibmpvHost {
fun attachSurface(surface: Surface)
fun detachSurface()
/** Consumes the opaque handle through pstr_android_stream_{read,size}. */
suspend fun play(
nativeHandle: ULong,
size: ULong,
startPosition: Double = 0.0,
audioLanguage: String? = null,
subtitleLanguage: String? = null,
subtitles: Boolean = true,
)
/** Calls pstr_android_stream_release exactly once for a published handle. */
fun releaseNativeStream(nativeHandle: ULong)
}
private data class PlaybackStartup(
val nativeHandle: ULong,
val size: ULong,
val startPosition: Double,
val audioLanguage: String?,
val subtitleLanguage: String?,
val subtitles: Boolean,
)
@Composable
fun LibmpvPlayerSurface(
titleKey: String,
episode: EpisodeRecord,
host: LibmpvHost? = null,
isFullscreen: Boolean = false,
onToggleFullscreen: () -> Unit = {}
) {
if (host == null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Playback is unavailable: this build does not include libmpv.", color = Color.White)
}
return
}
val scope = rememberCoroutineScope()
var playbackError by remember(sessionKey(episode)) { mutableStateOf<String?>(null) }
val session = remember(episode.shareId, episode.linkId) {
RustStreamSession(episode)
}
LaunchedEffect(session, host) {
runCatching {
val startup = withContext(Dispatchers.IO) {
session.open()
val engine = NativeRuntime.engine()
val preferences = engine.titleTrackPreferences(titleKey)
val watch = engine.watchState(episode.shareId, episode.linkId)
PlaybackStartup(
nativeHandle = session.nativeHandle(),
size = session.size(),
startPosition = watch?.positionSecs ?: 0.0,
audioLanguage = preferences.audioLanguage,
subtitleLanguage = preferences.subtitleLanguage,
subtitles = preferences.subtitles,
)
}
host.play(
startup.nativeHandle, startup.size, startup.startPosition,
startup.audioLanguage, startup.subtitleLanguage, startup.subtitles,
)
session.transferToPlayer()
}.onFailure { playbackError = it.message ?: "Playback failed" }
}
DisposableEffect(session, host) {
onDispose {
host.detachSurface()
session.releaseNative(host)
scope.launch { session.closeEngine() }
}
}
var showControls by remember { mutableStateOf(true) }
LaunchedEffect(showControls) {
if (showControls) {
delay(4000)
showControls = false
}
}
Box(Modifier.fillMaxSize().clickable(
interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() },
indication = null,
onClick = { showControls = !showControls }
)) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
SurfaceView(context).apply {
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) = host.attachSurface(holder.surface)
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) = host.attachSurface(holder.surface)
override fun surfaceDestroyed(holder: SurfaceHolder) = host.detachSurface()
})
}
},
)
AnimatedVisibility(
visible = showControls,
modifier = Modifier.align(Alignment.BottomCenter),
enter = fadeIn(),
exit = fadeOut()
) {
if (host is NativeMpvHost) PlayerControls(
titleKey,
episode,
host,
isFullscreen,
onToggleFullscreen
)
}
playbackError?.let { message ->
Text(message, color = Color.White, modifier = Modifier.align(Alignment.Center).background(Color.Black.copy(alpha = 0.8f)).padding(16.dp))
}
}
}
private fun sessionKey(episode: EpisodeRecord) = "${episode.shareId}:${episode.linkId}"
@Composable
private fun PlayerControls(
titleKey: String,
episode: EpisodeRecord,
host: NativeMpvHost,
isFullscreen: Boolean,
onToggleFullscreen: () -> Unit,
modifier: Modifier = Modifier
) {
val state by host.state.collectAsState()
val tracks by host.tracks.collectAsState()
val scope = rememberCoroutineScope()
var choosing by remember { mutableStateOf<String?>(null) }
LaunchedEffect(state.position.roundToInt() / 5) {
if (state.duration > 0.0) withContext(Dispatchers.IO) {
NativeRuntime.engine().saveWatchState(
episode.shareId, episode.linkId, state.position.coerceAtMost(state.duration), state.duration,
state.ended || state.position >= state.duration * 0.9,
)
}
}
Column(modifier.fillMaxWidth().background(Color.Black.copy(alpha = 0.68f)).padding(12.dp)) {
Slider(
value = state.position.toFloat().coerceIn(0f, state.duration.toFloat().coerceAtLeast(1f)),
onValueChange = { host.seek(it.toDouble()) },
valueRange = 0f..state.duration.toFloat().coerceAtLeast(1f),
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = { host.setPaused(!state.paused) }) {
Icon(if (state.paused) Icons.Default.PlayArrow else Icons.Default.Pause, contentDescription = "Play/Pause", tint = Color.White)
}
Text("${formatTime(state.position)} / ${formatTime(state.duration)}", color = Color.White)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
IconButton(onClick = { host.setMuted(!state.muted) }) {
Icon(if (state.muted) Icons.Default.VolumeOff else Icons.Default.VolumeUp, contentDescription = "Mute", tint = Color.White)
}
IconButton(onClick = onToggleFullscreen) {
Icon(if (isFullscreen) Icons.Default.FullscreenExit else Icons.Default.Fullscreen, contentDescription = "Fullscreen", tint = Color.White)
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = { choosing = "audio" }) {
Icon(Icons.Default.Audiotrack, contentDescription = "Audio", tint = Color.White)
}
IconButton(onClick = { choosing = "sub" }) {
Icon(Icons.Default.Subtitles, contentDescription = "Subtitles", tint = Color.White)
}
Text(
"Audio: ${tracks.firstOrNull { it.type == "audio" && it.selected }?.label ?: "default"} • " +
"Subtitles: ${tracks.firstOrNull { it.type == "sub" && it.selected }?.label ?: "off"}",
color = Color.White,
style = MaterialTheme.typography.bodySmall
)
}
}
choosing?.let { type ->
TrackChooser(
type = type,
tracks = tracks.filter { it.type == type },
onDismiss = { choosing = null },
onSelect = { selected ->
host.selectTrack(selected)
choosing = null
scope.launch(Dispatchers.IO) {
val engine = NativeRuntime.engine()
val old = engine.titleTrackPreferences(titleKey)
engine.setTitleTrackPreferences(
titleKey,
if (type == "audio") old.copy(audioLanguage = selected?.language?.takeIf(String::isNotBlank))
else old.copy(
subtitleLanguage = selected?.language?.takeIf(String::isNotBlank),
subtitles = selected != null,
),
)
}
},
)
}
}
@Composable
private fun TrackChooser(
type: String,
tracks: List<MpvTrack>,
onDismiss: () -> Unit,
onSelect: (MpvTrack?) -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(if (type == "audio") "Audio track" else "Subtitle track") },
text = {
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
if (type == "sub") {
item {
OutlinedButton(onClick = { onSelect(null) }, Modifier.fillMaxWidth()) {
Text(if (tracks.none(MpvTrack::selected)) "✓ Off" else "Off")
}
}
}
items(tracks, key = MpvTrack::id) { track ->
OutlinedButton(onClick = { onSelect(track) }, Modifier.fillMaxWidth()) {
Text(if (track.selected) "✓ ${track.label}" else track.label)
}
}
}
},
confirmButton = { Button(onClick = onDismiss) { Text("Close") } },
)
}
private fun formatTime(seconds: Double): String {
val total = seconds.coerceAtLeast(0.0).roundToInt()
return "%d:%02d".format(total / 60, total % 60)
}
@@ -1,6 +1,7 @@
package io.narl.protonstream.playback
import android.view.Surface
import io.narl.protonstream.ui.ThumbnailSource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -16,6 +17,31 @@ import kotlinx.coroutines.withContext
import org.json.JSONArray
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
import uniffi.pstr_android.ChapterPlan
import uniffi.pstr_android.ChapterRecord
import uniffi.pstr_android.chapterPlan
/**
* Why the open file ended, mirroring `pstr_player::EndReason` so both clients
* agree on what "finished" means.
*
* Only [Eof] is an episode watched to the end. Treating the others as one — a
* stop, a shutdown, a failed demux — is what marks an episode that never played
* as watched and autoplays past it.
*/
enum class EndReason {
None,
Eof,
Stopped,
Quit,
Failed,
Other,
;
companion object {
fun of(value: Double): EndReason = entries.getOrElse(value.toInt()) { Other }
}
}
data class MpvPlaybackState(
val position: Double = 0.0,
@@ -24,6 +50,53 @@ data class MpvPlaybackState(
val paused: Boolean = true,
val muted: Boolean = false,
val ended: Boolean = false,
/** Meaningful only while [ended]; [EndReason.None] otherwise. */
val endReason: EndReason = EndReason.None,
/**
* Which episode this reading belongs to, or null before the first poll of a
* newly loaded file. Everything that *writes* per-episode state — resume
* position above all — must check this: one host serves every episode, and
* a reading attributed to the wrong one resumes the next episode where the
* last one stopped.
*/
val media: String? = null,
/** The picture's display size, or zero until mpv has decoded enough to know. */
val videoWidth: Double = 0.0,
val videoHeight: Double = 0.0,
/**
* mpv has run out of buffered data and stopped to refill.
*
* Not the same as [paused], which is the viewer's doing. A frozen picture
* that nothing explains is indistinguishable from a hung app, which is what
* a network-backed stream looked like before this existed.
*/
val buffering: Boolean = false,
/** How full the demuxer cache is while [buffering], 0–100. */
val cachePercent: Double = 0.0,
/** A seek has been issued but playback has not resumed at the new position. */
val seeking: Boolean = false,
) {
/** Whether the picture is stopped for a reason the viewer did not choose. */
val stalled: Boolean get() = (buffering || seeking) && !paused && !ended
}
/**
* What is on screen, for the surfaces that are not the player.
*
* The media session, its notification and Picture-in-Picture all have to name
* the episode, and none of them can reach the composition that knows it. The
* player publishes this once per episode and they read it.
*/
data class NowPlaying(
val media: String,
val title: String,
val show: String?,
val detail: String?,
val artworkUrl: String?,
/** The file to pull a Proton thumbnail from when the provider has no poster. */
val artworkFile: ThumbnailSource?,
val hasPrevious: Boolean,
val hasNext: Boolean,
)
data class MpvTrack(
@@ -38,21 +111,61 @@ data class MpvTrack(
}
/** Process-local owner of the libmpv core. PlaybackService owns its lifetime. */
class NativeMpvHost : LibmpvHost, AutoCloseable {
private val nativeHandle = nativeCreate().also { check(it != 0L) { "libmpv initialization failed" } }
class NativeMpvHost private constructor(private val nativeHandle: Long) : LibmpvHost, AutoCloseable {
private val lifecycle = ReentrantLock()
@Volatile private var closed = false
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val mutableState = MutableStateFlow(MpvPlaybackState())
private val mutableTracks = MutableStateFlow(emptyList<MpvTrack>())
private val mutableChapters = MutableStateFlow(ChapterPlan(emptyList(), null))
private val mutableNowPlaying = MutableStateFlow<NowPlaying?>(null)
private val mutableAdvancing = MutableStateFlow(false)
private val mutableProblem = MutableStateFlow<String?>(null)
private var poller: Job? = null
private var currentStreamHandle: Long? = null
/** The episode key and load generation the native player currently holds. */
@Volatile private var currentMedia: String? = null
@Volatile private var currentGeneration: Double = -1.0
@Volatile private var plannedFor: Pair<Double, Long>? = null
var onPlaybackStarted: (() -> Unit)? = null
var onStateChanged: ((MpvPlaybackState) -> Unit)? = null
var onExplicitStop: (() -> Unit)? = null
/**
* Where a transport outside the player sends previous/next.
*
* The player owns which episode is open, so the notification, the lock
* screen and Picture-in-Picture cannot walk the playlist themselves. Both
* run on the main thread, which is where the player's state lives.
*/
var onSkipPrevious: (() -> Unit)? = null
var onSkipNext: (() -> Unit)? = null
val state: StateFlow<MpvPlaybackState> = mutableState.asStateFlow()
val tracks: StateFlow<List<MpvTrack>> = mutableTracks.asStateFlow()
/** The open file's chapters, already resolved into openings and endings. */
val chapters: StateFlow<ChapterPlan> = mutableChapters.asStateFlow()
/** The episode the player has open, for every surface that has to name it. */
val nowPlaying: StateFlow<NowPlaying?> = mutableNowPlaying.asStateFlow()
/**
* True while the player is between episodes.
*
* The end of one episode and the start of the next are separated by a
* network round trip — opening the next revision's stream. The foreground
* service must not retire in that gap: with no foreground state left, the
* `startForeground` on the other side is a background start, which API 31+
* refuses outright.
*/
val advancing: StateFlow<Boolean> = mutableAdvancing.asStateFlow()
/**
* What mpv last complained about, until the viewer dismisses it.
*
* Errors mpv reports are about the *file* — an unreadable header, a codec it
* has no decoder for — and reach nothing else: the play call has already
* returned successfully by the time they happen.
*/
val problem: StateFlow<String?> = mutableProblem.asStateFlow()
/** Dismiss the current [problem]. */
fun clearProblem() { mutableProblem.value = null }
val hasActivePlayback: Boolean get() = mutableState.value.duration > 0.0 && !mutableState.value.ended
override fun attachSurface(surface: Surface) {
@@ -64,6 +177,7 @@ class NativeMpvHost : LibmpvHost, AutoCloseable {
}
override suspend fun play(
media: String,
nativeHandle: ULong,
size: ULong,
startPosition: Double,
@@ -72,69 +186,172 @@ class NativeMpvHost : LibmpvHost, AutoCloseable {
subtitles: Boolean,
) {
check(size > 0uL) { "Cannot play an empty stream" }
withContext(Dispatchers.IO) {
val loaded = withOpenHandle {
nativeLoad(it, nativeHandle.toLong(), startPosition, audioLanguage, subtitleLanguage, subtitles).also { loaded ->
if (loaded) {
currentStreamHandle?.let(::pstrAndroidStreamRelease)
currentStreamHandle = nativeHandle.toLong()
try {
withContext(Dispatchers.IO) {
val loaded = withOpenHandle {
nativeLoad(it, nativeHandle.toLong(), startPosition, audioLanguage, subtitleLanguage, subtitles).also { loaded ->
if (loaded) {
// The outgoing stream is released by mpv closing it,
// not from here: `loadfile` is asynchronous and its
// demuxer may still be reading. `stream_close` in
// `pstr_mpv.cpp` owns that release.
//
// The outgoing file's readings describe an episode
// that is no longer open. Retire them here as well
// as natively, so nothing observes them even once.
currentMedia = media
currentGeneration = withOpenHandle(::nativeState)?.generation() ?: -1.0
mutableState.value = MpvPlaybackState(
position = startPosition,
volume = mutableState.value.volume,
muted = mutableState.value.muted,
media = media,
)
mutableProblem.value = null
mutableTracks.value = emptyList()
mutableChapters.value = ChapterPlan(emptyList(), null)
plannedFor = null
}
}
} ?: false
check(loaded) {
"libmpv rejected the stream or no video surface was available"
}
} ?: false
check(loaded) {
"libmpv rejected the stream or no video surface was available"
}
}
withOpenHandle {
onPlaybackStarted?.invoke()
startPolling()
withOpenHandle {
onPlaybackStarted?.invoke()
startPolling()
}
} finally {
// Whether the next episode arrived or failed to, the gap the
// foreground service was holding open is over.
mutableAdvancing.value = false
}
}
fun setPaused(paused: Boolean) { withOpenHandle { nativePause(it, paused) } }
/**
* Declare that the player is walking to another episode.
*
* Called before the stream for the next one is opened, and cleared when
* [play] returns either way — the window this covers is the one in which
* nothing is loaded and the service would otherwise conclude playback is
* finished.
*/
fun beginTransition() { mutableAdvancing.value = true }
/** Names the open episode for the media session, its notification and PiP. */
fun publish(playing: NowPlaying?) { mutableNowPlaying.value = playing }
fun setPaused(paused: Boolean) {
// The native side writes the flag through, so every transport can flip
// now instead of on the next poll a quarter second from here.
val next = withOpenHandle {
nativePause(it, paused)
mutableState.value.copy(paused = paused).also { state -> mutableState.value = state }
} ?: return
onStateChanged?.invoke(next)
}
fun seek(position: Double) { withOpenHandle { nativeSeek(it, position) } }
fun setVolume(volume: Double) { withOpenHandle { nativeVolume(it, volume) } }
fun setMuted(muted: Boolean) { withOpenHandle { nativeMute(it, muted) } }
/** Playback rate, 1.0 being the file's own. Clamped native-side. */
fun setSpeed(speed: Double) { withOpenHandle { nativeSpeed(it, speed) } }
fun selectTrack(track: MpvTrack?) {
val audio = track?.type == "audio"
withOpenHandle { nativeSelectTrack(it, audio, track?.id ?: -1) }
refreshTracks()
}
/** Explicit user stop: stop media, release its stream, and retire the foreground service. */
/**
* Explicit user stop: stop media and retire the foreground service.
*
* The stream behind the open file is released by mpv closing it, which
* `stop` causes; releasing it from here would race that close.
*/
fun stop() {
val callback = withOpenHandle {
nativeStop(it)
currentStreamHandle?.let(::pstrAndroidStreamRelease)
currentStreamHandle = null
// Nothing loaded means nothing to attribute a reading to: a save
// issued after this must not land on the episode just stopped.
currentMedia = null
mutableAdvancing.value = false
mutableChapters.value = ChapterPlan(emptyList(), null)
mutableNowPlaying.value = null
onExplicitStop
}
callback?.invoke()
}
internal fun releaseEndedStream() {
withOpenHandle { currentStreamHandle?.let(::pstrAndroidStreamRelease); currentStreamHandle = null }
}
override fun releaseNativeStream(nativeHandle: ULong) = pstrAndroidStreamRelease(nativeHandle.toLong())
private fun startPolling() {
if (poller?.isActive == true) return
poller = scope.launch {
// Off the main thread deliberately: `nativeState` and `nativeTracks`
// are synchronous `mpv_get_property` calls — five per track for the
// latter — that contend on the core lock the demuxer holds during a
// fetch. A poll that blocks there blocks the UI thread with it.
poller = scope.launch(Dispatchers.Default) {
var tick = 0
while (isActive) {
val values = withOpenHandle(::nativeState) ?: break
if (values.size >= 6) {
val next = MpvPlaybackState(values[0], values[1], values[2], values[3] != 0.0, values[4] != 0.0, values[5] != 0.0)
if (values.size >= FIELDS) {
val next = MpvPlaybackState(
values[0], values[1], values[2],
values[3] != 0.0, values[4] != 0.0, values[5] != 0.0,
// Only a reading of the file this host was last asked
// to load carries its episode's name.
media = currentMedia.takeIf { values.generation() == currentGeneration },
videoWidth = values[WIDTH],
videoHeight = values[HEIGHT],
endReason = EndReason.of(values[END_REASON]),
buffering = values[BUFFERING] != 0.0,
cachePercent = values[CACHE_PERCENT],
seeking = values[SEEKING] != 0.0,
)
mutableState.value = next
onStateChanged?.invoke(next)
// The listeners drive the media session and the player UI,
// both of which are the main thread's.
withContext(Dispatchers.Main) { onStateChanged?.invoke(next) }
refreshChapters(next)
}
withOpenHandle(::nativeTakeError)?.let { mutableProblem.value = it }
if (tick++ % 4 == 0) refreshTracks()
delay(250)
}
}
}
/**
* Re-read the chapters when there is a reason to: a different file, or a
* duration that has only just arrived. Both change the answer — the length
* of a chapter is what settles whether `Intro` is an opening or ten minutes
* of story — and neither changes again after that, so this settles quickly
* and then costs one comparison per poll.
*/
private fun refreshChapters(state: MpvPlaybackState) {
val generation = currentGeneration
val wanted = state.duration to generation.toRawBits()
if (plannedFor == wanted) return
runCatching {
val encoded = withOpenHandle(::nativeChapters) ?: return
val values = JSONArray(encoded)
List(values.length()) { index ->
values.getJSONObject(index).run {
ChapterRecord(
index = getLong("index"),
title = optString("title").takeIf(String::isNotBlank),
start = getDouble("start"),
)
}
}
}.onSuccess { chapters ->
plannedFor = wanted
mutableChapters.value = chapterPlan(chapters, state.duration.takeIf { it > 0.0 })
}
}
private fun refreshTracks() {
runCatching {
val encoded = withOpenHandle(::nativeTracks) ?: return
@@ -149,16 +366,28 @@ class NativeMpvHost : LibmpvHost, AutoCloseable {
override fun close() {
scope.cancel()
lifecycle.withLock {
if (closed) return@withLock
val destroy = lifecycle.withLock {
if (closed) return@withLock false
closed = true
onPlaybackStarted = null
onStateChanged = null
onExplicitStop = null
nativeDestroy(nativeHandle)
currentStreamHandle?.let(::pstrAndroidStreamRelease)
currentStreamHandle = null
onSkipPrevious = null
onSkipNext = null
mutableNowPlaying.value = null
mutableAdvancing.value = false
true
}
if (!destroy) return
// `mpv_terminate_destroy` joins mpv's demuxer thread, which may be
// parked in a block fetch over a degraded connection. This is called
// from Service.onDestroy — on the main thread — so waiting for it there
// is an ANR. `closed` is already set, so nothing new enters the handle;
// the lock is only to let a call that is already inside finish.
//
// Terminating the core closes the open file, and closing it releases the
// stream — after mpv's last read rather than during it.
Thread({ lifecycle.withLock { nativeDestroy(nativeHandle) } }, "pstr-mpv-teardown").start()
}
/** Serializes destruction against every JNI use of the raw Player pointer. */
@@ -166,7 +395,6 @@ class NativeMpvHost : LibmpvHost, AutoCloseable {
if (closed) null else block(nativeHandle)
}
private external fun nativeCreate(): Long
private external fun nativeDestroy(handle: Long)
private external fun nativeAttachSurface(handle: Long, surface: Surface)
private external fun nativeDetachSurface(handle: Long)
@@ -174,17 +402,47 @@ class NativeMpvHost : LibmpvHost, AutoCloseable {
private external fun nativePause(handle: Long, paused: Boolean)
private external fun nativeSeek(handle: Long, position: Double)
private external fun nativeVolume(handle: Long, volume: Double)
private external fun nativeSpeed(handle: Long, speed: Double)
private external fun nativeMute(handle: Long, muted: Boolean)
private external fun nativeSelectTrack(handle: Long, audio: Boolean, track: Long)
private external fun nativeStop(handle: Long)
private external fun nativeState(handle: Long): DoubleArray
private external fun nativeTracks(handle: Long): String
private external fun nativeTakeError(handle: Long): String?
// Nullable: `NewStringUTF` returns null when the string cannot be built,
// and a non-null declaration turns that into an NPE that `runCatching`
// swallows into a silently empty track list.
private external fun nativeTracks(handle: Long): String?
private external fun nativeChapters(handle: Long): String?
private external fun pstrAndroidStreamRelease(handle: Long)
private fun DoubleArray.generation(): Double = getOrElse(GENERATION) { -1.0 }
companion object {
/** Fields `nativeState` fills, in the order `pstr_mpv.cpp` writes them. */
private const val FIELDS = 13
private const val GENERATION = 6
private const val WIDTH = 7
private const val HEIGHT = 8
private const val END_REASON = 9
private const val BUFFERING = 10
private const val CACHE_PERCENT = 11
private const val SEEKING = 12
init {
System.loadLibrary("pstr_android")
System.loadLibrary("pstr_mpv")
}
/**
* A host over a live libmpv core, or null if libmpv would not start.
*
* Null rather than an exception: the service that owns this is created
* by the system, so a throw out of the constructor takes the process
* with it — and the app has a graceful "this build does not include
* libmpv" screen that a crash makes unreachable.
*/
fun createOrNull(): NativeMpvHost? = nativeCreate().takeIf { it != 0L }?.let(::NativeMpvHost)
@JvmStatic private external fun nativeCreate(): Long
}
}
@@ -5,7 +5,12 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.Bitmap
import android.graphics.drawable.Icon
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
@@ -14,38 +19,99 @@ import android.media.session.MediaSession
import android.media.session.PlaybackState
import android.os.Binder
import android.os.IBinder
import androidx.core.content.ContextCompat
import io.narl.protonstream.MainActivity
import io.narl.protonstream.R
import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.ui.loadArtwork
import io.narl.protonstream.ui.loadThumbnail
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
/** Owns libmpv so audio survives Activity recreation, rotation and PiP. */
class PlaybackService : Service() {
inner class PlaybackBinder : Binder() {
val host: NativeMpvHost get() = this@PlaybackService.host
val host: NativeMpvHost? get() = this@PlaybackService.host
}
private lateinit var host: NativeMpvHost
/**
* The libmpv core, or null when this build could not start one.
*
* Nullable rather than a `lateinit` that throws: the system creates this
* service, so a failure in its constructor takes the process down and makes
* the app's own "playback is unavailable" screen unreachable.
*/
private var host: NativeMpvHost? = null
private lateinit var mediaSession: MediaSession
private lateinit var audioManager: AudioManager
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var audioFocus: AudioFocusRequest? = null
private var notifiedPaused: Boolean? = null
/** Whether the pause on screen is this service's doing, not the viewer's. */
private var pausedForFocus = false
/** The volume before ducking, so the restore does not read a ducked one. */
private var beforeDucking: Double? = null
/**
* Pulling the headphone jack must not continue on the speaker.
*
* Android broadcasts this and expects every media app to handle it; the
* alternative is an episode playing out loud in a quiet room.
*/
private val becomingNoisy = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) host?.setPaused(true)
}
}
private var foregroundPlayback = false
private var sessionPlayback = false
/**
* What the posted notification already says.
*
* The state poll runs four times a second and almost none of those readings
* change anything the shade shows. Re-posting on every one of them animates
* the media control and costs a binder round trip per tick, so the
* notification is rebuilt only when its own inputs move.
*/
private var notified: NotificationShape? = null
/** The pending teardown, held so an episode transition can call it off. */
private var retire: Job? = null
/** The episode's artwork, and the source key it was fetched for. */
private var artwork: Pair<String, Bitmap>? = null
private val binder = PlaybackBinder()
override fun onCreate() {
super.onCreate()
createChannel()
ContextCompat.registerReceiver(
this,
becomingNoisy,
IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
audioManager = getSystemService(AudioManager::class.java)
host = NativeMpvHost().apply {
host = NativeMpvHost.createOrNull()?.apply {
onPlaybackStarted = {
requestAudioFocus()
// A refusal is rare — a call in progress — but playing over it
// is worse than not playing, and the viewer can simply press
// play again once the call ends.
if (!requestAudioFocus()) setPaused(true)
sessionPlayback = true
if (SettingsStore(this@PlaybackService).backgroundAudio) {
cancelRetire()
if (!foregroundPlayback && SettingsStore(this@PlaybackService).backgroundAudio) {
// Convert the bound service into a started foreground
// service only when the viewer opted to keep audio alive.
startService(Intent(this@PlaybackService, PlaybackService::class.java))
startForeground(NOTIFICATION_ID, notification(false))
foregroundPlayback = true
// Already-foreground is the ordinary case across an episode
// transition, and re-entering costs a notification rebuild.
foregroundPlayback = runCatching {
startService(Intent(this@PlaybackService, PlaybackService::class.java))
startForeground(NOTIFICATION_ID, notification(state.value, nowPlaying.value))
}.isSuccess
}
mediaSession.isActive = true
}
@@ -53,80 +119,213 @@ class PlaybackService : Service() {
onExplicitStop = { retirePlayback() }
}
mediaSession = MediaSession(this, "proton-stream").apply {
setSessionActivity(openActivity())
setCallback(object : MediaSession.Callback() {
override fun onPlay() = host.setPaused(false)
override fun onPause() = host.setPaused(true)
override fun onSeekTo(pos: Long) = host.seek(pos / 1_000.0)
override fun onStop() = host.stop()
override fun onPlay() { host?.setPaused(false) }
override fun onPause() { host?.setPaused(true) }
override fun onSeekTo(pos: Long) { host?.seek(pos / 1_000.0) }
override fun onStop() { host?.stop() }
override fun onSkipToNext() { host?.onSkipNext?.invoke() }
override fun onSkipToPrevious() { host?.onSkipPrevious?.invoke() }
})
}
// Artwork arrives after the episode does, and the shape of the transport
// changes with the playlist position, so the notification follows the
// published episode rather than being built once when playback starts.
val core = host ?: return
scope.launch {
core.nowPlaying.collect { playing ->
// Keyed by whichever source the episode has: with metadata off
// there is no provider poster, and Proton's own thumbnail is
// the only thing the shade can show.
artwork = playing?.let { episode ->
val key = episode.artworkUrl ?: episode.artworkFile?.key ?: return@let null
artwork?.takeIf { it.first == key }
?: (
loadArtwork(this@PlaybackService, episode.artworkUrl)
?: loadThumbnail(episode.artworkFile)
)?.let { key to it }
}
refreshSession(core.state.value, playing)
}
}
}
override fun onBind(intent: Intent?): IBinder = binder
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_PLAY -> host.setPaused(false)
ACTION_PAUSE -> host.setPaused(true)
ACTION_STOP -> host.stop()
ACTION_PLAY -> host?.setPaused(false)
ACTION_PAUSE -> host?.setPaused(true)
ACTION_PREVIOUS -> host?.onSkipPrevious?.invoke()
ACTION_NEXT -> host?.onSkipNext?.invoke()
ACTION_STOP -> host?.stop()
}
return START_NOT_STICKY
}
override fun onDestroy() {
scope.cancel()
runCatching { unregisterReceiver(becomingNoisy) }
audioFocus?.let(audioManager::abandonAudioFocusRequest)
mediaSession.release()
host.close()
host?.close()
super.onDestroy()
}
private fun requestAudioFocus() {
/**
* Take audio focus, and honour what the system says about it afterwards.
*
* The four cases are genuinely different and collapsing them is what made
* this feel broken: a phone call is transient and playback should come back
* by itself; a navigation prompt wants the volume down, not a pause; another
* media app taking over for good means giving the focus back rather than
* holding it; and a refusal means not playing at all.
*/
private fun requestAudioFocus(): Boolean {
audioFocus?.let(audioManager::abandonAudioFocusRequest)
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(
AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE).build(),
)
.setWillPauseWhenDucked(false)
.setOnAudioFocusChangeListener { focus ->
when (focus) {
AudioManager.AUDIOFOCUS_LOSS, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> host.setPaused(true)
AudioManager.AUDIOFOCUS_GAIN -> Unit
AudioManager.AUDIOFOCUS_LOSS -> {
pausedForFocus = false
audioFocus?.let(audioManager::abandonAudioFocusRequest)
audioFocus = null
host?.setPaused(true)
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
// Only resume what this paused: a viewer who paused
// before the phone rang expects it still paused after.
pausedForFocus = host?.state?.value?.paused == false
host?.setPaused(true)
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
host?.let { core ->
val current = core.state.value.volume
beforeDucking = beforeDucking ?: current
core.setVolume(current * DUCK_FACTOR)
}
}
AudioManager.AUDIOFOCUS_GAIN -> {
beforeDucking?.let { host?.setVolume(it) }
beforeDucking = null
if (pausedForFocus) {
pausedForFocus = false
host?.setPaused(false)
}
}
}
}.build()
audioFocus = request
audioManager.requestAudioFocus(request)
return audioManager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
}
private fun updateSession(state: MpvPlaybackState) {
refreshSession(state, host?.nowPlaying?.value)
if (state.ended) scheduleRetire() else cancelRetire()
}
/**
* Retire the session once playback is really over.
*
* "This file ended" is not "playback is over": autoplay walks to the next
* episode, and opening its stream is a network round trip. Retiring inside
* that gap leaves the app with no foreground state at all, so the
* `startForeground` on the far side is a background start — which API 31+
* refuses with `ForegroundServiceStartNotAllowedException`, and this app's
* minSdk is 31.
*/
private fun scheduleRetire() {
if (retire?.isActive == true) return
retire = scope.launch {
// The end of file reaches the player and this service from the same
// poll, and the player's advance is a recomposition behind. Give it
// that long to declare a transition before concluding there is none.
delay(TRANSITION_SETTLE_MS)
val host = this@PlaybackService.host ?: return@launch
withTimeoutOrNull(TRANSITION_GRACE_MS) { host.advancing.first { !it } }
if (host.state.value.ended) retirePlayback()
}
}
private fun cancelRetire() {
retire?.cancel()
retire = null
}
private fun refreshSession(state: MpvPlaybackState, playing: NowPlaying?) {
val status = when {
state.ended -> PlaybackState.STATE_STOPPED
state.paused -> PlaybackState.STATE_PAUSED
else -> PlaybackState.STATE_PLAYING
}
var actions = PlaybackState.ACTION_PLAY or PlaybackState.ACTION_PAUSE or
PlaybackState.ACTION_PLAY_PAUSE or PlaybackState.ACTION_SEEK_TO or PlaybackState.ACTION_STOP
if (playing?.hasNext == true) actions = actions or PlaybackState.ACTION_SKIP_TO_NEXT
if (playing?.hasPrevious == true) actions = actions or PlaybackState.ACTION_SKIP_TO_PREVIOUS
mediaSession.setPlaybackState(
PlaybackState.Builder()
.setActions(PlaybackState.ACTION_PLAY or PlaybackState.ACTION_PAUSE or PlaybackState.ACTION_SEEK_TO or PlaybackState.ACTION_STOP)
.setActions(actions)
.setState(status, (state.position * 1_000).toLong(), if (state.paused) 0f else 1f)
.build(),
)
mediaSession.setMetadata(
MediaMetadata.Builder().putLong(MediaMetadata.METADATA_KEY_DURATION, (state.duration * 1_000).toLong()).build(),
mediaSession.setMetadata(metadata(state, playing))
val shape = NotificationShape(
paused = state.paused,
media = playing?.media,
title = playing?.title,
subtitle = playing?.show,
hasPrevious = playing?.hasPrevious == true,
hasNext = playing?.hasNext == true,
hasArtwork = artwork != null,
)
if (state.ended && foregroundPlayback) {
host.releaseEndedStream()
retirePlayback()
return
}
if (foregroundPlayback && notifiedPaused != state.paused) {
notifiedPaused = state.paused
getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(state.paused))
if (foregroundPlayback && notified != shape) {
notified = shape
getSystemService(NotificationManager::class.java)
.notify(NOTIFICATION_ID, notification(state, playing))
}
}
/**
* What the shade, the lock screen and Android Auto read.
*
* The episode is the title and the show is the artist, which is the mapping
* every media surface already lays out well: one prominent line, one
* secondary, and the poster behind both.
*/
private fun metadata(state: MpvPlaybackState, playing: NowPlaying?): MediaMetadata =
MediaMetadata.Builder()
.putString(MediaMetadata.METADATA_KEY_TITLE, playing?.title ?: "proton-stream")
.putString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE, playing?.title ?: "proton-stream")
.apply {
playing?.show?.let {
putString(MediaMetadata.METADATA_KEY_ARTIST, it)
putString(MediaMetadata.METADATA_KEY_ALBUM, it)
putString(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE, it)
}
playing?.detail?.let {
putString(MediaMetadata.METADATA_KEY_DISPLAY_DESCRIPTION, it)
}
artwork?.second?.let {
putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, it)
putBitmap(MediaMetadata.METADATA_KEY_ART, it)
putBitmap(MediaMetadata.METADATA_KEY_DISPLAY_ICON, it)
}
}
.putLong(MediaMetadata.METADATA_KEY_DURATION, (state.duration * 1_000).toLong())
.build()
private fun retirePlayback() {
cancelRetire()
if (!sessionPlayback && !foregroundPlayback) return
sessionPlayback = false
notifiedPaused = null
notified = null
artwork = null
mediaSession.isActive = false
audioFocus?.let(audioManager::abandonAudioFocusRequest)
audioFocus = null
@@ -135,40 +334,98 @@ class PlaybackService : Service() {
stopSelf()
}
private fun notification(paused: Boolean): Notification {
val open = PendingIntent.getActivity(
this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val toggleAction = if (paused) ACTION_PLAY else ACTION_PAUSE
val toggle = PendingIntent.getService(
this, 1, Intent(this, PlaybackService::class.java).setAction(toggleAction), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val stop = PendingIntent.getService(
this, 2, Intent(this, PlaybackService::class.java).setAction(ACTION_STOP), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
private fun openActivity(): PendingIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
private fun command(requestCode: Int, action: String): PendingIntent = PendingIntent.getService(
this,
requestCode,
Intent(this, PlaybackService::class.java).setAction(action),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
private fun action(icon: Int, label: String, requestCode: Int, name: String): Notification.Action =
Notification.Action.Builder(Icon.createWithResource(this, icon), label, command(requestCode, name)).build()
private fun notification(state: MpvPlaybackState, playing: NowPlaying?): Notification {
val paused = state.paused
val actions = buildList {
if (playing?.hasPrevious == true) {
add(action(android.R.drawable.ic_media_previous, "Previous", 3, ACTION_PREVIOUS))
}
add(
if (paused) action(android.R.drawable.ic_media_play, "Play", 1, ACTION_PLAY)
else action(android.R.drawable.ic_media_pause, "Pause", 1, ACTION_PAUSE),
)
if (playing?.hasNext == true) {
add(action(android.R.drawable.ic_media_next, "Next", 4, ACTION_NEXT))
}
add(action(android.R.drawable.ic_menu_close_clear_cancel, "Stop", 2, ACTION_STOP))
}
val style = Notification.MediaStyle()
.setMediaSession(mediaSession.sessionToken)
// The compact view holds three: the transport as it exists for this
// episode, so a first episode does not show a dead Previous.
.setShowActionsInCompactView(*IntArray(minOf(3, actions.size)) { it })
return Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_media_play)
.setContentTitle("proton-stream")
.setContentText(if (paused) "Playback paused" else "Playing")
.setContentIntent(open)
.setSmallIcon(R.drawable.ic_notification_stream)
.setContentTitle(playing?.title ?: "proton-stream")
.setContentText(playing?.show ?: playing?.detail ?: if (paused) "Paused" else "Playing")
.apply {
playing?.takeIf { it.show != null }?.detail?.let { setSubText(it) }
artwork?.second?.let { setLargeIcon(it) }
}
.setColor(ACCENT)
.setColorized(true)
.setContentIntent(openActivity())
.setDeleteIntent(command(2, ACTION_STOP))
.setVisibility(Notification.VISIBILITY_PUBLIC)
.setOngoing(!paused)
.addAction(Notification.Action.Builder(null, if (paused) "Play" else "Pause", toggle).build())
.addAction(Notification.Action.Builder(null, "Stop", stop).build())
.setStyle(Notification.MediaStyle().setMediaSession(mediaSession.sessionToken).setShowActionsInCompactView(0, 1))
.apply { actions.forEach { addAction(it) } }
.setStyle(style)
.build()
}
private fun createChannel() {
getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(CHANNEL_ID, "Playback", NotificationManager.IMPORTANCE_LOW),
NotificationChannel(CHANNEL_ID, "Playback", NotificationManager.IMPORTANCE_LOW).apply {
description = "Transport controls for the episode being played."
setShowBadge(false)
},
)
}
/** The inputs the posted notification is built from, and nothing else. */
private data class NotificationShape(
val paused: Boolean,
val media: String?,
val title: String?,
val subtitle: String?,
val hasPrevious: Boolean,
val hasNext: Boolean,
val hasArtwork: Boolean,
)
companion object {
private const val CHANNEL_ID = "playback"
private const val NOTIFICATION_ID = 47
/** How long a player gets to declare that it is changing episodes. */
private const val TRANSITION_SETTLE_MS = 1_000L
/** And how long that transition may then take before it is abandoned. */
private const val TRANSITION_GRACE_MS = 30_000L
/** How far the volume drops for a navigation prompt or a notification. */
private const val DUCK_FACTOR = 0.3
/** Catppuccin Mocha mauve, the accent the app is themed with. */
private const val ACCENT = 0xFFCBA6F7.toInt()
private const val ACTION_PLAY = "io.narl.protonstream.PLAY"
private const val ACTION_PAUSE = "io.narl.protonstream.PAUSE"
private const val ACTION_PREVIOUS = "io.narl.protonstream.PREVIOUS"
private const val ACTION_NEXT = "io.narl.protonstream.NEXT"
private const val ACTION_STOP = "io.narl.protonstream.STOP"
}
}
@@ -0,0 +1,974 @@
package io.narl.protonstream.playback
import android.app.Activity
import android.content.pm.ActivityInfo
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.WindowManager
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Toc
import androidx.compose.material.icons.automirrored.filled.VolumeOff
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Audiotrack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.filled.Forward30
import androidx.compose.material.icons.filled.Fullscreen
import androidx.compose.material.icons.filled.FullscreenExit
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Replay10
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.SkipPrevious
import androidx.compose.material.icons.filled.Subtitles
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.ui.thumbnailSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import uniffi.pstr_android.ChapterEntry
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TrackPreferencesRecord
import uniffi.pstr_android.skipOffer
import kotlin.math.roundToInt
/** How long the controls stay up after a tap. */
private const val CONTROLS_SECONDS = 4L
/** How long the "up next" card counts down before it loads the next episode. */
private const val UP_NEXT_SECONDS = 10
/** Past this much of an episode it counts as watched. */
private const val WATCHED_FRACTION = 0.9
/** The loudest this app plays. Matches `pstr_core::prefs`, which stores it. */
private const val MAX_VOLUME = 100f
/**
* The narrowest layout the in-app volume slider is worth the room it costs.
*
* Below it — a phone held upright — the transport row is already five controls
* wide, and the device's own volume keys are the better answer.
*/
private val VOLUME_SLIDER_MIN_WIDTH = 600.dp
/**
* The player, as its own screen on the activity's own window.
*
* Not a dialog: a dialog window has its own insets, cannot be told to draw into
* the display cutout, and hands Picture-in-Picture the wrong window. Owning the
* activity window is what makes "fullscreen" mean the whole panel, notch
* included.
*
* [episodes] is the title's episodes in display order and [index] the one that is
* open, which is what previous/next and autoplay walk. The index is hoisted so
* that it survives process recreation with the rest of the navigation state —
* this screen is destroyed and rebuilt by a dark-mode toggle like any other.
*
* In [inPictureInPicture] the window is a thumbnail with the system's own
* controls over it, so this screen draws the picture and nothing else.
*/
@Composable
fun PlayerScreen(
title: TitleRecord,
episodes: List<EpisodeRecord>,
index: Int,
host: LibmpvHost?,
inPictureInPicture: Boolean,
minimized: Boolean,
onIndexChange: (Int) -> Unit,
onSaveProgress: (EpisodeRecord, Double, Double, Boolean) -> Unit,
onMinimize: () -> Unit,
onClose: () -> Unit,
) {
val context = LocalContext.current
val activity = context as? Activity
val titleKey = title.key
val episode = episodes.getOrNull(index)
// Minimised, this screen keeps the stream and the effects that own it, and
// draws nothing: no surface — mpv plays on without one, exactly as it does
// while the app is in the background — no immersive window, and no back
// handler, because back belongs to the page the viewer went to.
if (!minimized) {
ImmersiveWindow(activity)
// Back leaves the video rather than ending it. Stopping is the mini
// transport's ✕, which is the one place it is unambiguous.
BackHandler(onBack = onMinimize)
}
// The transport reads state only this host publishes, so the interface
// alone is not enough to draw a player.
val nativeHost = host as? NativeMpvHost
if (nativeHost == null || episode == null) {
if (minimized) return
Box(Modifier.fillMaxSize().background(Color.Black), contentAlignment = Alignment.Center) {
Text(
if (nativeHost == null) "Playback is unavailable: this build does not include libmpv."
else "This title has no episode to play.",
color = Color.White,
)
}
return
}
val key = mediaKey(episode)
val session = remember(key) { RustStreamSession(episode) }
var playbackError by remember(key) { mutableStateOf<String?>(null) }
LaunchedEffect(session, host) {
runCatching {
val startup = withContext(Dispatchers.IO) {
session.open()
val engine = NativeRuntime.engine()
// A choice made on this title wins; without one, the choice the
// viewer made anywhere else does. Both are the desktop client's
// fallback chain, and it is what makes "Japanese with English
// subtitles" a thing you say once rather than once per show.
val show = engine.titleTrackPreferences(titleKey)
val global = engine.playbackPrefs()
val watch = engine.watchState(episode.shareId, episode.linkId)
Startup(
nativeHandle = session.nativeHandle(),
size = session.size(),
// A finished episode restarts rather than resuming three
// seconds from its own credits.
startPosition = watch?.takeUnless { it.watched }?.positionSecs ?: 0.0,
audioLanguage = show?.audioLanguage ?: global.audioLanguage,
subtitleLanguage = show?.subtitleLanguage ?: global.subtitleLanguage,
subtitles = show?.subtitles ?: global.subtitles,
volume = global.volume,
muted = global.muted,
speed = global.speed,
)
}
nativeHost.play(
key, startup.nativeHandle, startup.size, startup.startPosition,
startup.audioLanguage, startup.subtitleLanguage, startup.subtitles,
)
// After the load, because mpv keeps none of the three across one.
nativeHost.setVolume(startup.volume)
nativeHost.setMuted(startup.muted)
nativeHost.setSpeed(startup.speed)
session.transferToPlayer()
}.onFailure { playbackError = it.message ?: "Playback failed" }
}
// Per episode: the stream Rust holds open. The surface is the screen's, not
// this episode's, so detaching it belongs to the effect below and not here.
DisposableEffect(session) { onDispose { session.close(nativeHost) } }
DisposableEffect(nativeHost) { onDispose { nativeHost.detachSurface() } }
val state by nativeHost.state.collectAsState()
val chapters by nativeHost.chapters.collectAsState()
val tracks by nativeHost.tracks.collectAsState()
// Read once: a preference the viewer changes mid-episode applies to the
// next one, and re-reading the store per frame would not.
var autoSkip by remember { mutableStateOf(false) }
var autoplay by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().playbackPrefs() } }
.onSuccess { autoSkip = it.autoSkip; autoplay = it.autoplayNext }
}
// Resume position, written only from readings that carry this episode's
// name: one host serves every episode, and an unattributed reading is the
// previous one's clock.
fun save() {
val current = nativeHost.state.value
if (current.media == key && current.duration > 0.0) {
onSaveProgress(
episode,
current.position.coerceIn(0.0, current.duration),
current.duration,
// Watched means played to the end. An episode that was stopped,
// or that mpv could not play at all, also "ended" — recording
// either as watched retires an episode nobody saw.
current.endReason == EndReason.Eof ||
current.position >= current.duration * WATCHED_FRACTION,
)
}
}
LaunchedEffect(key) {
while (true) {
delay(5_000)
save()
}
}
DisposableEffect(key) { onDispose { save() } }
val next = episodes.getOrNull(index + 1)
// Both walks tell the host they are one, so the foreground service holds
// through the gap where nothing is loaded — see NativeMpvHost.advancing.
fun advance() {
save()
next?.let { nativeHost.beginTransition(); onIndexChange(index + 1) }
}
fun retreat() {
if (index == 0) return
save()
nativeHost.beginTransition()
onIndexChange(index - 1)
}
// What the media notification, the lock screen and Picture-in-Picture show.
// None of them can reach this composition, and all three have to name the
// episode rather than the app.
LaunchedEffect(key, index, episodes.size, title.key) {
nativeHost.publish(
NowPlaying(
media = key,
title = episode.label,
show = title.canonicalName ?: title.name,
detail = episode.detail,
artworkUrl = title.posterUrl ?: title.backdropUrl,
artworkFile = episode.thumbnailSource,
hasPrevious = index > 0,
hasNext = next != null,
),
)
}
// Those same surfaces walk the playlist through here, because this is where
// the open episode lives. Both callbacks run on the main thread.
DisposableEffect(nativeHost, index, episodes.size) {
nativeHost.onSkipNext = { advance() }
nativeHost.onSkipPrevious = { retreat() }
onDispose {
nativeHost.onSkipNext = null
nativeHost.onSkipPrevious = null
}
}
// Autoplay fires on a clean end of file only, and only for this episode's
// own reading — never on a failure, and never on the outgoing file's.
LaunchedEffect(state.endReason, state.media) {
if (state.endReason == EndReason.Eof && state.media == key && autoplay && next != null) advance()
}
// A file mpv could not play ends like any other. Saying so is the whole
// difference between a broken episode and an app that silently skipped one.
LaunchedEffect(state.endReason, state.media) {
if (state.endReason == EndReason.Failed && state.media == key) {
playbackError = "This episode could not be played."
}
}
// What mpv itself complained about, which the play call never sees: by the
// time a header turns out to be unreadable it has long since returned.
val problem by nativeHost.problem.collectAsState()
LaunchedEffect(problem) { problem?.let { playbackError = it } }
val offer = remember(chapters, key, (state.position * 4).roundToInt()) {
runCatching { skipOffer(chapters, state.position) }.getOrNull()
}
// Auto-skip is offered once per chapter: seeking back into an opening on
// purpose must not be undone by the setting that skipped it.
val skipped = remember(key) { mutableSetOf<Long>() }
LaunchedEffect(offer?.target) {
val target = offer?.target ?: return@LaunchedEffect
val chapter = chapters.entries.lastOrNull { state.position + 0.001 >= it.start } ?: return@LaunchedEffect
if (autoSkip && !state.paused && skipped.add(chapter.index)) nativeHost.seek(target)
}
var landscapeLocked by remember { mutableStateOf(false) }
var showControls by remember { mutableStateOf(true) }
LaunchedEffect(showControls, state.paused) {
if (showControls && !state.paused) {
delay(CONTROLS_SECONDS * 1_000)
showControls = false
}
}
// Everything this screen draws over the picture is sized for a phone panel
// and unreadable in a thumbnail, and Android supplies its own transport
// there. Leaving PiP must find the controls up rather than mid-timeout.
val overlays = !inPictureInPicture
LaunchedEffect(inPictureInPicture) { if (!inPictureInPicture) showControls = true }
if (minimized) return
Box(
Modifier
.fillMaxSize()
.background(Color.Black)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
enabled = overlays,
onClick = { showControls = !showControls },
),
) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { viewContext ->
SurfaceView(viewContext).apply {
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) = host.attachSurface(holder.surface)
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) =
host.attachSurface(holder.surface)
override fun surfaceDestroyed(holder: SurfaceHolder) = host.detachSurface()
})
}
},
)
AnimatedVisibility(
visible = overlays && showControls,
modifier = Modifier.align(Alignment.TopStart),
enter = fadeIn(),
exit = fadeOut(),
) {
Row(
Modifier
.fillMaxWidth()
.background(Color.Black.copy(alpha = 0.45f))
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onMinimize) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Leave the video",
tint = Color.White,
)
}
// The episode leads and the show follows it: which episode is
// the thing a viewer who has just woken the controls is
// checking, and the show is what they already know.
Column(Modifier.weight(1f)) {
Text(
episode.label,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleMedium,
)
Text(
listOfNotNull(title.canonicalName ?: title.name, episode.detail.takeIf(String::isNotBlank))
.joinToString(" • "),
color = Color.White.copy(alpha = 0.7f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodySmall,
)
}
// Two ways out, because they mean different things: back leaves
// the video and keeps the episode playing, this ends it.
IconButton(onClick = onClose) {
Icon(Icons.Default.Close, contentDescription = "Stop playback", tint = Color.White)
}
remaining(state)?.let {
Text(
it,
color = Color.White.copy(alpha = 0.7f),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(start = 12.dp, end = 4.dp),
)
}
}
}
AnimatedVisibility(
visible = overlays && showControls,
modifier = Modifier.align(Alignment.BottomCenter),
enter = fadeIn(),
exit = fadeOut(),
) {
PlayerControls(
titleKey = titleKey,
host = nativeHost,
state = state,
chapters = chapters.entries,
tracks = tracks,
hasPrevious = index > 0,
hasNext = next != null,
onPrevious = { retreat() },
onNext = { advance() },
onRotate = { landscapeLocked = activity?.toggleLandscape() ?: false },
landscapeLocked = landscapeLocked,
)
}
// Drawn whether or not the rest of the controls are up: an opening lasts
// ninety seconds, and a button you have to wake with a tap is one nobody
// reaches in time.
offer?.takeIf { overlays && !autoSkip }?.let { skip ->
FilledTonalButton(
onClick = { nativeHost.seek(skip.target) },
modifier = Modifier
.align(Alignment.BottomEnd)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(end = 16.dp, bottom = if (showControls) 132.dp else 24.dp),
) { Text(skip.label) }
}
UpNextCard(
next = next,
visible = overlays && autoplay && !state.paused &&
chapters.creditsStart?.let { state.position >= it } == true,
onPlayNow = { advance() },
modifier = Modifier
.align(Alignment.BottomEnd)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(end = 16.dp, bottom = if (showControls) 132.dp else 24.dp),
)
// Only while nothing else explains the still picture: an error is the
// more useful thing to say, and a spinner over it reads as "still
// trying".
if (state.stalled && playbackError == null) {
Column(
Modifier
.align(Alignment.Center)
.background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(12.dp))
.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator(color = Color.White, modifier = Modifier.size(36.dp))
if (state.buffering) {
Text(
state.cachePercent.takeIf { it > 0.0 }
?.let { "Buffering… ${it.roundToInt()}%" } ?: "Buffering…",
color = Color.White,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(top = 12.dp),
)
}
}
}
playbackError?.let { message ->
Column(
Modifier
.align(Alignment.Center)
.background(Color.Black.copy(alpha = 0.85f), RoundedCornerShape(12.dp))
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(message, color = Color.White)
// Dismissible, because the message is not always fatal — a
// subtitle track that failed to load leaves an episode that
// plays perfectly well behind an error that used to be permanent.
TextButton(onClick = { playbackError = null; nativeHost.clearProblem() }) {
Text("Dismiss", color = Color.White)
}
}
}
}
}
private data class Startup(
val nativeHandle: ULong,
val size: ULong,
val startPosition: Double,
val audioLanguage: String?,
val subtitleLanguage: String?,
val subtitles: Boolean,
val volume: Double,
val muted: Boolean,
val speed: Double,
)
/**
* Immersive for as long as this screen is up, and only that long.
*
* Hiding the bars is not enough on its own: without `setDecorFitsSystemWindows`
* and a cutout mode the picture stops at the notch and at the gesture bar, which
* is the "not really fullscreen" everyone notices.
*/
@Composable
private fun ImmersiveWindow(activity: Activity?) {
DisposableEffect(activity) {
val window = activity?.window
if (window == null) return@DisposableEffect onDispose {}
val controller = WindowInsetsControllerCompat(window, window.decorView)
val previousCutout = window.attributes.layoutInDisplayCutoutMode
WindowCompat.setDecorFitsSystemWindows(window, false)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
window.attributes = window.attributes.also {
it.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
controller.hide(WindowInsetsCompat.Type.systemBars())
controller.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
onDispose {
controller.show(WindowInsetsCompat.Type.systemBars())
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
window.attributes = window.attributes.also { it.layoutInDisplayCutoutMode = previousCutout }
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
}
}
/** Lock to landscape, or hand rotation back to the sensor. Reports which. */
private fun Activity.toggleLandscape(): Boolean {
val lock = requestedOrientation != ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
requestedOrientation = if (lock) ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
else ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
return lock
}
/**
* Everything the transport offers, in two rows.
*
* A phone in landscape has one thumb's worth of room, so the second row is
* icons: the transport cluster on the left, where a thumb already is, and the
* pickers on the right. Only the seek bar gets a full row of its own.
*/
@Composable
private fun PlayerControls(
titleKey: String,
host: NativeMpvHost,
state: MpvPlaybackState,
chapters: List<ChapterEntry>,
tracks: List<MpvTrack>,
hasPrevious: Boolean,
hasNext: Boolean,
onPrevious: () -> Unit,
onNext: () -> Unit,
onRotate: () -> Unit,
landscapeLocked: Boolean,
) {
val scope = rememberCoroutineScope()
var choosing by remember { mutableStateOf<String?>(null) }
var showChapters by remember { mutableStateOf(false) }
var showSpeeds by remember { mutableStateOf(false) }
// What the store says, so the dialog opens on the rate that is playing.
var speed by remember { mutableStateOf(1.0) }
LaunchedEffect(Unit) {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().playbackPrefs() } }
.onSuccess { speed = it.speed }
}
val duration = state.duration.toFloat().coerceAtLeast(1f)
val density = LocalDensity.current
val roomForVolume = with(density) {
LocalWindowInfo.current.containerSize.width.toDp() >= VOLUME_SLIDER_MIN_WIDTH
}
Column(
Modifier
.fillMaxWidth()
.background(Color.Black.copy(alpha = 0.68f))
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = 12.dp, vertical = 4.dp),
) {
// Where the thumb is while a finger is on it. A seek per touch-move
// aborts every outstanding prefetch, so a one-second drag used to issue
// dozens of seeks and throw away every in-flight block — and the thumb
// snapped backwards under the finger, because it was drawn from a
// position that only updates four times a second.
var scrubbing by remember { mutableStateOf<Float?>(null) }
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
formatTime(scrubbing?.toDouble() ?: state.position),
color = Color.White,
style = MaterialTheme.typography.labelMedium,
)
Box(Modifier.weight(1f)) {
Slider(
value = scrubbing ?: state.position.toFloat().coerceIn(0f, duration),
onValueChange = { scrubbing = it },
onValueChangeFinished = {
scrubbing?.let { host.seek(it.toDouble()) }
scrubbing = null
},
valueRange = 0f..duration,
)
ChapterMarks(chapters, state.duration, Modifier.matchParentSize())
}
Text(formatTime(state.duration), color = Color.White, style = MaterialTheme.typography.labelMedium)
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
CompactIcon(Icons.Default.SkipPrevious, "Previous episode", enabled = hasPrevious, onClick = onPrevious)
CompactIcon(Icons.Default.Replay10, "Back ten seconds") { host.seek(state.position - 10) }
IconButton(onClick = { host.setPaused(!state.paused) }) {
Icon(
if (state.paused) Icons.Default.PlayArrow else Icons.Default.Pause,
contentDescription = if (state.paused) "Play" else "Pause",
tint = Color.White,
modifier = Modifier.size(32.dp),
)
}
CompactIcon(Icons.Default.Forward30, "Forward thirty seconds") { host.seek(state.position + 30) }
CompactIcon(Icons.Default.SkipNext, "Next episode", enabled = hasNext, onClick = onNext)
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (chapters.size > 1) {
CompactIcon(Icons.AutoMirrored.Filled.Toc, "Chapters") { showChapters = true }
}
CompactIcon(Icons.Default.Speed, "Playback speed") { showSpeeds = true }
CompactIcon(Icons.Default.Audiotrack, "Audio track") { choosing = "audio" }
CompactIcon(Icons.Default.Subtitles, "Subtitles") { choosing = "sub" }
CompactIcon(
if (state.muted) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp,
"Mute",
) {
host.setMuted(!state.muted)
scope.launch(Dispatchers.IO) { rememberVolume(muted = !state.muted) }
}
// The app's own volume, which is not the device's: a file
// mastered quiet is turned up here and stays up for the next
// one. Only where there is room for it — a phone in portrait
// has the system volume keys and no width to spare.
if (roomForVolume) {
var dragging by remember { mutableStateOf<Float?>(null) }
Slider(
value = dragging ?: state.volume.toFloat().coerceIn(0f, MAX_VOLUME),
onValueChange = { dragging = it; host.setVolume(it.toDouble()) },
onValueChangeFinished = {
val chosen = dragging?.toDouble()
dragging = null
chosen?.let { scope.launch(Dispatchers.IO) { rememberVolume(volume = it) } }
},
valueRange = 0f..MAX_VOLUME,
modifier = Modifier.width(96.dp),
)
}
CompactIcon(
if (landscapeLocked) Icons.Default.FullscreenExit else Icons.Default.Fullscreen,
if (landscapeLocked) "Unlock rotation" else "Lock to landscape",
onClick = onRotate,
)
}
}
}
if (showSpeeds) {
SpeedChooser(
current = speed,
onDismiss = { showSpeeds = false },
onSelect = { chosen ->
speed = chosen
host.setSpeed(chosen)
showSpeeds = false
scope.launch(Dispatchers.IO) { rememberSpeed(chosen) }
},
)
}
if (showChapters) {
ChapterChooser(
chapters = chapters,
position = state.position,
onDismiss = { showChapters = false },
onSelect = { host.seek(it.start); showChapters = false },
)
}
choosing?.let { type ->
TrackChooser(
type = type,
tracks = tracks.filter { it.type == type },
onDismiss = { choosing = null },
onSelect = { selected ->
host.selectTrack(selected)
choosing = null
scope.launch(Dispatchers.IO) {
val engine = NativeRuntime.engine()
// No per-title choice yet: start from what plays everywhere
// else, so choosing an audio track does not silently drop
// the subtitle preference this episode started with.
val global = engine.playbackPrefs()
val old = engine.titleTrackPreferences(titleKey) ?: TrackPreferencesRecord(
audioLanguage = global.audioLanguage,
subtitleLanguage = global.subtitleLanguage,
subtitles = global.subtitles,
)
engine.setTitleTrackPreferences(
titleKey,
if (type == "audio") {
old.copy(audioLanguage = selected?.language?.takeIf(String::isNotBlank))
} else {
// Turning subtitles off keeps the language it was
// off *from*, so turning them back on does not have
// to be told the language again — as on desktop.
old.copy(
subtitleLanguage = selected?.language?.takeIf(String::isNotBlank)
?: old.subtitleLanguage,
subtitles = selected != null,
)
},
)
}
},
)
}
}
/**
* Persist a volume or a mute, leaving whichever was not given as it was.
*
* Written when the thumb is released rather than while it moves: a drag emits
* dozens of values a second and every one of them would be a file rewrite. A
* failure is dropped — the volume is already applied, and the cost of not
* storing it is that the next launch starts where the last one was stored.
*/
private suspend fun rememberVolume(volume: Double? = null, muted: Boolean? = null) {
runCatching {
val engine = NativeRuntime.engine()
val prefs = engine.playbackPrefs()
engine.setPlaybackPrefs(prefs.copy(volume = volume ?: prefs.volume, muted = muted ?: prefs.muted))
}
}
/** Persist a playback rate, so the next episode starts at it. */
private suspend fun rememberSpeed(speed: Double) {
runCatching {
val engine = NativeRuntime.engine()
engine.setPlaybackPrefs(engine.playbackPrefs().copy(speed = speed))
}
}
/**
* The rates worth offering.
*
* A short list rather than a slider: the useful rates are a handful of ratios,
* and a slider on a phone lands on 1.07 as often as on 1.0.
*/
private val SPEEDS = listOf(0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0)
@Composable
private fun SpeedChooser(current: Double, onDismiss: () -> Unit, onSelect: (Double) -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Playback speed") },
text = {
Column(Modifier.fillMaxWidth()) {
SPEEDS.forEach { rate ->
OutlinedButton(
onClick = { onSelect(rate) },
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
) {
Text(
(if (kotlin.math.abs(rate - current) < 0.001) "✓ " else "") +
if (rate == 1.0) "Normal" else "${rate}×",
)
}
}
}
},
confirmButton = { Button(onClick = onDismiss) { Text("Close") } },
)
}
/** Where each chapter begins, ticked onto the seek bar. */
@Composable
private fun ChapterMarks(chapters: List<ChapterEntry>, duration: Double, modifier: Modifier) {
if (chapters.size < 2 || duration <= 0.0) return
Canvas(modifier.padding(horizontal = 10.dp)) {
val width = 2.dp.toPx()
val height = 8.dp.toPx()
chapters.drop(1).forEach { chapter ->
val fraction = (chapter.start / duration).coerceIn(0.0, 1.0).toFloat()
drawRect(
color = Color.White.copy(alpha = 0.75f),
topLeft = Offset(fraction * size.width - width / 2, (size.height - height) / 2),
size = Size(width, height),
)
}
}
}
@Composable
private fun ChapterChooser(
chapters: List<ChapterEntry>,
position: Double,
onDismiss: () -> Unit,
onSelect: (ChapterEntry) -> Unit,
) {
val current = chapters.indexOfLast { position + 0.001 >= it.start }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Chapters") },
text = {
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
items(chapters, key = ChapterEntry::index) { chapter ->
OutlinedButton(
onClick = { onSelect(chapter) },
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
) {
Text(
(if (chapters.getOrNull(current) === chapter) "✓ " else "") +
"${formatTime(chapter.start)} ${chapter.label}",
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
},
confirmButton = { Button(onClick = onDismiss) { Text("Close") } },
)
}
/**
* The countdown into the next episode, shown once the credits run starts.
*
* "Watch till the end" holds for the rest of the file: someone who wants the
* post-credits scene should have to say so once, not once every ten seconds.
*/
@Composable
private fun UpNextCard(
next: EpisodeRecord?,
visible: Boolean,
onPlayNow: () -> Unit,
modifier: Modifier = Modifier,
) {
var held by remember(next?.linkId) { mutableStateOf(false) }
var remaining by remember(next?.linkId) { mutableStateOf(UP_NEXT_SECONDS) }
val upNext = next
val showing = visible && upNext != null && !held
LaunchedEffect(showing) {
if (!showing) return@LaunchedEffect
remaining = UP_NEXT_SECONDS
while (remaining > 0) {
delay(1_000)
remaining -= 1
}
onPlayNow()
}
// `showing` already implies a non-null episode, and the compiler knows it.
if (!showing) return
Column(
modifier
.background(Color.Black.copy(alpha = 0.85f), RoundedCornerShape(12.dp))
.padding(12.dp),
) {
Text("Up next in ${remaining}s", color = Color.White, style = MaterialTheme.typography.labelMedium)
Text(
upNext.label,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleSmall,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 8.dp)) {
Button(onClick = onPlayNow) { Text("Play now") }
TextButton(onClick = { held = true }) { Text("Watch till the end", color = Color.White) }
}
}
}
@Composable
private fun TrackChooser(
type: String,
tracks: List<MpvTrack>,
onDismiss: () -> Unit,
onSelect: (MpvTrack?) -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(if (type == "audio") "Audio track" else "Subtitle track") },
text = {
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
if (type == "sub") {
item {
OutlinedButton(onClick = { onSelect(null) }, Modifier.fillMaxWidth()) {
Text(if (tracks.none(MpvTrack::selected)) "✓ Off" else "Off")
}
}
}
items(tracks, key = MpvTrack::id) { track ->
OutlinedButton(onClick = { onSelect(track) }, Modifier.fillMaxWidth()) {
Text(if (track.selected) "✓ ${track.label}" else track.label)
}
}
}
},
confirmButton = { Button(onClick = onDismiss) { Text("Close") } },
)
}
@Composable
private fun CompactIcon(
icon: androidx.compose.ui.graphics.vector.ImageVector,
description: String,
enabled: Boolean = true,
onClick: () -> Unit,
) {
IconButton(onClick = onClick, enabled = enabled, modifier = Modifier.size(40.dp)) {
Icon(
icon,
contentDescription = description,
tint = if (enabled) Color.White else Color.White.copy(alpha = 0.35f),
modifier = Modifier.size(22.dp),
)
}
}
private fun ((Double) -> Unit).asFloat(): (Float) -> Unit = { this(it.toDouble()) }
/** How much of the episode is left, or nothing before the duration is known. */
private fun remaining(state: MpvPlaybackState): String? = (state.duration - state.position)
.takeIf { state.duration > 0.0 && it >= 1.0 }
?.let { "${formatTime(it)} left" }
private fun formatTime(seconds: Double): String {
val total = seconds.coerceAtLeast(0.0).roundToInt()
val hours = total / 3_600
return if (hours > 0) "%d:%02d:%02d".format(hours, (total % 3_600) / 60, total % 60)
else "%d:%02d".format(total / 60, total % 60)
}
@@ -0,0 +1,102 @@
package io.narl.protonstream.playback
import android.view.Surface
import io.narl.protonstream.native.NativeRuntime
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import uniffi.pstr_android.AndroidEngine
import uniffi.pstr_android.AndroidStream
import uniffi.pstr_android.EpisodeRecord
/** Exact seekable stream contract a bundled libmpv JNI adapter must consume. */
class RustStreamSession(
private val episode: EpisodeRecord,
) {
private var engine: AndroidEngine? = null
private var stream: AndroidStream? = null
private var handle: ULong? = null
private var streamSize: ULong? = null
suspend fun open(): RustStreamSession = withContext(Dispatchers.IO) {
val openedEngine = NativeRuntime.engine()
engine = openedEngine
stream = openedEngine.openStream(episode.shareId, episode.volumeId, episode.linkId).also {
streamSize = it.size()
handle = it.nativeHandle()
}
this@RustStreamSession
}
fun nativeHandle(): ULong = checkNotNull(handle) { "stream is not published" }
fun size(): ULong = checkNotNull(streamSize) { "stream is not open" }
fun releaseNative(host: LibmpvHost) {
handle?.let(host::releaseNativeStream)
handle = null
streamSize = null
stream = null
}
/** The native player now owns the published token until stop/end/close. */
fun transferToPlayer() {
handle = null
streamSize = null
stream = null
}
/**
* Give the episode's stream back to Rust, from a scope of this session's own.
*
* Deliberately not the caller's: the one moment this has to run is when the
* player leaves the composition, which is the same moment a
* `rememberCoroutineScope` is cancelled — so a release launched there is
* cancelled before it starts, and the stream is held for the life of the
* process. Once per episode played, that is every episode.
*/
fun close(host: LibmpvHost) {
releaseNative(host)
sessions.launch {
runCatching { engine?.releaseStream(episode.shareId, episode.volumeId, episode.linkId) }
engine = null
}
}
}
/**
* Where stream teardown runs: process-lifetime, like the engine it releases to,
* and never cancelled by a screen going away.
*/
private val sessions = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/** Implement only in the variant that bundles a pinned libmpv native artifact. */
interface LibmpvHost {
fun attachSurface(surface: Surface)
fun detachSurface()
/**
* Consumes the opaque handle through pstr_android_stream_{read,size}.
*
* [media] names the episode being loaded. One host serves every episode in
* turn, so its readings have to say which one they describe — see
* [MpvPlaybackState.media].
*/
suspend fun play(
media: String,
nativeHandle: ULong,
size: ULong,
startPosition: Double = 0.0,
audioLanguage: String? = null,
subtitleLanguage: String? = null,
subtitles: Boolean = true,
)
/** Calls pstr_android_stream_release exactly once for a published handle. */
fun releaseNativeStream(nativeHandle: ULong)
}
/** How an episode is named in watch state, work names and player readings. */
fun mediaKey(episode: EpisodeRecord) = "${episode.shareId}:${episode.linkId}"