merge: integrate Phase 5 (Media Upload) with existing phases

This commit is contained in:
Paweł Orzech 2026-03-20 00:57:01 +01:00
commit 0c43dc173c
14 changed files with 818 additions and 21 deletions

View file

@ -107,6 +107,10 @@ dependencies {
// Jsoup for OpenGraph parsing // Jsoup for OpenGraph parsing
implementation("org.jsoup:jsoup:1.17.2") 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 // Testing
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")

View file

@ -50,11 +50,23 @@ object MobiledocBuilder {
} }
/** /**
* Builds mobiledoc JSON with support for multiple images (with optional alt text on the first), * Build with multiple images, alt text, and optional video/audio.
* an optional link preview, and an optional file attachment. * Delegates to the full implementation.
* Each image becomes an image card in the mobiledoc format. */
* The bookmark card (link preview) is added after image cards. fun build(
* The file card is added last, after all other cards. text: String,
imageUrls: List<String>,
linkUrl: String?,
linkTitle: String?,
linkDescription: String?,
imageAlt: String?
): String {
return build(text, imageUrls, linkUrl, linkTitle, linkDescription, imageAlt, null, null)
}
/**
* Build with images, video, audio (no file).
* Delegates to the full implementation.
*/ */
fun build( fun build(
text: String, text: String,
@ -63,6 +75,32 @@ object MobiledocBuilder {
linkTitle: String?, linkTitle: String?,
linkDescription: String?, linkDescription: String?,
imageAlt: String?, imageAlt: String?,
videoUrl: String?,
audioUrl: String?
): String {
return build(
text = text, imageUrls = imageUrls,
linkUrl = linkUrl, linkTitle = linkTitle, linkDescription = linkDescription,
imageAlt = imageAlt, videoUrl = videoUrl, audioUrl = audioUrl,
fileUrl = null, fileName = null, fileSize = 0
)
}
/**
* Builds mobiledoc JSON with support for multiple images (with optional alt text on the first),
* optional video, optional audio, an optional link preview, and an optional file attachment.
*
* Card order: images -> video -> audio -> bookmark -> file
*/
fun build(
text: String,
imageUrls: List<String>,
linkUrl: String?,
linkTitle: String?,
linkDescription: String?,
imageAlt: String?,
videoUrl: String? = null,
audioUrl: String? = null,
fileUrl: String? = null, fileUrl: String? = null,
fileName: String? = null, fileName: String? = null,
fileSize: Long = 0 fileSize: Long = 0
@ -81,6 +119,20 @@ object MobiledocBuilder {
cardSections.add("[10,${cards.size - 1}]") cardSections.add("[10,${cards.size - 1}]")
} }
// Add video card if present
if (videoUrl != null) {
val escapedUrl = escapeForJson(videoUrl)
cards.add("""["video",{"src":"$escapedUrl","loop":false}]""")
cardSections.add("[10,${cards.size - 1}]")
}
// Add audio card if present
if (audioUrl != null) {
val escapedUrl = escapeForJson(audioUrl)
cards.add("""["audio",{"src":"$escapedUrl"}]""")
cardSections.add("[10,${cards.size - 1}]")
}
// Add bookmark card if link is present // Add bookmark card if link is present
if (linkUrl != null) { if (linkUrl != null) {
val escapedUrl = escapeForJson(linkUrl) val escapedUrl = escapeForJson(linkUrl)

View file

@ -2,6 +2,7 @@ package com.swoosh.microblog.data.api
import com.swoosh.microblog.data.model.FileUploadResponse import com.swoosh.microblog.data.model.FileUploadResponse
import com.swoosh.microblog.data.model.GhostSite import com.swoosh.microblog.data.model.GhostSite
import com.swoosh.microblog.data.model.MediaUploadResponse
import com.swoosh.microblog.data.model.MembersResponse import com.swoosh.microblog.data.model.MembersResponse
import com.swoosh.microblog.data.model.NewslettersResponse import com.swoosh.microblog.data.model.NewslettersResponse
import com.swoosh.microblog.data.model.PageWrapper import com.swoosh.microblog.data.model.PageWrapper
@ -133,6 +134,20 @@ interface GhostApiService {
@Part file: MultipartBody.Part, @Part file: MultipartBody.Part,
@Part("ref") ref: RequestBody? = null @Part("ref") ref: RequestBody? = null
): Response<FileUploadResponse> ): Response<FileUploadResponse>
@Multipart
@POST("ghost/api/admin/media/upload/")
suspend fun uploadMedia(
@Part file: MultipartBody.Part,
@Part("ref") ref: RequestBody? = null
): Response<MediaUploadResponse>
@Multipart
@POST("ghost/api/admin/media/thumbnail/upload/")
suspend fun uploadMediaThumbnail(
@Part file: MultipartBody.Part,
@Part("ref") ref: RequestBody? = null
): Response<MediaUploadResponse>
} }
data class ImageUploadResponse( data class ImageUploadResponse(

View file

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

View file

@ -0,0 +1,11 @@
package com.swoosh.microblog.data.model
data class MediaUploadResponse(
val media: List<UploadedMedia>
)
data class UploadedMedia(
val url: String,
val ref: String?,
val fileName: String?
)

View file

@ -161,8 +161,8 @@ class PostRepository(private val context: Context) {
suspend fun uploadImage(uri: Uri): Result<String> = suspend fun uploadImage(uri: Uri): Result<String> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
val file = copyUriToTempFile(uri)
val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg" val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg"
val file = copyUriToTempFile(uri, ".jpg")
val requestBody = file.asRequestBody(mimeType.toMediaType()) val requestBody = file.asRequestBody(mimeType.toMediaType())
val part = MultipartBody.Part.createFormData("file", file.name, requestBody) val part = MultipartBody.Part.createFormData("file", file.name, requestBody)
val purpose = "image".toRequestBody("text/plain".toMediaType()) val purpose = "image".toRequestBody("text/plain".toMediaType())
@ -226,10 +226,41 @@ class PostRepository(private val context: Context) {
} }
} }
private fun copyUriToTempFile(uri: Uri): File { /**
* Uploads a media file (video or audio) to the Ghost media upload endpoint.
* Determines MIME type from the content resolver. Returns the uploaded URL on success.
*/
suspend fun uploadMediaFile(uri: Uri): Result<String> =
withContext(Dispatchers.IO) {
try {
val mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream"
val extension = when {
mimeType.startsWith("video/") -> mimeType.substringAfter("video/").let { ".$it" }
mimeType.startsWith("audio/") -> mimeType.substringAfter("audio/").let { ".$it" }
else -> ""
}
val file = copyUriToTempFile(uri, extension)
val requestBody = file.asRequestBody(mimeType.toMediaType())
val part = MultipartBody.Part.createFormData("file", file.name, requestBody)
val response = getApi().uploadMedia(part)
file.delete()
if (response.isSuccessful) {
val url = response.body()!!.media.first().url
Result.success(url)
} else {
Result.failure(Exception("Media upload failed ${response.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
private fun copyUriToTempFile(uri: Uri, extension: String = ".jpg"): File {
val inputStream = context.contentResolver.openInputStream(uri) val inputStream = context.contentResolver.openInputStream(uri)
?: throw IllegalStateException("Cannot open URI") ?: throw IllegalStateException("Cannot open URI")
val tempFile = File.createTempFile("upload_", ".jpg", context.cacheDir) val tempFile = File.createTempFile("upload_", extension, context.cacheDir)
FileOutputStream(tempFile).use { output -> FileOutputStream(tempFile).use { output ->
inputStream.copyTo(output) inputStream.copyTo(output)
} }

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

@ -123,8 +123,22 @@ fun ComposerScreen(
} }
} }
// Video picker
val videoPickerLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.GetContent()
) { uri: Uri? ->
uri?.let { viewModel.setVideo(it) }
}
// Audio picker
val audioPickerLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.GetContent()
) { uri: Uri? ->
uri?.let { viewModel.setAudio(it) }
}
val canSubmit by remember { val canSubmit by remember {
derivedStateOf { !state.isSubmitting && (state.text.isNotBlank() || state.imageUris.isNotEmpty() || state.fileUri != null) } derivedStateOf { !state.isSubmitting && (state.text.isNotBlank() || state.imageUris.isNotEmpty() || state.fileUri != null || state.videoUri != null || state.audioUri != null) }
} }
Scaffold( Scaffold(
@ -424,6 +438,12 @@ fun ComposerScreen(
OutlinedIconButton(onClick = { multiImagePickerLauncher.launch("image/*") }) { OutlinedIconButton(onClick = { multiImagePickerLauncher.launch("image/*") }) {
Icon(Icons.Default.Image, "Attach images") Icon(Icons.Default.Image, "Attach images")
} }
OutlinedIconButton(onClick = { videoPickerLauncher.launch("video/*") }) {
Icon(Icons.Default.Videocam, "Attach video")
}
OutlinedIconButton(onClick = { audioPickerLauncher.launch("audio/*") }) {
Icon(Icons.Default.MusicNote, "Attach audio")
}
OutlinedIconButton(onClick = { showLinkDialog = true }) { OutlinedIconButton(onClick = { showLinkDialog = true }) {
Icon(Icons.Default.Link, "Add link") Icon(Icons.Default.Link, "Add link")
} }
@ -482,6 +502,40 @@ fun ComposerScreen(
} }
} }
// Video preview card
AnimatedVisibility(
visible = state.videoUri != null,
enter = scaleIn(initialScale = 0f, animationSpec = SwooshMotion.bouncy()) + fadeIn(SwooshMotion.quick()),
exit = scaleOut(animationSpec = SwooshMotion.quick()) + fadeOut(SwooshMotion.quick())
) {
Column {
Spacer(modifier = Modifier.height(12.dp))
MediaPreviewCard(
icon = Icons.Default.Videocam,
label = "Video attached",
uri = state.videoUri,
onRemove = viewModel::removeVideo
)
}
}
// Audio preview card
AnimatedVisibility(
visible = state.audioUri != null,
enter = scaleIn(initialScale = 0f, animationSpec = SwooshMotion.bouncy()) + fadeIn(SwooshMotion.quick()),
exit = scaleOut(animationSpec = SwooshMotion.quick()) + fadeOut(SwooshMotion.quick())
) {
Column {
Spacer(modifier = Modifier.height(12.dp))
MediaPreviewCard(
icon = Icons.Default.MusicNote,
label = "Audio attached",
uri = state.audioUri,
onRemove = viewModel::removeAudio
)
}
}
// File attachment card // File attachment card
AnimatedVisibility( AnimatedVisibility(
visible = state.fileUri != null, visible = state.fileUri != null,
@ -1203,6 +1257,95 @@ class HashtagVisualTransformation(private val hashtagColor: Color) : VisualTrans
} }
} }
/**
* Compact preview card for attached video or audio files.
* Shows an icon, label, filename (from URI), and a remove button.
*/
@Composable
fun MediaPreviewCard(
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
uri: Uri?,
onRemove: () -> Unit
) {
val context = LocalContext.current
val fileName = remember(uri) {
uri?.let { mediaUri ->
try {
context.contentResolver.query(mediaUri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
if (cursor.moveToFirst() && nameIndex >= 0) {
cursor.getString(nameIndex)
} else null
}
} catch (_: Exception) {
null
} ?: mediaUri.lastPathSegment ?: "Unknown file"
} ?: "Unknown file"
}
val fileSize = remember(uri) {
uri?.let { mediaUri ->
try {
context.contentResolver.query(mediaUri, null, null, null, null)?.use { cursor ->
val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE)
if (cursor.moveToFirst() && sizeIndex >= 0) {
val bytes = cursor.getLong(sizeIndex)
when {
bytes < 1024 -> "$bytes B"
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
else -> "%.1f MB".format(bytes / (1024.0 * 1024.0))
}
} else null
}
} catch (_: Exception) {
null
}
}
}
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = fileName,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
)
if (fileSize != null) {
Text(
text = fileSize,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
IconButton(onClick = onRemove) {
Icon(Icons.Default.Close, "Remove $label", Modifier.size(18.dp))
}
}
}
}
/** /**
* Tag input section with autocomplete suggestions and tag chips. * Tag input section with autocomplete suggestions and tag chips.
*/ */

