mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
merge: integrate Phase 5 (Media Upload) with existing phases
This commit is contained in:
commit
0c43dc173c
14 changed files with 818 additions and 21 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")
|
||||
|
|
|
|||
|
|
@ -50,11 +50,23 @@ object MobiledocBuilder {
|
|||
}
|
||||
|
||||
/**
|
||||
* Builds mobiledoc JSON with support for multiple images (with optional alt text on the first),
|
||||
* an optional link preview, and an optional file attachment.
|
||||
* Each image becomes an image card in the mobiledoc format.
|
||||
* The bookmark card (link preview) is added after image cards.
|
||||
* The file card is added last, after all other cards.
|
||||
* Build with multiple images, alt text, and optional video/audio.
|
||||
* Delegates to the full implementation.
|
||||
*/
|
||||
fun build(
|
||||
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(
|
||||
text: String,
|
||||
|
|
@ -63,6 +75,32 @@ object MobiledocBuilder {
|
|||
linkTitle: String?,
|
||||
linkDescription: 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,
|
||||
fileName: String? = null,
|
||||
fileSize: Long = 0
|
||||
|
|
@ -81,6 +119,20 @@ object MobiledocBuilder {
|
|||
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
|
||||
if (linkUrl != null) {
|
||||
val escapedUrl = escapeForJson(linkUrl)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.swoosh.microblog.data.api
|
|||
|
||||
import com.swoosh.microblog.data.model.FileUploadResponse
|
||||
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.NewslettersResponse
|
||||
import com.swoosh.microblog.data.model.PageWrapper
|
||||
|
|
@ -133,6 +134,20 @@ interface GhostApiService {
|
|||
@Part file: MultipartBody.Part,
|
||||
@Part("ref") ref: RequestBody? = null
|
||||
): 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(
|
||||
|
|
|
|||
|
|
@ -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,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?
|
||||
)
|
||||
|
|
@ -161,8 +161,8 @@ class PostRepository(private val context: Context) {
|
|||
suspend fun uploadImage(uri: Uri): Result<String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val file = copyUriToTempFile(uri)
|
||||
val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg"
|
||||
val file = copyUriToTempFile(uri, ".jpg")
|
||||
val requestBody = file.asRequestBody(mimeType.toMediaType())
|
||||
val part = MultipartBody.Part.createFormData("file", file.name, requestBody)
|
||||
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)
|
||||
?: 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 ->
|
||||
inputStream.copyTo(output)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')}"
|
||||
}
|
||||
|
|
@ -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 {
|
||||
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(
|
||||
|
|
@ -424,6 +438,12 @@ fun ComposerScreen(
|
|||
OutlinedIconButton(onClick = { multiImagePickerLauncher.launch("image/*") }) {
|
||||
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 }) {
|
||||
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
|
||||
AnimatedVisibility(
|
||||
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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -149,6 +149,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,
|
||||
|
|
@ -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) {
|
||||
if (url.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
|
|
@ -345,7 +363,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
|||
|
||||
private fun submitPost(status: PostStatus, offlineQueueStatus: QueueStatus) {
|
||||
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 {
|
||||
_uiState.update { it.copy(isSubmitting = true, error = null) }
|
||||
|
|
@ -370,6 +388,8 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
|||
imageUri = state.imageUris.firstOrNull()?.toString(),
|
||||
imageUris = Converters.stringListToJson(state.imageUris.map { it.toString() }),
|
||||
imageAlt = altText,
|
||||
videoUri = state.videoUri?.toString(),
|
||||
audioUri = state.audioUri?.toString(),
|
||||
linkUrl = state.linkPreview?.url,
|
||||
linkTitle = state.linkPreview?.title,
|
||||
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
|
||||
var uploadedFileUrl = state.uploadedFileUrl
|
||||
if (state.fileUri != null && uploadedFileUrl == null) {
|
||||
|
|
@ -417,12 +467,18 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
|||
)
|
||||
}
|
||||
|
||||
|
||||
val featureImage = uploadedImageUrls.firstOrNull()
|
||||
|
||||
val mobiledoc = MobiledocBuilder.build(
|
||||
state.text, uploadedImageUrls,
|
||||
state.linkPreview?.url, state.linkPreview?.title, state.linkPreview?.description,
|
||||
altText,
|
||||
text = state.text,
|
||||
imageUrls = uploadedImageUrls,
|
||||
linkUrl = state.linkPreview?.url,
|
||||
linkTitle = state.linkPreview?.title,
|
||||
linkDescription = state.linkPreview?.description,
|
||||
imageAlt = altText,
|
||||
videoUrl = videoUrl,
|
||||
audioUrl = audioUrl,
|
||||
fileUrl = uploadedFileUrl,
|
||||
fileName = state.fileName,
|
||||
fileSize = state.fileSize ?: 0
|
||||
|
|
@ -472,6 +528,10 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
|||
uploadedImageUrl = featureImage,
|
||||
uploadedImageUrls = Converters.stringListToJson(uploadedImageUrls),
|
||||
imageAlt = altText,
|
||||
videoUri = state.videoUri?.toString(),
|
||||
uploadedVideoUrl = videoUrl,
|
||||
audioUri = state.audioUri?.toString(),
|
||||
uploadedAudioUrl = audioUrl,
|
||||
linkUrl = state.linkPreview?.url,
|
||||
linkTitle = state.linkPreview?.title,
|
||||
linkDescription = state.linkPreview?.description,
|
||||
|
|
@ -509,6 +569,11 @@ data class ComposerUiState(
|
|||
val text: String = "",
|
||||
val imageUris: List<Uri> = emptyList(),
|
||||
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 isLoadingLink: Boolean = false,
|
||||
val scheduledAt: String? = null,
|
||||
|
|
|
|||
|
|
@ -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.FileAttachmentCard
|
||||
import com.swoosh.microblog.ui.feed.FullScreenGallery
|
||||
import com.swoosh.microblog.ui.feed.StatusBadge
|
||||
|
|
@ -95,7 +97,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 ->
|
||||
|
|
@ -301,9 +303,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 {
|
||||
|
|
@ -352,7 +376,7 @@ fun DetailScreen(
|
|||
// File attachment
|
||||
if (post.fileUrl != null) {
|
||||
AnimatedVisibility(
|
||||
visible = sectionVisible[4].value,
|
||||
visible = sectionVisible[6].value,
|
||||
enter = fadeIn(SwooshMotion.quick()) + slideInVertically(initialOffsetY = { 20 }, animationSpec = SwooshMotion.gentle())
|
||||
) {
|
||||
Column {
|
||||
|
|
@ -365,9 +389,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 {
|
||||
|
|
|
|||
|
|
@ -91,7 +91,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
|
||||
|
|
@ -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
|
||||
if (post.linkUrl != null && post.linkTitle != null) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
|
|
|||
|
|
@ -536,6 +536,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
|||
}
|
||||
}
|
||||
val fileData = extractFileCardFromMobiledoc(mobiledoc)
|
||||
val (videoUrl, audioUrl) = extractMediaUrlsFromMobiledoc(mobiledoc)
|
||||
return FeedPost(
|
||||
ghostId = id,
|
||||
slug = slug,
|
||||
|
|
@ -546,6 +547,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,
|
||||
|
|
@ -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 {
|
||||
val tagNames: List<String> = try {
|
||||
Gson().fromJson(tags, object : TypeToken<List<String>>() {}.type) ?: emptyList()
|
||||
|
|
@ -632,6 +671,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,
|
||||
|
|
|
|||
|
|
@ -66,6 +66,30 @@ class PostUploadWorker(
|
|||
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
|
||||
var uploadedFileUrl = post.uploadedFileUrl
|
||||
if (uploadedFileUrl == null && post.fileUri != null) {
|
||||
|
|
@ -79,8 +103,14 @@ class PostUploadWorker(
|
|||
}
|
||||
|
||||
val mobiledoc = MobiledocBuilder.build(
|
||||
post.content, allImageUrls, post.linkUrl, post.linkTitle, post.linkDescription,
|
||||
post.imageAlt,
|
||||
text = post.content,
|
||||
imageUrls = allImageUrls,
|
||||
linkUrl = post.linkUrl,
|
||||
linkTitle = post.linkTitle,
|
||||
linkDescription = post.linkDescription,
|
||||
imageAlt = post.imageAlt,
|
||||
videoUrl = videoUrl,
|
||||
audioUrl = audioUrl,
|
||||
fileUrl = uploadedFileUrl,
|
||||
fileName = post.fileName,
|
||||
fileSize = 0 // Size not stored in LocalPost; Ghost only needs src/fileName
|
||||
|
|
|
|||
|
|
@ -598,6 +598,133 @@ class MobiledocBuilderTest {
|
|||
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 ---
|
||||
|
||||
@Test
|
||||
|
|
|
|||
Loading…
Reference in a new issue