feat: add video and audio picker buttons and upload support in Composer

- Add videoUri, audioUri, uploadedVideoUrl, uploadedAudioUrl, isUploadingMedia to ComposerUiState
- Add setVideo/removeVideo/setAudio/removeAudio methods to ComposerViewModel
- Update submitPost to upload video/audio via uploadMediaFile and pass URLs to MobiledocBuilder
- Save videoUri/audioUri to LocalPost for offline queue
- Add Video and Audio picker buttons to composer toolbar
- Add MediaPreviewCard composable showing filename, file size, and remove button
- Update PostUploadWorker to upload video/audio before building mobiledoc
This commit is contained in:
Paweł Orzech 2026-03-20 00:46:27 +01:00
parent 96e2799787
commit 27782893dc
3 changed files with 232 additions and 4 deletions

View file

@ -110,8 +110,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()) } derivedStateOf { !state.isSubmitting && (state.text.isNotBlank() || state.imageUris.isNotEmpty() || state.videoUri != null || state.audioUri != null) }
} }
Scaffold( Scaffold(
@ -381,6 +395,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")
} }
@ -433,6 +453,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
)
}
}
// Link preview // Link preview
AnimatedVisibility( AnimatedVisibility(
visible = state.isLoadingLink, visible = state.isLoadingLink,
@ -884,6 +938,95 @@ class HashtagVisualTransformation(private val hashtagColor: Color) : VisualTrans
} }
} }
/**
* Compact preview card for attached video or audio files.
* Shows an icon, label, filename (from URI), and a remove button.
*/
@Composable
fun MediaPreviewCard(
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
uri: Uri?,
onRemove: () -> Unit
) {
val context = LocalContext.current
val fileName = remember(uri) {
uri?.let { mediaUri ->
try {
context.contentResolver.query(mediaUri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
if (cursor.moveToFirst() && nameIndex >= 0) {
cursor.getString(nameIndex)
} else null
}
} catch (_: Exception) {
null
} ?: mediaUri.lastPathSegment ?: "Unknown file"
} ?: "Unknown file"
}
val fileSize = remember(uri) {
uri?.let { mediaUri ->
try {
context.contentResolver.query(mediaUri, null, null, null, null)?.use { cursor ->
val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE)
if (cursor.moveToFirst() && sizeIndex >= 0) {
val bytes = cursor.getLong(sizeIndex)
when {
bytes < 1024 -> "$bytes B"
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
else -> "%.1f MB".format(bytes / (1024.0 * 1024.0))
}
} else null
}
} catch (_: Exception) {
null
}
}
}
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = fileName,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
)
if (fileSize != null) {
Text(
text = fileSize,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
IconButton(onClick = onRemove) {
Icon(Icons.Default.Close, "Remove $label", Modifier.size(18.dp))
}
}
}
}
/** /**
* Tag input section with autocomplete suggestions and tag chips. * Tag input section with autocomplete suggestions and tag chips.
*/ */

View file

@ -156,6 +156,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 {
@ -234,7 +250,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()) return if (state.text.isBlank() && state.imageUris.isEmpty() && 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) }
@ -259,6 +275,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,
@ -290,12 +308,44 @@ 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) }
val featureImage = uploadedImageUrls.firstOrNull() val featureImage = uploadedImageUrls.firstOrNull()
val mobiledoc = MobiledocBuilder.build( val mobiledoc = MobiledocBuilder.build(
state.text, uploadedImageUrls, state.text, uploadedImageUrls,
state.linkPreview?.url, state.linkPreview?.title, state.linkPreview?.description, state.linkPreview?.url, state.linkPreview?.title, state.linkPreview?.description,
altText altText,
videoUrl,
audioUrl
) )
val ghostTags = allTags.map { GhostTag(name = it) } val ghostTags = allTags.map { GhostTag(name = it) }
@ -336,6 +386,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,
@ -369,6 +423,11 @@ data class ComposerUiState(
val text: String = "", val text: String = "",
val imageUris: List<Uri> = emptyList(), val imageUris: List<Uri> = emptyList(),
val imageAlt: String = "", val imageAlt: String = "",
val videoUri: Uri? = null,
val audioUri: Uri? = null,
val uploadedVideoUrl: String? = null,
val uploadedAudioUrl: String? = null,
val isUploadingMedia: Boolean = false,
val linkPreview: LinkPreview? = null, val linkPreview: LinkPreview? = null,
val isLoadingLink: Boolean = false, val isLoadingLink: Boolean = false,
val scheduledAt: String? = null, val scheduledAt: String? = null,

View file

@ -66,9 +66,35 @@ 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()
}
val mobiledoc = MobiledocBuilder.build( val mobiledoc = MobiledocBuilder.build(
post.content, allImageUrls, post.linkUrl, post.linkTitle, post.linkDescription, post.content, allImageUrls, post.linkUrl, post.linkTitle, post.linkDescription,
post.imageAlt post.imageAlt,
videoUrl,
audioUrl
) )
// Parse tags from JSON stored in LocalPost // Parse tags from JSON stored in LocalPost