View file

@ -149,6 +149,8 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
text = post.textContent, text = post.textContent,
imageUris = imageUris, imageUris = imageUris,
imageAlt = post.imageAlt ?: "", 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( linkPreview = if (post.linkUrl != null) LinkPreview(
url = post.linkUrl, url = post.linkUrl,
title = post.linkTitle, title = post.linkTitle,
@ -259,6 +261,22 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
} }
} }
fun setVideo(uri: Uri) {
_uiState.update { it.copy(videoUri = uri) }
}
fun removeVideo() {
_uiState.update { it.copy(videoUri = null, uploadedVideoUrl = null) }
}
fun setAudio(uri: Uri) {
_uiState.update { it.copy(audioUri = uri) }
}
fun removeAudio() {
_uiState.update { it.copy(audioUri = null, uploadedAudioUrl = null) }
}
fun fetchLinkPreview(url: String) { fun fetchLinkPreview(url: String) {
if (url.isBlank()) return if (url.isBlank()) return
viewModelScope.launch { viewModelScope.launch {
@ -345,7 +363,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
private fun submitPost(status: PostStatus, offlineQueueStatus: QueueStatus) { private fun submitPost(status: PostStatus, offlineQueueStatus: QueueStatus) {
val state = _uiState.value val state = _uiState.value
if (state.text.isBlank() && state.imageUris.isEmpty() && state.fileUri == null) return if (state.text.isBlank() && state.imageUris.isEmpty() && state.fileUri == null && state.videoUri == null && state.audioUri == null) return
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(isSubmitting = true, error = null) } _uiState.update { it.copy(isSubmitting = true, error = null) }
@ -370,6 +388,8 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
imageUri = state.imageUris.firstOrNull()?.toString(), imageUri = state.imageUris.firstOrNull()?.toString(),
imageUris = Converters.stringListToJson(state.imageUris.map { it.toString() }), imageUris = Converters.stringListToJson(state.imageUris.map { it.toString() }),
imageAlt = altText, imageAlt = altText,
videoUri = state.videoUri?.toString(),
audioUri = state.audioUri?.toString(),
linkUrl = state.linkPreview?.url, linkUrl = state.linkPreview?.url,
linkTitle = state.linkPreview?.title, linkTitle = state.linkPreview?.title,
linkDescription = state.linkPreview?.description, linkDescription = state.linkPreview?.description,
@ -404,6 +424,36 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
) )
} }
// Upload video if present
var videoUrl: String? = state.uploadedVideoUrl
if (videoUrl == null && state.videoUri != null) {
_uiState.update { it.copy(isUploadingMedia = true) }
val videoResult = repository.uploadMediaFile(state.videoUri)
videoResult.fold(
onSuccess = { url -> videoUrl = url },
onFailure = { e ->
_uiState.update { it.copy(isSubmitting = false, isUploadingMedia = false, error = "Video upload failed: ${e.message}") }
return@launch
}
)
}
// Upload audio if present
var audioUrl: String? = state.uploadedAudioUrl
if (audioUrl == null && state.audioUri != null) {
_uiState.update { it.copy(isUploadingMedia = true) }
val audioResult = repository.uploadMediaFile(state.audioUri)
audioResult.fold(
onSuccess = { url -> audioUrl = url },
onFailure = { e ->
_uiState.update { it.copy(isSubmitting = false, isUploadingMedia = false, error = "Audio upload failed: ${e.message}") }
return@launch
}
)
}
_uiState.update { it.copy(isUploadingMedia = false) }
// Upload file if attached // Upload file if attached
var uploadedFileUrl = state.uploadedFileUrl var uploadedFileUrl = state.uploadedFileUrl
if (state.fileUri != null && uploadedFileUrl == null) { if (state.fileUri != null && uploadedFileUrl == null) {
@ -417,12 +467,18 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
) )
} }
val featureImage = uploadedImageUrls.firstOrNull() val featureImage = uploadedImageUrls.firstOrNull()
val mobiledoc = MobiledocBuilder.build( val mobiledoc = MobiledocBuilder.build(
state.text, uploadedImageUrls, text = state.text,
state.linkPreview?.url, state.linkPreview?.title, state.linkPreview?.description, imageUrls = uploadedImageUrls,
altText, linkUrl = state.linkPreview?.url,
linkTitle = state.linkPreview?.title,
linkDescription = state.linkPreview?.description,
imageAlt = altText,
videoUrl = videoUrl,
audioUrl = audioUrl,
fileUrl = uploadedFileUrl, fileUrl = uploadedFileUrl,
fileName = state.fileName, fileName = state.fileName,
fileSize = state.fileSize ?: 0 fileSize = state.fileSize ?: 0
@ -472,6 +528,10 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
uploadedImageUrl = featureImage, uploadedImageUrl = featureImage,
uploadedImageUrls = Converters.stringListToJson(uploadedImageUrls), uploadedImageUrls = Converters.stringListToJson(uploadedImageUrls),
imageAlt = altText, imageAlt = altText,
videoUri = state.videoUri?.toString(),
uploadedVideoUrl = videoUrl,
audioUri = state.audioUri?.toString(),
uploadedAudioUrl = audioUrl,
linkUrl = state.linkPreview?.url, linkUrl = state.linkPreview?.url,
linkTitle = state.linkPreview?.title, linkTitle = state.linkPreview?.title,
linkDescription = state.linkPreview?.description, linkDescription = state.linkPreview?.description,
@ -509,6 +569,11 @@ data class ComposerUiState(
val text: String = "", val text: String = "",
val imageUris: List<Uri> = emptyList(), val imageUris: List<Uri> = emptyList(),
val imageAlt: String = "", val imageAlt: String = "",
val videoUri: Uri? = null,
val audioUri: Uri? = null,
val uploadedVideoUrl: String? = null,
val uploadedAudioUrl: String? = null,
val isUploadingMedia: Boolean = false,
val linkPreview: LinkPreview? = null, val linkPreview: LinkPreview? = null,
val isLoadingLink: Boolean = false, val isLoadingLink: Boolean = false,
val scheduledAt: String? = null, val scheduledAt: String? = null,

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.PostStats
import com.swoosh.microblog.data.model.QueueStatus import com.swoosh.microblog.data.model.QueueStatus
import com.swoosh.microblog.ui.animation.SwooshMotion 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.ConfirmationDialog
import com.swoosh.microblog.ui.components.VideoPlayer
import com.swoosh.microblog.ui.feed.FileAttachmentCard import com.swoosh.microblog.ui.feed.FileAttachmentCard
import com.swoosh.microblog.ui.feed.FullScreenGallery import com.swoosh.microblog.ui.feed.FullScreenGallery
import com.swoosh.microblog.ui.feed.StatusBadge import com.swoosh.microblog.ui.feed.StatusBadge
@ -95,7 +97,7 @@ fun DetailScreen(
} }
// D1: Content reveal sequence // 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) } } val sectionVisible = remember { List(revealCount) { mutableStateOf(false) } }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
sectionVisible.forEachIndexed { index, state -> sectionVisible.forEachIndexed { index, state ->
@ -301,9 +303,31 @@ fun DetailScreen(
} }
} }
// Section 4 — Link preview // Section 4 — Video player
AnimatedVisibility( 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()) enter = fadeIn(SwooshMotion.quick()) + slideInVertically(initialOffsetY = { 20 }, animationSpec = SwooshMotion.gentle())
) { ) {
Column { Column {
@ -352,7 +376,7 @@ fun DetailScreen(
// File attachment // File attachment
if (post.fileUrl != null) { if (post.fileUrl != null) {
AnimatedVisibility( AnimatedVisibility(
visible = sectionVisible[4].value, visible = sectionVisible[6].value,
enter = fadeIn(SwooshMotion.quick()) + slideInVertically(initialOffsetY = { 20 }, animationSpec = SwooshMotion.gentle()) enter = fadeIn(SwooshMotion.quick()) + slideInVertically(initialOffsetY = { 20 }, animationSpec = SwooshMotion.gentle())
) { ) {
Column { Column {
@ -365,9 +389,9 @@ fun DetailScreen(
} }
} }
// Section 5 — PostStatsSection // Section 7 — PostStatsSection
AnimatedVisibility( AnimatedVisibility(
visible = sectionVisible[5].value, visible = sectionVisible[7].value,
enter = slideInVertically(initialOffsetY = { it / 4 }, animationSpec = SwooshMotion.gentle()) + fadeIn(SwooshMotion.quick()) enter = slideInVertically(initialOffsetY = { it / 4 }, animationSpec = SwooshMotion.gentle()) + fadeIn(SwooshMotion.quick())
) { ) {
Column { Column {

View file

@ -91,7 +91,9 @@ import com.swoosh.microblog.data.model.PostFilter
import com.swoosh.microblog.data.model.PostStats import com.swoosh.microblog.data.model.PostStats
import com.swoosh.microblog.data.model.QueueStatus import com.swoosh.microblog.data.model.QueueStatus
import com.swoosh.microblog.data.model.SortOrder 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.ConfirmationDialog
import com.swoosh.microblog.ui.components.VideoPlayer
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
@Composable @Composable
@ -1556,6 +1558,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 // Link preview
if (post.linkUrl != null && post.linkTitle != null) { if (post.linkUrl != null && post.linkTitle != null) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))

View file

@ -536,6 +536,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
val fileData = extractFileCardFromMobiledoc(mobiledoc) val fileData = extractFileCardFromMobiledoc(mobiledoc)
val (videoUrl, audioUrl) = extractMediaUrlsFromMobiledoc(mobiledoc)
return FeedPost( return FeedPost(
ghostId = id, ghostId = id,
slug = slug, slug = slug,
@ -546,6 +547,8 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
imageUrl = allImages.firstOrNull(), imageUrl = allImages.firstOrNull(),
imageAlt = feature_image_alt, imageAlt = feature_image_alt,
imageUrls = allImages, imageUrls = allImages,
videoUrl = videoUrl,
audioUrl = audioUrl,
linkUrl = null, linkUrl = null,
linkTitle = null, linkTitle = null,
linkDescription = null, linkDescription = null,
@ -608,6 +611,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 { private fun LocalPost.toFeedPost(): FeedPost {
val tagNames: List<String> = try { val tagNames: List<String> = try {
Gson().fromJson(tags, object : TypeToken<List<String>>() {}.type) ?: emptyList() Gson().fromJson(tags, object : TypeToken<List<String>>() {}.type) ?: emptyList()
@ -632,6 +671,8 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
imageUrl = allImageUrls.firstOrNull(), imageUrl = allImageUrls.firstOrNull(),
imageAlt = imageAlt, imageAlt = imageAlt,
imageUrls = allImageUrls, imageUrls = allImageUrls,
videoUrl = uploadedVideoUrl ?: videoUri,
audioUrl = uploadedAudioUrl ?: audioUri,
linkUrl = linkUrl, linkUrl = linkUrl,
linkTitle = linkTitle, linkTitle = linkTitle,
linkDescription = linkDescription, linkDescription = linkDescription,

View file

@ -66,6 +66,30 @@ class PostUploadWorker(
featureImage = allImageUrls.first() featureImage = allImageUrls.first()
} }
// Upload video if needed
var videoUrl = post.uploadedVideoUrl
if (videoUrl == null && post.videoUri != null) {
val videoResult = repository.uploadMediaFile(Uri.parse(post.videoUri))
if (videoResult.isFailure) {
repository.updateQueueStatus(post.localId, QueueStatus.FAILED)
allSuccess = false
continue
}
videoUrl = videoResult.getOrNull()
}
// Upload audio if needed
var audioUrl = post.uploadedAudioUrl
if (audioUrl == null && post.audioUri != null) {
val audioResult = repository.uploadMediaFile(Uri.parse(post.audioUri))
if (audioResult.isFailure) {
repository.updateQueueStatus(post.localId, QueueStatus.FAILED)
allSuccess = false
continue
}
audioUrl = audioResult.getOrNull()
}
// Upload file if needed // Upload file if needed
var uploadedFileUrl = post.uploadedFileUrl var uploadedFileUrl = post.uploadedFileUrl
if (uploadedFileUrl == null && post.fileUri != null) { if (uploadedFileUrl == null && post.fileUri != null) {
@ -79,8 +103,14 @@ class PostUploadWorker(
} }
val mobiledoc = MobiledocBuilder.build( val mobiledoc = MobiledocBuilder.build(
post.content, allImageUrls, post.linkUrl, post.linkTitle, post.linkDescription, text = post.content,
post.imageAlt, imageUrls = allImageUrls,
linkUrl = post.linkUrl,
linkTitle = post.linkTitle,
linkDescription = post.linkDescription,
imageAlt = post.imageAlt,
videoUrl = videoUrl,
audioUrl = audioUrl,
fileUrl = uploadedFileUrl, fileUrl = uploadedFileUrl,
fileName = post.fileName, fileName = post.fileName,
fileSize = 0 // Size not stored in LocalPost; Ghost only needs src/fileName fileSize = 0 // Size not stored in LocalPost; Ghost only needs src/fileName

View file

@ -598,6 +598,133 @@ class MobiledocBuilderTest {
assertEquals("", secondCard.get("alt").asString) assertEquals("", secondCard.get("alt").asString)
} }
// --- Video card tests ---
@Test
fun `build with video only produces valid JSON with video card`() {
val result = MobiledocBuilder.build(
"Check this video", emptyList(), null, null, null, null,
"https://example.com/video.mp4", null
)
val json = JsonParser.parseString(result).asJsonObject
assertNotNull(json)
val cards = json.getAsJsonArray("cards")
assertEquals(1, cards.size())
val card = cards.get(0).asJsonArray
assertEquals("video", card.get(0).asString)
val cardData = card.get(1).asJsonObject
assertEquals("https://example.com/video.mp4", cardData.get("src").asString)
assertFalse(cardData.get("loop").asBoolean)
}
@Test
fun `build with video has correct sections`() {
val result = MobiledocBuilder.build(
"Text", emptyList(), null, null, null, null,
"https://example.com/video.mp4", null
)
val json = JsonParser.parseString(result).asJsonObject
val sections = json.getAsJsonArray("sections")
assertEquals("Should have text section + video card section", 2, sections.size())
val videoSection = sections.get(1).asJsonArray
assertEquals(10, videoSection.get(0).asInt)
assertEquals(0, videoSection.get(1).asInt)
}
// --- Audio card tests ---
@Test
fun `build with audio only produces valid JSON with audio card`() {
val result = MobiledocBuilder.build(
"Listen to this", emptyList(), null, null, null, null,
null, "https://example.com/audio.mp3"
)
val json = JsonParser.parseString(result).asJsonObject
assertNotNull(json)
val cards = json.getAsJsonArray("cards")
assertEquals(1, cards.size())
val card = cards.get(0).asJsonArray
assertEquals("audio", card.get(0).asString)
val cardData = card.get(1).asJsonObject
assertEquals("https://example.com/audio.mp3", cardData.get("src").asString)
}
@Test
fun `build with audio has correct sections`() {
val result = MobiledocBuilder.build(
"Text", emptyList(), null, null, null, null,
null, "https://example.com/audio.mp3"
)
val json = JsonParser.parseString(result).asJsonObject
val sections = json.getAsJsonArray("sections")
assertEquals("Should have text section + audio card section", 2, sections.size())
}
// --- Combined: text + images + video + audio + bookmark ---
@Test
fun `build with all media types produces valid JSON with correct card order`() {
val images = listOf("https://example.com/img1.jpg", "https://example.com/img2.jpg")
val result = MobiledocBuilder.build(
"Full post", images,
"https://link.com", "Link Title", "Link Desc",
"Alt text",
"https://example.com/video.mp4",
"https://example.com/audio.mp3"
)
val json = JsonParser.parseString(result).asJsonObject
assertNotNull(json)
val cards = json.getAsJsonArray("cards")
// 2 images + 1 video + 1 audio + 1 bookmark = 5 cards
assertEquals(5, cards.size())
// Verify card order: image, image, video, audio, bookmark
assertEquals("image", cards.get(0).asJsonArray.get(0).asString)
assertEquals("image", cards.get(1).asJsonArray.get(0).asString)
assertEquals("video", cards.get(2).asJsonArray.get(0).asString)
assertEquals("audio", cards.get(3).asJsonArray.get(0).asString)
assertEquals("bookmark", cards.get(4).asJsonArray.get(0).asString)
// Verify sections: 1 text + 5 card sections = 6
val sections = json.getAsJsonArray("sections")
assertEquals(6, sections.size())
// Verify card section indices are sequential
for (i in 0 until 5) {
val cardSection = sections.get(i + 1).asJsonArray
assertEquals(10, cardSection.get(0).asInt)
assertEquals(i, cardSection.get(1).asInt)
}
}
@Test
fun `build with video and audio but no images produces correct cards`() {
val result = MobiledocBuilder.build(
"Media post", emptyList(), null, null, null, null,
"https://example.com/video.mp4",
"https://example.com/audio.mp3"
)
val json = JsonParser.parseString(result).asJsonObject
val cards = json.getAsJsonArray("cards")
assertEquals(2, cards.size())
assertEquals("video", cards.get(0).asJsonArray.get(0).asString)
assertEquals("audio", cards.get(1).asJsonArray.get(0).asString)
}
@Test
fun `build with video URL containing special chars produces valid JSON`() {
val result = MobiledocBuilder.build(
"Text", emptyList(), null, null, null, null,
"https://example.com/video?id=1&name=\"test\"", null
)
val json = JsonParser.parseString(result).asJsonObject
assertNotNull(json)
}
// --- File card tests --- // --- File card tests ---
@Test @Test