mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 11:55:47 +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
|
// 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")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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?,
|
||||||
|
|
|
||||||
|
|
@ -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> =
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
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.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue