feat: add video/audio playback in Feed and Detail screens

- Add Media3 ExoPlayer dependencies (media3-exoplayer, media3-ui 1.2.1)
- Extend FeedPost with videoUrl and audioUrl fields
- Parse video/audio card URLs from mobiledoc JSON in FeedViewModel
- Map LocalPost video/audio URIs to FeedPost in toFeedPost()
- Create VideoPlayer composable: ExoPlayer in AndroidView, play button overlay, tap to play
- Create AudioPlayer composable: play/pause button, progress slider, duration text
- Integrate compact VideoPlayer and AudioPlayer in FeedScreen post cards
- Integrate full-size VideoPlayer and AudioPlayer in DetailScreen with reveal animations
- Load video/audio URIs when editing a post in ComposerViewModel
This commit is contained in:
Paweł Orzech 2026-03-20 00:49:24 +01:00
parent 27782893dc
commit a1aae661c9
7 changed files with 330 additions and 5 deletions

View file

@ -107,6 +107,10 @@ dependencies {
// Jsoup for OpenGraph parsing
implementation("org.jsoup:jsoup:1.17.2")
// Media3 for video/audio playback
implementation("androidx.media3:media3-exoplayer:1.2.1")
implementation("androidx.media3:media3-ui:1.2.1")
// Testing
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")

View file

@ -132,6 +132,8 @@ data class FeedPost(
val imageUrl: String?,
val imageAlt: String? = null,
val imageUrls: List<String> = emptyList(),
val videoUrl: String? = null,
val audioUrl: String? = null,
val linkUrl: String?,
val linkTitle: String?,
val linkDescription: String?,

View file

@ -0,0 +1,238 @@
package com.swoosh.microblog.ui.components
import android.view.ViewGroup
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
/**
* Video player composable using ExoPlayer.
* Shows a play button overlay; tap to play/pause. No autoplay.
*/
@Composable
fun VideoPlayer(
url: String,
modifier: Modifier = Modifier,
compact: Boolean = false
) {
val context = LocalContext.current
var isPlaying by remember { mutableStateOf(false) }
var showOverlay by remember { mutableStateOf(true) }
val exoPlayer = remember(url) {
ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(url))
prepare()
playWhenReady = false
}
}
DisposableEffect(exoPlayer) {
val listener = object : Player.Listener {
override fun onIsPlayingChanged(playing: Boolean) {
isPlaying = playing
if (!playing && exoPlayer.playbackState == Player.STATE_ENDED) {
showOverlay = true
}
}
}
exoPlayer.addListener(listener)
onDispose {
exoPlayer.removeListener(listener)
exoPlayer.release()
}
}
val height = if (compact) 180.dp else 240.dp
Box(
modifier = modifier
.fillMaxWidth()
.height(height)
.clip(MaterialTheme.shapes.medium)
.clickable {
if (isPlaying) {
exoPlayer.pause()
showOverlay = true
} else {
exoPlayer.play()
showOverlay = false
}
},
contentAlignment = Alignment.Center
) {
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
player = exoPlayer
useController = false
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}
},
modifier = Modifier.fillMaxSize()
)
// Play button overlay
if (showOverlay || !isPlaying) {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.8f)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(32.dp)
)
}
}
}
}
/**
* Compact audio player with play/pause button, progress slider, and duration text.
*/
@Composable
fun AudioPlayer(
url: String,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
var isPlaying by remember { mutableStateOf(false) }
var currentPosition by remember { mutableLongStateOf(0L) }
var duration by remember { mutableLongStateOf(0L) }
val exoPlayer = remember(url) {
ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(url))
prepare()
playWhenReady = false
}
}
DisposableEffect(exoPlayer) {
val listener = object : Player.Listener {
override fun onIsPlayingChanged(playing: Boolean) {
isPlaying = playing
}
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_READY) {
duration = exoPlayer.duration.coerceAtLeast(0L)
}
if (playbackState == Player.STATE_ENDED) {
isPlaying = false
exoPlayer.seekTo(0)
exoPlayer.pause()
}
}
}
exoPlayer.addListener(listener)
onDispose {
exoPlayer.removeListener(listener)
exoPlayer.release()
}
}
// Update position periodically while playing
LaunchedEffect(isPlaying) {
while (isPlaying) {
currentPosition = exoPlayer.currentPosition.coerceAtLeast(0L)
kotlinx.coroutines.delay(500)
}
}
OutlinedCard(
modifier = modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Music note icon
Icon(
imageVector = Icons.Default.MusicNote,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
// Play/Pause button
IconButton(
onClick = {
if (isPlaying) {
exoPlayer.pause()
} else {
exoPlayer.play()
}
},
modifier = Modifier.size(36.dp)
) {
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = MaterialTheme.colorScheme.primary
)
}
// Progress slider
Slider(
value = if (duration > 0) currentPosition.toFloat() / duration.toFloat() else 0f,
onValueChange = { fraction ->
val newPosition = (fraction * duration).toLong()
exoPlayer.seekTo(newPosition)
currentPosition = newPosition
},
modifier = Modifier.weight(1f),
colors = SliderDefaults.colors(
thumbColor = MaterialTheme.colorScheme.primary,
activeTrackColor = MaterialTheme.colorScheme.primary
)
)
Spacer(modifier = Modifier.width(8.dp))
// Duration text
Text(
text = formatDuration(if (isPlaying) currentPosition else duration),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
private fun formatDuration(millis: Long): String {
if (millis <= 0) return "0:00"
val totalSeconds = millis / 1000
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return "$minutes:${seconds.toString().padStart(2, '0')}"
}

View file

@ -97,6 +97,8 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
text = post.textContent,
imageUris = imageUris,
imageAlt = post.imageAlt ?: "",
videoUri = post.videoUrl?.let { url -> Uri.parse(url) },
audioUri = post.audioUrl?.let { url -> Uri.parse(url) },
linkPreview = if (post.linkUrl != null) LinkPreview(
url = post.linkUrl,
title = post.linkTitle,

View file

@ -53,7 +53,9 @@ import com.swoosh.microblog.data.model.LinkPreview
import com.swoosh.microblog.data.model.PostStats
import com.swoosh.microblog.data.model.QueueStatus
import com.swoosh.microblog.ui.animation.SwooshMotion
import com.swoosh.microblog.ui.components.AudioPlayer
import com.swoosh.microblog.ui.components.ConfirmationDialog
import com.swoosh.microblog.ui.components.VideoPlayer
import com.swoosh.microblog.ui.feed.FullScreenGallery
import com.swoosh.microblog.ui.feed.StatusBadge
import com.swoosh.microblog.ui.feed.formatRelativeTime
@ -94,7 +96,7 @@ fun DetailScreen(
}
// D1: Content reveal sequence
val revealCount = 6 // status, text, tags, gallery, link, stats
val revealCount = 8 // status, text, tags, gallery, video, audio, link, stats
val sectionVisible = remember { List(revealCount) { mutableStateOf(false) } }
LaunchedEffect(Unit) {
sectionVisible.forEachIndexed { index, state ->
@ -300,9 +302,31 @@ fun DetailScreen(
}
}
// Section 4 — Link preview
// Section 4 — Video player
AnimatedVisibility(
visible = sectionVisible[4].value && post.linkUrl != null,
visible = sectionVisible[4].value && post.videoUrl != null,
enter = fadeIn(SwooshMotion.quick()) + slideInVertically(initialOffsetY = { 20 }, animationSpec = SwooshMotion.gentle())
) {
Column {
Spacer(modifier = Modifier.height(16.dp))
VideoPlayer(url = post.videoUrl!!, compact = false)
}
}
// Section 5 — Audio player
AnimatedVisibility(
visible = sectionVisible[5].value && post.audioUrl != null,
enter = fadeIn(SwooshMotion.quick()) + slideInVertically(initialOffsetY = { 20 }, animationSpec = SwooshMotion.gentle())
) {
Column {
Spacer(modifier = Modifier.height(16.dp))
AudioPlayer(url = post.audioUrl!!)
}
}
// Section 6 — Link preview
AnimatedVisibility(
visible = sectionVisible[6].value && post.linkUrl != null,
enter = fadeIn(SwooshMotion.quick()) + slideInVertically(initialOffsetY = { 20 }, animationSpec = SwooshMotion.gentle())
) {
Column {
@ -348,9 +372,9 @@ fun DetailScreen(
}
}
// Section 5 — PostStatsSection
// Section 7 — PostStatsSection
AnimatedVisibility(
visible = sectionVisible[5].value,
visible = sectionVisible[7].value,
enter = slideInVertically(initialOffsetY = { it / 4 }, animationSpec = SwooshMotion.gentle()) + fadeIn(SwooshMotion.quick())
) {
Column {

View file

@ -90,7 +90,9 @@ import com.swoosh.microblog.data.model.PostFilter
import com.swoosh.microblog.data.model.PostStats
import com.swoosh.microblog.data.model.QueueStatus
import com.swoosh.microblog.data.model.SortOrder
import com.swoosh.microblog.ui.components.AudioPlayer
import com.swoosh.microblog.ui.components.ConfirmationDialog
import com.swoosh.microblog.ui.components.VideoPlayer
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
@Composable
@ -1555,6 +1557,18 @@ fun PostCardContent(
}
}
// Video player (compact)
if (post.videoUrl != null) {
Spacer(modifier = Modifier.height(12.dp))
VideoPlayer(url = post.videoUrl, compact = true)
}
// Audio player (compact)
if (post.audioUrl != null) {
Spacer(modifier = Modifier.height(12.dp))
AudioPlayer(url = post.audioUrl)
}
// Link preview
if (post.linkUrl != null && post.linkTitle != null) {
Spacer(modifier = Modifier.height(12.dp))

View file

@ -535,6 +535,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
allImages.add(url)
}
}
val (videoUrl, audioUrl) = extractMediaUrlsFromMobiledoc(mobiledoc)
return FeedPost(
ghostId = id,
slug = slug,
@ -545,6 +546,8 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
imageUrl = allImages.firstOrNull(),
imageAlt = feature_image_alt,
imageUrls = allImages,
videoUrl = videoUrl,
audioUrl = audioUrl,
linkUrl = null,
linkTitle = null,
linkDescription = null,
@ -580,6 +583,42 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
}
}
/**
* Extracts video and audio URLs from Ghost mobiledoc JSON.
* Video cards: ["video", {"src": "url"}]
* Audio cards: ["audio", {"src": "url"}]
* Returns a Pair of (videoUrl, audioUrl), either may be null.
*/
private fun extractMediaUrlsFromMobiledoc(mobiledoc: String?): Pair<String?, String?> {
if (mobiledoc == null) return null to null
return try {
val json = com.google.gson.JsonParser.parseString(mobiledoc).asJsonObject
val cards = json.getAsJsonArray("cards") ?: return null to null
var videoUrl: String? = null
var audioUrl: String? = null
for (card in cards) {
val cardArray = card.asJsonArray
if (cardArray.size() >= 2) {
when (cardArray[0].asString) {
"video" -> {
if (videoUrl == null) {
videoUrl = cardArray[1].asJsonObject.get("src")?.asString
}
}
"audio" -> {
if (audioUrl == null) {
audioUrl = cardArray[1].asJsonObject.get("src")?.asString
}
}
}
}
}
videoUrl to audioUrl
} catch (e: Exception) {
null to null
}
}
private fun LocalPost.toFeedPost(): FeedPost {
val tagNames: List<String> = try {
Gson().fromJson(tags, object : TypeToken<List<String>>() {}.type) ?: emptyList()
@ -604,6 +643,8 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
imageUrl = allImageUrls.firstOrNull(),
imageAlt = imageAlt,
imageUrls = allImageUrls,
videoUrl = uploadedVideoUrl ?: videoUri,
audioUrl = uploadedAudioUrl ?: audioUri,
linkUrl = linkUrl,
linkTitle = linkTitle,
linkDescription = linkDescription,