diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0bd534e..59c8667 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt b/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt index c99eef3..ff68958 100644 --- a/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt +++ b/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt @@ -132,6 +132,8 @@ data class FeedPost( val imageUrl: String?, val imageAlt: String? = null, val imageUrls: List = emptyList(), + val videoUrl: String? = null, + val audioUrl: String? = null, val linkUrl: String?, val linkTitle: String?, val linkDescription: String?, diff --git a/app/src/main/java/com/swoosh/microblog/ui/components/MediaPlayers.kt b/app/src/main/java/com/swoosh/microblog/ui/components/MediaPlayers.kt new file mode 100644 index 0000000..338a130 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/ui/components/MediaPlayers.kt @@ -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')}" +} diff --git a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt index 714e07e..32e4b0a 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt @@ -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, diff --git a/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt index 8fb0070..7ad7903 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt @@ -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 { diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt index a29db61..a58c2f5 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt @@ -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)) diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt index 744a3cb..175ad20 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt @@ -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 { + 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 = try { Gson().fromJson(tags, object : TypeToken>() {}.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,