mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 11:55:47 +00:00
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:
parent
27782893dc
commit
a1aae661c9
7 changed files with 330 additions and 5 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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')}"
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue