diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0bd534e..59c8667 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -107,6 +107,10 @@ dependencies { // Jsoup for OpenGraph parsing implementation("org.jsoup:jsoup:1.17.2") + // Media3 for video/audio playback + implementation("androidx.media3:media3-exoplayer:1.2.1") + implementation("androidx.media3:media3-ui:1.2.1") + // Testing testImplementation("junit:junit:4.13.2") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") diff --git a/app/src/main/java/com/swoosh/microblog/data/MobiledocBuilder.kt b/app/src/main/java/com/swoosh/microblog/data/MobiledocBuilder.kt index 8bfc973..dfc7393 100644 --- a/app/src/main/java/com/swoosh/microblog/data/MobiledocBuilder.kt +++ b/app/src/main/java/com/swoosh/microblog/data/MobiledocBuilder.kt @@ -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, + 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, + 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) diff --git a/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt b/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt index 0d45de7..b8bad24 100644 --- a/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt +++ b/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt @@ -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 + + @Multipart + @POST("ghost/api/admin/media/upload/") + suspend fun uploadMedia( + @Part file: MultipartBody.Part, + @Part("ref") ref: RequestBody? = null + ): Response + + @Multipart + @POST("ghost/api/admin/media/thumbnail/upload/") + suspend fun uploadMediaThumbnail( + @Part file: MultipartBody.Part, + @Part("ref") ref: RequestBody? = null + ): Response } data class ImageUploadResponse( diff --git a/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt b/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt index 45dca66..17730d2 100644 --- a/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt +++ b/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt @@ -132,6 +132,8 @@ data class FeedPost( val imageUrl: String?, val imageAlt: String? = null, val imageUrls: List = emptyList(), + val videoUrl: String? = null, + val audioUrl: String? = null, val linkUrl: String?, val linkTitle: String?, val linkDescription: String?, diff --git a/app/src/main/java/com/swoosh/microblog/data/model/MediaModels.kt b/app/src/main/java/com/swoosh/microblog/data/model/MediaModels.kt new file mode 100644 index 0000000..7765d18 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/data/model/MediaModels.kt @@ -0,0 +1,11 @@ +package com.swoosh.microblog.data.model + +data class MediaUploadResponse( + val media: List +) + +data class UploadedMedia( + val url: String, + val ref: String?, + val fileName: String? +) diff --git a/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt b/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt index aa7e66b..72e3729 100644 --- a/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt +++ b/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt @@ -161,8 +161,8 @@ class PostRepository(private val context: Context) { suspend fun uploadImage(uri: Uri): Result = 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 = + 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) } diff --git a/app/src/main/java/com/swoosh/microblog/ui/components/MediaPlayers.kt b/app/src/main/java/com/swoosh/microblog/ui/components/MediaPlayers.kt new file mode 100644 index 0000000..338a130 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/ui/components/MediaPlayers.kt @@ -0,0 +1,238 @@ +package com.swoosh.microblog.ui.components + +import android.view.ViewGroup +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.PlayerView + +/** + * Video player composable using ExoPlayer. + * Shows a play button overlay; tap to play/pause. No autoplay. + */ +@Composable +fun VideoPlayer( + url: String, + modifier: Modifier = Modifier, + compact: Boolean = false +) { + val context = LocalContext.current + var isPlaying by remember { mutableStateOf(false) } + var showOverlay by remember { mutableStateOf(true) } + + val exoPlayer = remember(url) { + ExoPlayer.Builder(context).build().apply { + setMediaItem(MediaItem.fromUri(url)) + prepare() + playWhenReady = false + } + } + + DisposableEffect(exoPlayer) { + val listener = object : Player.Listener { + override fun onIsPlayingChanged(playing: Boolean) { + isPlaying = playing + if (!playing && exoPlayer.playbackState == Player.STATE_ENDED) { + showOverlay = true + } + } + } + exoPlayer.addListener(listener) + onDispose { + exoPlayer.removeListener(listener) + exoPlayer.release() + } + } + + val height = if (compact) 180.dp else 240.dp + + Box( + modifier = modifier + .fillMaxWidth() + .height(height) + .clip(MaterialTheme.shapes.medium) + .clickable { + if (isPlaying) { + exoPlayer.pause() + showOverlay = true + } else { + exoPlayer.play() + showOverlay = false + } + }, + contentAlignment = Alignment.Center + ) { + AndroidView( + factory = { ctx -> + PlayerView(ctx).apply { + player = exoPlayer + useController = false + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + } + }, + modifier = Modifier.fillMaxSize() + ) + + // Play button overlay + if (showOverlay || !isPlaying) { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.8f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = if (isPlaying) "Pause" else "Play", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(32.dp) + ) + } + } + } +} + +/** + * Compact audio player with play/pause button, progress slider, and duration text. + */ +@Composable +fun AudioPlayer( + url: String, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + var isPlaying by remember { mutableStateOf(false) } + var currentPosition by remember { mutableLongStateOf(0L) } + var duration by remember { mutableLongStateOf(0L) } + + val exoPlayer = remember(url) { + ExoPlayer.Builder(context).build().apply { + setMediaItem(MediaItem.fromUri(url)) + prepare() + playWhenReady = false + } + } + + DisposableEffect(exoPlayer) { + val listener = object : Player.Listener { + override fun onIsPlayingChanged(playing: Boolean) { + isPlaying = playing + } + + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == Player.STATE_READY) { + duration = exoPlayer.duration.coerceAtLeast(0L) + } + if (playbackState == Player.STATE_ENDED) { + isPlaying = false + exoPlayer.seekTo(0) + exoPlayer.pause() + } + } + } + exoPlayer.addListener(listener) + onDispose { + exoPlayer.removeListener(listener) + exoPlayer.release() + } + } + + // Update position periodically while playing + LaunchedEffect(isPlaying) { + while (isPlaying) { + currentPosition = exoPlayer.currentPosition.coerceAtLeast(0L) + kotlinx.coroutines.delay(500) + } + } + + OutlinedCard( + modifier = modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Music note icon + Icon( + imageVector = Icons.Default.MusicNote, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.width(8.dp)) + + // Play/Pause button + IconButton( + onClick = { + if (isPlaying) { + exoPlayer.pause() + } else { + exoPlayer.play() + } + }, + modifier = Modifier.size(36.dp) + ) { + Icon( + imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = if (isPlaying) "Pause" else "Play", + tint = MaterialTheme.colorScheme.primary + ) + } + + // Progress slider + Slider( + value = if (duration > 0) currentPosition.toFloat() / duration.toFloat() else 0f, + onValueChange = { fraction -> + val newPosition = (fraction * duration).toLong() + exoPlayer.seekTo(newPosition) + currentPosition = newPosition + }, + modifier = Modifier.weight(1f), + colors = SliderDefaults.colors( + thumbColor = MaterialTheme.colorScheme.primary, + activeTrackColor = MaterialTheme.colorScheme.primary + ) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + // Duration text + Text( + text = formatDuration(if (isPlaying) currentPosition else duration), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +private fun formatDuration(millis: Long): String { + if (millis <= 0) return "0:00" + val totalSeconds = millis / 1000 + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + return "$minutes:${seconds.toString().padStart(2, '0')}" +} diff --git a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt index bdaeac9..da26802 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt @@ -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. */ diff --git a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt index 802b316..3492171 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt @@ -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 = 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, diff --git a/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt index dcfa1f1..a5deafa 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt @@ -53,7 +53,9 @@ import com.swoosh.microblog.data.model.LinkPreview import com.swoosh.microblog.data.model.PostStats import com.swoosh.microblog.data.model.QueueStatus import com.swoosh.microblog.ui.animation.SwooshMotion +import com.swoosh.microblog.ui.components.AudioPlayer import com.swoosh.microblog.ui.components.ConfirmationDialog +import com.swoosh.microblog.ui.components.VideoPlayer import com.swoosh.microblog.ui.feed.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 { diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt index 8395ce4..009ff9b 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt @@ -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)) diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt index 0173054..c59186c 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt @@ -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 { + if (mobiledoc == null) return null to null + return try { + val json = com.google.gson.JsonParser.parseString(mobiledoc).asJsonObject + val cards = json.getAsJsonArray("cards") ?: return null to null + var videoUrl: String? = null + var audioUrl: String? = null + for (card in cards) { + val cardArray = card.asJsonArray + if (cardArray.size() >= 2) { + when (cardArray[0].asString) { + "video" -> { + if (videoUrl == null) { + videoUrl = cardArray[1].asJsonObject.get("src")?.asString + } + } + "audio" -> { + if (audioUrl == null) { + audioUrl = cardArray[1].asJsonObject.get("src")?.asString + } + } + } + } + } + videoUrl to audioUrl + } catch (e: Exception) { + null to null + } + } + private fun LocalPost.toFeedPost(): FeedPost { val tagNames: List = try { Gson().fromJson(tags, object : TypeToken>() {}.type) ?: emptyList() @@ -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, diff --git a/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt b/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt index 94c9c59..be6525b 100644 --- a/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt +++ b/app/src/main/java/com/swoosh/microblog/worker/PostUploadWorker.kt @@ -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 diff --git a/app/src/test/java/com/swoosh/microblog/data/MobiledocBuilderTest.kt b/app/src/test/java/com/swoosh/microblog/data/MobiledocBuilderTest.kt index 7031b2e..b916527 100644 --- a/app/src/test/java/com/swoosh/microblog/data/MobiledocBuilderTest.kt +++ b/app/src/test/java/com/swoosh/microblog/data/MobiledocBuilderTest.kt @@ -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