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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+10
-4
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user