mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-13 09:37:03 +01:00
added android client
This commit is contained in:
1 parent
8b95cf7a05
commit
6db6909824
73 files changed
+9234
-106
No files matched your search
@@ -0,0 +1,273 @@
|
||||
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.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.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.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) {
|
||||
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() }
|
||||
}
|
||||
}
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
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()
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
if (host is NativeMpvHost) PlayerControls(titleKey, episode, host, Modifier.align(Alignment.BottomCenter))
|
||||
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, 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) {
|
||||
Button(onClick = { host.setPaused(!state.paused) }) { Text(if (state.paused) "Play" else "Pause") }
|
||||
Text("${formatTime(state.position)} / ${formatTime(state.duration)}", color = Color.White)
|
||||
Button(onClick = { host.setMuted(!state.muted) }) { Text(if (state.muted) "Unmute" else "Mute") }
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { choosing = "audio" }) {
|
||||
Text("Audio: ${tracks.firstOrNull { it.type == "audio" && it.selected }?.label ?: "default"}", maxLines = 1)
|
||||
}
|
||||
Button(onClick = { choosing = "sub" }) {
|
||||
Text("Subtitles: ${tracks.firstOrNull { it.type == "sub" && it.selected }?.label ?: "off"}", maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package io.narl.protonstream.playback
|
||||
|
||||
import android.view.Surface
|
||||
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.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
|
||||
data class MpvPlaybackState(
|
||||
val position: Double = 0.0,
|
||||
val duration: Double = 0.0,
|
||||
val volume: Double = 100.0,
|
||||
val paused: Boolean = true,
|
||||
val muted: Boolean = false,
|
||||
val ended: Boolean = false,
|
||||
)
|
||||
|
||||
data class MpvTrack(
|
||||
val id: Long,
|
||||
val type: String,
|
||||
val language: String,
|
||||
val title: String,
|
||||
val selected: Boolean,
|
||||
) {
|
||||
val label: String get() = listOf(language, title).filter(String::isNotBlank)
|
||||
.joinToString(" — ").ifBlank { "${type.replaceFirstChar(Char::uppercase)} $id" }
|
||||
}
|
||||
|
||||
/** 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" } }
|
||||
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 var poller: Job? = null
|
||||
private var currentStreamHandle: Long? = null
|
||||
var onPlaybackStarted: (() -> Unit)? = null
|
||||
var onStateChanged: ((MpvPlaybackState) -> Unit)? = null
|
||||
var onExplicitStop: (() -> Unit)? = null
|
||||
|
||||
val state: StateFlow<MpvPlaybackState> = mutableState.asStateFlow()
|
||||
val tracks: StateFlow<List<MpvTrack>> = mutableTracks.asStateFlow()
|
||||
val hasActivePlayback: Boolean get() = mutableState.value.duration > 0.0 && !mutableState.value.ended
|
||||
|
||||
override fun attachSurface(surface: Surface) {
|
||||
withOpenHandle { nativeAttachSurface(it, surface) }
|
||||
}
|
||||
|
||||
override fun detachSurface() {
|
||||
withOpenHandle { nativeDetachSurface(it) }
|
||||
}
|
||||
|
||||
override suspend fun play(
|
||||
nativeHandle: ULong,
|
||||
size: ULong,
|
||||
startPosition: Double,
|
||||
audioLanguage: String?,
|
||||
subtitleLanguage: String?,
|
||||
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()
|
||||
}
|
||||
}
|
||||
} ?: false
|
||||
check(loaded) {
|
||||
"libmpv rejected the stream or no video surface was available"
|
||||
}
|
||||
}
|
||||
withOpenHandle {
|
||||
onPlaybackStarted?.invoke()
|
||||
startPolling()
|
||||
}
|
||||
}
|
||||
|
||||
fun setPaused(paused: Boolean) { withOpenHandle { nativePause(it, paused) } }
|
||||
fun seek(position: Double) { withOpenHandle { nativeSeek(it, position) } }
|
||||
fun setVolume(volume: Double) { withOpenHandle { nativeVolume(it, volume) } }
|
||||
fun setMuted(muted: Boolean) { withOpenHandle { nativeMute(it, muted) } }
|
||||
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. */
|
||||
fun stop() {
|
||||
val callback = withOpenHandle {
|
||||
nativeStop(it)
|
||||
currentStreamHandle?.let(::pstrAndroidStreamRelease)
|
||||
currentStreamHandle = 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 {
|
||||
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)
|
||||
mutableState.value = next
|
||||
onStateChanged?.invoke(next)
|
||||
}
|
||||
if (tick++ % 4 == 0) refreshTracks()
|
||||
delay(250)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshTracks() {
|
||||
runCatching {
|
||||
val encoded = withOpenHandle(::nativeTracks) ?: return
|
||||
val values = JSONArray(encoded)
|
||||
List(values.length()) { index ->
|
||||
values.getJSONObject(index).run {
|
||||
MpvTrack(getLong("id"), getString("type"), getString("language"), getString("title"), getBoolean("selected"))
|
||||
}
|
||||
}
|
||||
}.onSuccess { mutableTracks.value = it }
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
scope.cancel()
|
||||
lifecycle.withLock {
|
||||
if (closed) return@withLock
|
||||
closed = true
|
||||
onPlaybackStarted = null
|
||||
onStateChanged = null
|
||||
onExplicitStop = null
|
||||
nativeDestroy(nativeHandle)
|
||||
currentStreamHandle?.let(::pstrAndroidStreamRelease)
|
||||
currentStreamHandle = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Serializes destruction against every JNI use of the raw Player pointer. */
|
||||
private inline fun <T> withOpenHandle(block: (Long) -> T): T? = lifecycle.withLock {
|
||||
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)
|
||||
private external fun nativeLoad(handle: Long, stream: Long, start: Double, audio: String?, subtitle: String?, subtitles: Boolean): Boolean
|
||||
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 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 pstrAndroidStreamRelease(handle: Long)
|
||||
|
||||
companion object {
|
||||
init {
|
||||
System.loadLibrary("pstr_android")
|
||||
System.loadLibrary("pstr_mpv")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package io.narl.protonstream.playback
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.media.MediaMetadata
|
||||
import android.media.session.MediaSession
|
||||
import android.media.session.PlaybackState
|
||||
import android.os.Binder
|
||||
import android.os.IBinder
|
||||
import io.narl.protonstream.MainActivity
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
|
||||
/** Owns libmpv so audio survives Activity recreation, rotation and PiP. */
|
||||
class PlaybackService : Service() {
|
||||
inner class PlaybackBinder : Binder() {
|
||||
val host: NativeMpvHost get() = this@PlaybackService.host
|
||||
}
|
||||
|
||||
private lateinit var host: NativeMpvHost
|
||||
private lateinit var mediaSession: MediaSession
|
||||
private lateinit var audioManager: AudioManager
|
||||
private var audioFocus: AudioFocusRequest? = null
|
||||
private var notifiedPaused: Boolean? = null
|
||||
private var foregroundPlayback = false
|
||||
private var sessionPlayback = false
|
||||
private val binder = PlaybackBinder()
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createChannel()
|
||||
audioManager = getSystemService(AudioManager::class.java)
|
||||
host = NativeMpvHost().apply {
|
||||
onPlaybackStarted = {
|
||||
requestAudioFocus()
|
||||
sessionPlayback = true
|
||||
if (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
|
||||
}
|
||||
mediaSession.isActive = true
|
||||
}
|
||||
onStateChanged = { updateSession(it) }
|
||||
onExplicitStop = { retirePlayback() }
|
||||
}
|
||||
mediaSession = MediaSession(this, "proton-stream").apply {
|
||||
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 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()
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
audioFocus?.let(audioManager::abandonAudioFocusRequest)
|
||||
mediaSession.release()
|
||||
host.close()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun requestAudioFocus() {
|
||||
audioFocus?.let(audioManager::abandonAudioFocusRequest)
|
||||
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE).build(),
|
||||
)
|
||||
.setOnAudioFocusChangeListener { focus ->
|
||||
when (focus) {
|
||||
AudioManager.AUDIOFOCUS_LOSS, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> host.setPaused(true)
|
||||
AudioManager.AUDIOFOCUS_GAIN -> Unit
|
||||
}
|
||||
}.build()
|
||||
audioFocus = request
|
||||
audioManager.requestAudioFocus(request)
|
||||
}
|
||||
|
||||
private fun updateSession(state: MpvPlaybackState) {
|
||||
val status = when {
|
||||
state.ended -> PlaybackState.STATE_STOPPED
|
||||
state.paused -> PlaybackState.STATE_PAUSED
|
||||
else -> PlaybackState.STATE_PLAYING
|
||||
}
|
||||
mediaSession.setPlaybackState(
|
||||
PlaybackState.Builder()
|
||||
.setActions(PlaybackState.ACTION_PLAY or PlaybackState.ACTION_PAUSE or PlaybackState.ACTION_SEEK_TO or PlaybackState.ACTION_STOP)
|
||||
.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(),
|
||||
)
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
private fun retirePlayback() {
|
||||
if (!sessionPlayback && !foregroundPlayback) return
|
||||
sessionPlayback = false
|
||||
notifiedPaused = null
|
||||
mediaSession.isActive = false
|
||||
audioFocus?.let(audioManager::abandonAudioFocusRequest)
|
||||
audioFocus = null
|
||||
if (foregroundPlayback) stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
foregroundPlayback = false
|
||||
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,
|
||||
)
|
||||
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)
|
||||
.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))
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun createChannel() {
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(
|
||||
NotificationChannel(CHANNEL_ID, "Playback", NotificationManager.IMPORTANCE_LOW),
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "playback"
|
||||
private const val NOTIFICATION_ID = 47
|
||||
private const val ACTION_PLAY = "io.narl.protonstream.PLAY"
|
||||
private const val ACTION_PAUSE = "io.narl.protonstream.PAUSE"
|
||||
private const val ACTION_STOP = "io.narl.protonstream.STOP"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user