feat: persistent audio mini-player docked above bottom nav

Replaces the basic inline audio widget with a global mini-player that persists
across feed scrolling and tab navigation. Playback continues with system
notification / lock-screen transport controls via MediaSessionService.

The dock has two states: a collapsed row showing the author's avatar and name
with rewind-15 / play-pause / forward-15; swipe up to expand for a scrub slider,
speed cycling, and close. Single ExoPlayer is owned by AudioPlayerController
and shared with the inline tap-to-play widget, which now reflects global
playback state.
This commit is contained in:
Barry Deen
2026-04-22 22:21:41 -04:00
parent ebeebccbe9
commit 06ec5592d3
9 changed files with 619 additions and 82 deletions
+1 -1
View File
@@ -44,7 +44,7 @@
</intent-filter>
</activity>
<service
android:name=".ui.component.VideoPlaybackService"
android:name=".ui.component.WispPlaybackService"
android:foregroundServiceType="mediaPlayback"
android:exported="true">
<intent-filter>
+24 -19
View File
@@ -2,6 +2,7 @@ package com.wisp.app
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.dp
@@ -90,6 +91,7 @@ import com.wisp.app.ui.screen.OnboardingScreen
import com.wisp.app.ui.component.AddNoteToListDialog
import com.wisp.app.ui.component.CrashReportDialog
import androidx.media3.exoplayer.ExoPlayer
import com.wisp.app.ui.component.FloatingAudioPlayer
import com.wisp.app.ui.component.FloatingVideoPlayer
import com.wisp.app.ui.component.PipController
import com.wisp.app.ui.component.FullScreenVideoPlayer
@@ -627,27 +629,30 @@ fun WispNavHost(
Scaffold(
contentWindowInsets = WindowInsets(0, 0, 0, 0),
bottomBar = {
if (showBottomBar) {
WispBottomBar(
currentRoute = currentRoute,
hasUnreadHome = newNoteCount > 0,
hasUnreadMessages = hasUnreadDms,
hasUnreadNotifications = hasUnreadNotifications,
isZapAnimating = isZapAnimating,
isReplyAnimating = isReplyAnimating,
notifSoundEnabled = notifSoundEnabled,
onTabSelected = { tab ->
if (currentRoute == tab.route) {
scrollToTopTrigger++
} else {
if (tab == BottomTab.WALLET) walletViewModel.navigateHome()
navController.navigate(tab.route) {
popUpTo(Routes.FEED) { inclusive = false }
launchSingleTop = true
Column {
FloatingAudioPlayer()
if (showBottomBar) {
WispBottomBar(
currentRoute = currentRoute,
hasUnreadHome = newNoteCount > 0,
hasUnreadMessages = hasUnreadDms,
hasUnreadNotifications = hasUnreadNotifications,
isZapAnimating = isZapAnimating,
isReplyAnimating = isReplyAnimating,
notifSoundEnabled = notifSoundEnabled,
onTabSelected = { tab ->
if (currentRoute == tab.route) {
scrollToTopTrigger++
} else {
if (tab == BottomTab.WALLET) walletViewModel.navigateHome()
navController.navigate(tab.route) {
popUpTo(Routes.FEED) { inclusive = false }
launchSingleTop = true
}
}
}
}
)
)
}
}
}
) { innerPadding ->
@@ -0,0 +1,33 @@
package com.wisp.app.ui.component
import android.content.Context
import android.content.Intent
import androidx.media3.common.Player
import androidx.media3.session.MediaSession
object AudioMediaSession {
internal var session: MediaSession? = null
private set
private var appContext: Context? = null
fun attach(context: Context, player: Player) {
if (session?.player === player) return
val ctx = context.applicationContext
release()
appContext = ctx
session = MediaSession.Builder(ctx, player).setId("wisp-audio").build()
ctx.startService(Intent(ctx, WispPlaybackService::class.java))
}
fun release() {
session?.release()
session = null
appContext?.let {
// Only stop the service if video isn't also holding it.
if (VideoMediaSession.session == null) {
it.stopService(Intent(it, WispPlaybackService::class.java))
}
}
appContext = null
}
}
@@ -0,0 +1,197 @@
package com.wisp.app.ui.component
import android.content.Context
import android.net.Uri
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class AudioTrack(
val url: String,
val title: String? = null,
val artist: String? = null,
val artworkUrl: String? = null,
val authorPubkey: String? = null
)
data class AudioPlaybackState(
val track: AudioTrack,
val isPlaying: Boolean,
val positionMs: Long,
val durationMs: Long,
val bufferedMs: Long,
val speed: Float,
val isBuffering: Boolean
)
object AudioPlayerController {
private val _state = MutableStateFlow<AudioPlaybackState?>(null)
val state: StateFlow<AudioPlaybackState?> = _state.asStateFlow()
private val scope: CoroutineScope = MainScope()
private var player: ExoPlayer? = null
private var listener: Player.Listener? = null
private var pollJob: Job? = null
private var appContext: Context? = null
private var currentTrack: AudioTrack? = null
val speedSteps = listOf(0.75f, 1.0f, 1.25f, 1.5f, 2.0f)
fun play(context: Context, track: AudioTrack) {
val ctx = context.applicationContext
appContext = ctx
val existing = player
if (existing != null && currentTrack?.url == track.url) {
existing.play()
return
}
// Pause any currently-playing video so we don't double-play.
PipController.pipState.value?.player?.pause()
val p = existing ?: ExoPlayer.Builder(ctx).build().also { newPlayer ->
attachListener(newPlayer)
player = newPlayer
}
val item = MediaItem.Builder()
.setUri(track.url)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(track.title ?: track.url.substringAfterLast('/').substringBeforeLast('.'))
.setArtist(track.artist)
.apply { track.artworkUrl?.let { setArtworkUri(Uri.parse(it)) } }
.build()
)
.build()
p.setMediaItem(item)
p.prepare()
p.playWhenReady = true
currentTrack = track
_state.value = AudioPlaybackState(
track = track,
isPlaying = false,
positionMs = 0L,
durationMs = 0L,
bufferedMs = 0L,
speed = p.playbackParameters.speed,
isBuffering = true
)
AudioMediaSession.attach(ctx, p)
startPolling()
}
fun togglePlayPause() {
val p = player ?: return
if (p.isPlaying) p.pause() else p.play()
}
fun seekTo(ms: Long) {
player?.seekTo(ms.coerceAtLeast(0L))
updateStateFromPlayer()
}
fun skipForward(deltaMs: Long = 15_000L) {
val p = player ?: return
val target = (p.currentPosition + deltaMs).coerceAtMost(
if (p.duration > 0) p.duration else Long.MAX_VALUE
)
p.seekTo(target)
updateStateFromPlayer()
}
fun skipBackward(deltaMs: Long = 15_000L) {
val p = player ?: return
p.seekTo((p.currentPosition - deltaMs).coerceAtLeast(0L))
updateStateFromPlayer()
}
fun cycleSpeed() {
val p = player ?: return
val current = p.playbackParameters.speed
val idx = speedSteps.indexOfFirst { kotlin.math.abs(it - current) < 0.01f }
val next = speedSteps[(if (idx < 0) 1 else idx + 1) % speedSteps.size]
p.playbackParameters = PlaybackParameters(next)
updateStateFromPlayer()
}
fun setSpeed(speed: Float) {
player?.playbackParameters = PlaybackParameters(speed)
updateStateFromPlayer()
}
fun close() {
pollJob?.cancel()
pollJob = null
listener?.let { l -> player?.removeListener(l) }
listener = null
AudioMediaSession.release()
player?.release()
player = null
currentTrack = null
_state.value = null
}
private fun attachListener(p: ExoPlayer) {
val l = object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
updateStateFromPlayer()
if (isPlaying) startPolling()
}
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_ENDED) {
p.pause()
p.seekTo(0L)
}
updateStateFromPlayer()
}
override fun onPlaybackParametersChanged(playbackParameters: PlaybackParameters) {
updateStateFromPlayer()
}
}
p.addListener(l)
listener = l
}
private fun startPolling() {
if (pollJob?.isActive == true) return
pollJob = scope.launch(Dispatchers.Main) {
while (true) {
updateStateFromPlayer()
val p = player ?: break
if (!p.isPlaying) break
delay(250L)
}
}
}
private fun updateStateFromPlayer() {
val p = player ?: return
val track = currentTrack ?: return
_state.value = AudioPlaybackState(
track = track,
isPlaying = p.isPlaying,
positionMs = p.currentPosition.coerceAtLeast(0L),
durationMs = p.duration.coerceAtLeast(0L),
bufferedMs = p.bufferedPosition.coerceAtLeast(0L),
speed = p.playbackParameters.speed,
isBuffering = p.playbackState == Player.STATE_BUFFERING
)
}
}
@@ -0,0 +1,284 @@
package com.wisp.app.ui.component
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Forward10
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Replay10
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
@Composable
fun FloatingAudioPlayer(modifier: Modifier = Modifier) {
val state by AudioPlayerController.state.collectAsState()
AnimatedVisibility(
visible = state != null,
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(),
modifier = modifier
) {
val s = state ?: return@AnimatedVisibility
FloatingAudioPlayerContent(s)
}
}
@Composable
private fun FloatingAudioPlayerContent(state: AudioPlaybackState) {
var expanded by remember { mutableStateOf(false) }
Surface(
tonalElevation = 4.dp,
shadowElevation = 8.dp,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.pointerInput(Unit) {
detectVerticalDragGestures(
onDragEnd = { /* no-op; toggle below */ }
) { _, dragAmount ->
// Negative dragAmount = swipe up; positive = swipe down.
if (dragAmount < -4f && !expanded) expanded = true
else if (dragAmount > 4f && expanded) expanded = false
}
}
) {
// Drag handle
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 6.dp, bottom = 2.dp),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.width(36.dp)
.height(4.dp)
.clip(RoundedCornerShape(2.dp))
.background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f))
)
}
// Always-visible row: avatar, name, transport controls
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded }
.padding(horizontal = 12.dp, vertical = 6.dp)
) {
AuthorAvatar(state.track.artworkUrl)
Spacer(Modifier.width(10.dp))
Text(
text = state.track.title ?: "Audio",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Spacer(Modifier.width(4.dp))
IconButton(
onClick = { AudioPlayerController.skipBackward() },
modifier = Modifier.size(36.dp)
) {
Icon(
Icons.Filled.Replay10,
contentDescription = "Skip back 15 seconds",
tint = MaterialTheme.colorScheme.onSurface
)
}
IconButton(
onClick = { AudioPlayerController.togglePlayPause() },
modifier = Modifier.size(44.dp)
) {
Icon(
if (state.isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = if (state.isPlaying) "Pause" else "Play",
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(32.dp)
)
}
IconButton(
onClick = { AudioPlayerController.skipForward() },
modifier = Modifier.size(36.dp)
) {
Icon(
Icons.Filled.Forward10,
contentDescription = "Skip forward 15 seconds",
tint = MaterialTheme.colorScheme.onSurface
)
}
}
// Expanded controls
AnimatedVisibility(
visible = expanded,
enter = expandVertically(animationSpec = tween(220)) + fadeIn(),
exit = shrinkVertically(animationSpec = tween(180)) + fadeOut()
) {
ExpandedControls(state)
}
}
}
}
@Composable
private fun AuthorAvatar(url: String?) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
if (!url.isNullOrBlank()) {
AsyncImage(
model = url,
contentDescription = null,
modifier = Modifier.size(40.dp).clip(CircleShape)
)
} else {
Icon(
Icons.Filled.Person,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun ExpandedControls(state: AudioPlaybackState) {
var scrubbing by remember { mutableStateOf(false) }
var scrubValue by remember { mutableStateOf(0f) }
val position = if (scrubbing) scrubValue.toLong() else state.positionMs
val duration = state.durationMs.coerceAtLeast(0L)
val sliderMax = duration.coerceAtLeast(1L).toFloat()
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
text = formatPlayerTime(position),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.width(44.dp)
)
Slider(
value = position.toFloat().coerceIn(0f, sliderMax),
valueRange = 0f..sliderMax,
onValueChange = {
scrubbing = true
scrubValue = it
},
onValueChangeFinished = {
AudioPlayerController.seekTo(scrubValue.toLong())
scrubbing = false
},
enabled = duration > 0,
colors = SliderDefaults.colors(
thumbColor = MaterialTheme.colorScheme.primary,
activeTrackColor = MaterialTheme.colorScheme.primary,
inactiveTrackColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
),
modifier = Modifier.weight(1f).height(24.dp)
)
Text(
text = formatPlayerTime(duration),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.width(44.dp)
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp)
) {
Text(
text = formatSpeed(state.speed),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f))
.clickable { AudioPlayerController.cycleSpeed() }
.padding(horizontal = 12.dp, vertical = 6.dp)
)
Spacer(Modifier.weight(1f))
IconButton(
onClick = { AudioPlayerController.close() },
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Filled.Close,
contentDescription = "Close player",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
private fun formatPlayerTime(ms: Long): String {
if (ms <= 0L) return "0:00"
val totalSeconds = (ms / 1000).toInt()
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return "%d:%02d".format(minutes, seconds)
}
private fun formatSpeed(speed: Float): String {
val trimmed = "%.2f".format(speed).trimEnd('0').trimEnd('.')
return "${trimmed}x"
}
@@ -569,7 +569,8 @@ fun PostCard(
eventRepo = eventRepo,
onProfileClick = onNavigateToProfile,
onNoteClick = onQuotedNoteClick,
noteActions = noteActions
noteActions = noteActions,
authorPubkey = event.pubkey
)
} else {
// Collapsible content with max height (~1 viewport)
@@ -135,6 +135,13 @@ data class MediaSettings(
val LocalMediaSettings = compositionLocalOf { MediaSettings() }
internal data class AudioPostContext(
val authorPubkey: String? = null,
val eventRepo: EventRepository? = null
)
internal val LocalAudioPostContext = compositionLocalOf { AudioPostContext() }
/**
* Bundles event-generic action callbacks so quoted notes can render
* a full PostCard (action bar, triple-dot menu, expandable details, etc.).
@@ -597,6 +604,7 @@ fun RichContent(
onHashtagClick: ((String) -> Unit)? = null,
onLiveStreamClick: ((String, String, String?) -> Unit)? = null,
noteActions: NoteActions? = null,
authorPubkey: String? = null,
modifier: Modifier = Modifier
) {
val segments = remember(content, emojiMap, imetaMap, plainLinks) { parseContent(content.trimEnd('\n', '\r'), emojiMap, imetaMap, trimBlankLines = !plainLinks) }
@@ -641,6 +649,9 @@ fun RichContent(
val effectiveRelayClick = noteActions?.onRelayClick
SelectionContainer {
androidx.compose.runtime.CompositionLocalProvider(
LocalAudioPostContext provides AudioPostContext(authorPubkey = authorPubkey, eventRepo = eventRepo)
) {
Column(modifier = modifier) {
for (group in groups) {
if (group is List<*>) {
@@ -800,7 +811,11 @@ fun RichContent(
)
}
is ContentSegment.AudioSegment -> {
InlineAudioPlayer(meta = segment.meta)
InlineAudioPlayer(
meta = segment.meta,
authorPubkey = authorPubkey,
eventRepo = eventRepo
)
}
is ContentSegment.UnknownMediaSegment -> {
UnknownMediaContent(
@@ -926,6 +941,7 @@ fun RichContent(
}
}
}
}
}
@Composable
@@ -1855,7 +1871,8 @@ private fun UnknownMediaContent(
InlineVideoPlayerWithFullscreen(meta = meta, onFullScreen = onFullScreenVideo)
}
resolved == "audio" -> {
InlineAudioPlayer(meta = meta)
val ctx = LocalAudioPostContext.current
InlineAudioPlayer(meta = meta, authorPubkey = ctx.authorPubkey, eventRepo = ctx.eventRepo)
}
else -> {
// Fallback: try loading as image (most blossom content is images)
@@ -1864,15 +1881,41 @@ private fun UnknownMediaContent(
}
}
@OptIn(UnstableApi::class)
@Composable
private fun InlineAudioPlayer(meta: MediaMeta) {
private fun InlineAudioPlayer(
meta: MediaMeta,
authorPubkey: String? = null,
eventRepo: EventRepository? = null
) {
val url = meta.url
val context = LocalContext.current
val autoLoad = LocalMediaSettings.current.autoLoadMedia
var loaded by remember { mutableStateOf(autoLoad) }
if (!loaded) {
val globalState by AudioPlayerController.state.collectAsState()
val isCurrent = globalState?.track?.url == url
val isPlaying = isCurrent && globalState?.isPlaying == true
val profileVer = eventRepo?.profileVersion?.collectAsState()?.value ?: 0
val profile = remember(authorPubkey, profileVer) {
authorPubkey?.let { eventRepo?.getProfileData(it) }
}
LaunchedEffect(authorPubkey) {
if (authorPubkey != null) eventRepo?.requestProfileIfMissing(authorPubkey, emptyList())
}
val title = profile?.displayString
?: url.substringAfterLast('/').substringBeforeLast('.').ifBlank { "Audio" }
fun buildTrack() = AudioTrack(
url = url,
title = title,
artist = null,
artworkUrl = profile?.picture,
authorPubkey = authorPubkey
)
if (!loaded && !isCurrent) {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
@@ -1880,7 +1923,10 @@ private fun InlineAudioPlayer(meta: MediaMeta) {
.fillMaxWidth()
.padding(vertical = 4.dp)
.clip(RoundedCornerShape(12.dp))
.clickable { loaded = true }
.clickable {
loaded = true
AudioPlayerController.play(context, buildTrack())
}
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -1893,7 +1939,7 @@ private fun InlineAudioPlayer(meta: MediaMeta) {
)
Spacer(Modifier.width(8.dp))
Text(
text = "Tap to load audio",
text = "Tap to play audio",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -1902,48 +1948,6 @@ private fun InlineAudioPlayer(meta: MediaMeta) {
return
}
val player = remember {
ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(url))
prepare()
}
}
DisposableEffect(url) {
onDispose { player.release() }
}
var isPlaying by remember { mutableStateOf(false) }
var currentPosition by remember { mutableLongStateOf(0L) }
var duration by remember { mutableLongStateOf(0L) }
DisposableEffect(player) {
val listener = object : Player.Listener {
override fun onIsPlayingChanged(playing: Boolean) {
isPlaying = playing
}
override fun onPlaybackStateChanged(state: Int) {
if (state == Player.STATE_READY) {
duration = player.duration.coerceAtLeast(0L)
}
}
}
player.addListener(listener)
onDispose { player.removeListener(listener) }
}
// Poll position while playing
LaunchedEffect(isPlaying) {
while (isPlaying) {
currentPosition = player.currentPosition.coerceAtLeast(0L)
withContext(Dispatchers.Main) {
kotlinx.coroutines.delay(250)
}
}
// Update once more when paused
currentPosition = player.currentPosition.coerceAtLeast(0L)
}
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
@@ -1958,8 +1962,11 @@ private fun InlineAudioPlayer(meta: MediaMeta) {
) {
IconButton(
onClick = {
if (isPlaying) player.pause()
else player.play()
if (isCurrent) {
AudioPlayerController.togglePlayPause()
} else {
AudioPlayerController.play(context, buildTrack())
}
}
) {
Icon(
@@ -1969,8 +1976,9 @@ private fun InlineAudioPlayer(meta: MediaMeta) {
)
}
// Progress bar
val progress = if (duration > 0) currentPosition.toFloat() / duration.toFloat() else 0f
val position = if (isCurrent) globalState?.positionMs ?: 0L else 0L
val duration = if (isCurrent) globalState?.durationMs ?: 0L else 0L
val progress = if (duration > 0) position.toFloat() / duration.toFloat() else 0f
androidx.compose.material3.LinearProgressIndicator(
progress = { progress },
modifier = Modifier
@@ -1983,9 +1991,8 @@ private fun InlineAudioPlayer(meta: MediaMeta) {
Spacer(Modifier.width(8.dp))
// Timestamp
Text(
text = formatAudioTime(currentPosition) + " / " + formatAudioTime(duration),
text = formatAudioTime(position) + " / " + formatAudioTime(duration),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -1994,6 +2001,7 @@ private fun InlineAudioPlayer(meta: MediaMeta) {
}
private fun formatAudioTime(ms: Long): String {
if (ms <= 0L) return "0:00"
val totalSeconds = (ms / 1000).toInt()
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
@@ -16,14 +16,17 @@ object VideoMediaSession {
release()
appContext = ctx
session = MediaSession.Builder(ctx, player).build()
ctx.startService(Intent(ctx, VideoPlaybackService::class.java))
ctx.startService(Intent(ctx, WispPlaybackService::class.java))
}
fun release() {
session?.release()
session = null
appContext?.let {
it.stopService(Intent(it, VideoPlaybackService::class.java))
// Only stop the service if audio isn't also holding it.
if (AudioMediaSession.session == null) {
it.stopService(Intent(it, WispPlaybackService::class.java))
}
}
appContext = null
}
@@ -4,27 +4,33 @@ import android.content.Intent
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
class VideoPlaybackService : MediaSessionService() {
class WispPlaybackService : MediaSessionService() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
VideoMediaSession.session?.let { addSession(it) }
AudioMediaSession.session?.let { addSession(it) }
return START_NOT_STICKY
}
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
return VideoMediaSession.session
// Prefer audio when both exist (more likely to be the backgrounded stream).
return AudioMediaSession.session ?: VideoMediaSession.session
}
override fun onTaskRemoved(rootIntent: Intent?) {
val session = VideoMediaSession.session
if (session == null || !session.player.playWhenReady) {
val audio = AudioMediaSession.session
val video = VideoMediaSession.session
val audioActive = audio != null && audio.player.playWhenReady
val videoActive = video != null && video.player.playWhenReady
if (!audioActive && !videoActive) {
stopSelf()
}
}
override fun onDestroy() {
VideoMediaSession.session?.let { removeSession(it) }
AudioMediaSession.session?.let { removeSession(it) }
super.onDestroy()
}
}