Skip to content

Commit 9d7a897

Browse files
Allow selecting a videoframe as activity image
1 parent b07c17a commit 9d7a897

File tree

5 files changed

+267
-28
lines changed

5 files changed

+267
-28
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Unreleased
22

33
- Allow adding multiple images to an activity
4+
- Allow selecting a single video frame as image for an activity
45

56
# 2025.11.1
67

app/src/main/java/com/inky/fitnesscalendar/ui/components/imageComponents.kt

Lines changed: 245 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,80 @@
11
package com.inky.fitnesscalendar.ui.components
22

33
import android.content.Context
4+
import android.graphics.Bitmap
5+
import android.media.MediaMetadataRetriever
46
import android.net.Uri
57
import androidx.activity.compose.rememberLauncherForActivityResult
68
import androidx.activity.result.PickVisualMediaRequest
79
import androidx.activity.result.contract.ActivityResultContracts
10+
import androidx.compose.animation.AnimatedVisibility
11+
import androidx.compose.animation.fadeIn
12+
import androidx.compose.animation.fadeOut
13+
import androidx.compose.foundation.Image
14+
import androidx.compose.foundation.background
815
import androidx.compose.foundation.clickable
916
import androidx.compose.foundation.layout.Arrangement
17+
import androidx.compose.foundation.layout.Box
1018
import androidx.compose.foundation.layout.BoxWithConstraints
1119
import androidx.compose.foundation.layout.aspectRatio
20+
import androidx.compose.foundation.layout.fillMaxSize
21+
import androidx.compose.foundation.layout.padding
1222
import androidx.compose.foundation.layout.width
1323
import androidx.compose.foundation.lazy.LazyRow
1424
import androidx.compose.foundation.lazy.items
25+
import androidx.compose.material3.BottomAppBar
26+
import androidx.compose.material3.BottomAppBarDefaults
27+
import androidx.compose.material3.CircularProgressIndicator
1528
import androidx.compose.material3.DropdownMenuItem
29+
import androidx.compose.material3.ExperimentalMaterial3Api
30+
import androidx.compose.material3.FloatingActionButton
31+
import androidx.compose.material3.FloatingActionButtonDefaults
1632
import androidx.compose.material3.Icon
1733
import androidx.compose.material3.MaterialTheme
34+
import androidx.compose.material3.Scaffold
35+
import androidx.compose.material3.Slider
1836
import androidx.compose.material3.Text
37+
import androidx.compose.material3.TopAppBar
38+
import androidx.compose.material3.TopAppBarDefaults
1939
import androidx.compose.runtime.Composable
2040
import androidx.compose.runtime.LaunchedEffect
21-
import androidx.compose.runtime.mutableStateListOf
41+
import androidx.compose.runtime.getValue
42+
import androidx.compose.runtime.mutableIntStateOf
43+
import androidx.compose.runtime.mutableStateOf
44+
import androidx.compose.runtime.remember
45+
import androidx.compose.runtime.rememberCoroutineScope
2246
import androidx.compose.runtime.saveable.rememberSaveable
47+
import androidx.compose.runtime.setValue
48+
import androidx.compose.runtime.snapshotFlow
49+
import androidx.compose.ui.Alignment
2350
import androidx.compose.ui.Modifier
2451
import androidx.compose.ui.draw.clip
52+
import androidx.compose.ui.graphics.Color
53+
import androidx.compose.ui.graphics.asImageBitmap
2554
import androidx.compose.ui.layout.ContentScale
2655
import androidx.compose.ui.platform.LocalContext
2756
import androidx.compose.ui.res.painterResource
2857
import androidx.compose.ui.res.stringResource
2958
import androidx.compose.ui.unit.dp
59+
import androidx.compose.ui.window.Popup
60+
import androidx.compose.ui.window.PopupProperties
61+
import androidx.compose.ui.zIndex
3062
import coil.compose.AsyncImage
3163
import coil.compose.AsyncImagePainter
3264
import com.inky.fitnesscalendar.R
3365
import com.inky.fitnesscalendar.data.ImageName
66+
import com.inky.fitnesscalendar.ui.util.Icons
3467
import com.inky.fitnesscalendar.util.NonEmptyList
3568
import com.inky.fitnesscalendar.util.asNonEmptyOrNull
3669
import com.inky.fitnesscalendar.util.copyFileToStorage
3770
import com.inky.fitnesscalendar.util.getOrCreateImagesDir
71+
import com.inky.fitnesscalendar.util.writeBitmapToStorage
72+
import kotlinx.coroutines.Dispatchers
73+
import kotlinx.coroutines.FlowPreview
74+
import kotlinx.coroutines.flow.collectLatest
75+
import kotlinx.coroutines.flow.debounce
76+
import kotlinx.coroutines.launch
77+
import kotlin.math.roundToInt
3878

3979
const val IMAGE_ASPECT_RATIO: Float = 4 / 3f
4080

@@ -85,6 +125,43 @@ enum class ImageLimit {
85125
Multiple
86126
}
87127

128+
enum class MediaType {
129+
Image,
130+
Video;
131+
132+
companion object {
133+
fun fromUri(context: Context, uri: Uri): MediaType? {
134+
val type = context.contentResolver.getType(uri) ?: return null
135+
return when {
136+
type.startsWith("image/") -> Image
137+
type.startsWith("video/") -> Video
138+
else -> null
139+
}
140+
}
141+
}
142+
}
143+
144+
data class SelectedMediaState(
145+
val imageUris: List<Uri>,
146+
val videoUris: List<Uri>
147+
) {
148+
companion object {
149+
fun fromUris(context: Context, uris: List<Uri>): SelectedMediaState {
150+
val imageUris = mutableListOf<Uri>()
151+
val videoUris = mutableListOf<Uri>()
152+
for (uri in uris) {
153+
when (MediaType.fromUri(context, uri)) {
154+
MediaType.Image -> imageUris.add(uri)
155+
MediaType.Video -> videoUris.add(uri)
156+
null -> {}
157+
}
158+
}
159+
160+
return SelectedMediaState(imageUris, videoUris)
161+
}
162+
}
163+
}
164+
88165
/**
89166
* A menu button that displays an image picker.
90167
* @param imageLimit: Whether multiple images or just one image can be picked
@@ -97,22 +174,28 @@ fun SelectImageDropdownMenuItem(
97174
onImages: (NonEmptyList<ImageName>) -> Unit,
98175
onDismissMenu: () -> Unit,
99176
) {
100-
val selectedImages = rememberSaveable { mutableStateListOf<ImageName>() }
177+
val context = LocalContext.current
178+
var receivedUris by rememberSaveable { mutableStateOf<SelectedMediaState?>(null) }
101179

102-
LaunchedEffect(selectedImages.size) {
103-
selectedImages.asNonEmptyOrNull()?.let {
104-
onImages(it)
105-
onDismissMenu()
180+
LaunchedEffect(receivedUris) {
181+
if (receivedUris != null && receivedUris?.videoUris?.isEmpty() == true) {
182+
receivedUris?.imageUris?.let { uris ->
183+
val storedImages = saveImageUris(context, uris)
184+
storedImages.asNonEmptyOrNull()?.let {
185+
onImages(it)
186+
onDismissMenu()
187+
}
188+
}
106189
}
107190
}
108191

109192
val launcher = when (imageLimit) {
110-
ImageLimit.Single -> rememberImagePickerLauncher(onName = {
111-
selectedImages.add(it)
193+
ImageLimit.Single -> rememberImagePickerLauncher(onUri = {
194+
receivedUris = SelectedMediaState.fromUris(context, listOf(it))
112195
})
113196

114-
ImageLimit.Multiple -> rememberMultipleImagePickerLauncher(onImages = {
115-
selectedImages.addAll(it)
197+
ImageLimit.Multiple -> rememberMultipleImagePickerLauncher(onUris = {
198+
receivedUris = SelectedMediaState.fromUris(context, it)
116199
})
117200
}
118201

@@ -131,33 +214,167 @@ fun SelectImageDropdownMenuItem(
131214
},
132215
onClick = {
133216
launcher.launch(
134-
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
217+
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)
135218
)
136219
}
137220
)
221+
222+
val videoUris = receivedUris?.videoUris?.asNonEmptyOrNull()
223+
if (videoUris != null) {
224+
ExtractImageFromVideoPopup(videoUris, onFramesSelected = { frameUris ->
225+
receivedUris = receivedUris?.run {
226+
SelectedMediaState(
227+
imageUris = imageUris + frameUris,
228+
videoUris = emptyList()
229+
)
230+
}
231+
})
232+
}
138233
}
139234

235+
@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class)
140236
@Composable
141-
private fun rememberMultipleImagePickerLauncher(
142-
onImages: (NonEmptyList<ImageName>) -> Unit,
143-
context: Context = LocalContext.current
144-
) = rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { uris ->
145-
val imageDir = context.getOrCreateImagesDir()
146-
val imageNames =
147-
uris.mapNotNull { context.copyFileToStorage(it, imageDir)?.name?.let { ImageName(it) } }
148-
imageNames.asNonEmptyOrNull()?.let { onImages(it) }
237+
private fun ExtractImageFromVideoPopup(
238+
videoUris: NonEmptyList<Uri>,
239+
onFramesSelected: (List<Uri>) -> Unit
240+
) {
241+
val context = LocalContext.current
242+
val scope = rememberCoroutineScope()
243+
244+
var saving by rememberSaveable { mutableStateOf(false) }
245+
var savedFrames by rememberSaveable(videoUris) { mutableStateOf(emptyList<Uri?>()) }
246+
val videoUri = videoUris[savedFrames.size]
247+
248+
val retriever = remember(videoUri) {
249+
MediaMetadataRetriever().apply {
250+
setDataSource(context, videoUri)
251+
}
252+
}
253+
val lastFrameIndex = remember(videoUri) {
254+
val numFrames =
255+
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_FRAME_COUNT)
256+
?.toInt() ?: 1
257+
numFrames - 1
258+
}
259+
var frameIndex by rememberSaveable(videoUri) { mutableIntStateOf(0) }
260+
var loadingFrame by rememberSaveable(videoUri) { mutableStateOf(true) }
261+
var frameBitmap by rememberSaveable(videoUri) { mutableStateOf<Bitmap?>(null) }
262+
263+
LaunchedEffect(frameIndex, videoUri) {
264+
snapshotFlow { frameIndex }.debounce(75).collectLatest { frameIndex ->
265+
loadingFrame = true
266+
try {
267+
frameBitmap = retriever.getFrameAtIndex(frameIndex)
268+
} finally {
269+
loadingFrame = false
270+
}
271+
}
272+
}
273+
274+
Popup(properties = PopupProperties(usePlatformDefaultWidth = false)) {
275+
Scaffold(
276+
topBar = {
277+
TopAppBar(
278+
colors = TopAppBarDefaults.topAppBarColors(
279+
containerColor = MaterialTheme.colorScheme.primaryContainer,
280+
titleContentColor = MaterialTheme.colorScheme.primary,
281+
),
282+
title = { Text(stringResource(R.string.select_frame_of_video)) }
283+
)
284+
},
285+
bottomBar = {
286+
BottomAppBar(
287+
floatingActionButton = {
288+
AnimatedVisibility(!saving) {
289+
FloatingActionButton(
290+
onClick = {
291+
if (saving) {
292+
return@FloatingActionButton
293+
}
294+
saving = true
295+
scope.launch(Dispatchers.IO) {
296+
val bitmap = retriever.getFrameAtIndex(frameIndex)
297+
val frameUri =
298+
bitmap?.let { context.writeBitmapToStorage(it)?.uri }
299+
val newSavedFrames = savedFrames + frameUri
300+
saving = false
301+
if (savedFrames.size + 1 < videoUris.size) {
302+
savedFrames = newSavedFrames
303+
} else {
304+
onFramesSelected(newSavedFrames.filterNotNull())
305+
}
306+
}
307+
},
308+
containerColor = BottomAppBarDefaults.bottomAppBarFabColor,
309+
elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation()
310+
) {
311+
if (savedFrames.size + 1 < videoUris.size) {
312+
Icons.KeyboardArrowRight(stringResource(R.string.next_video))
313+
} else {
314+
Icons.Check(stringResource(R.string.select_frame_of_video))
315+
}
316+
}
317+
}
318+
},
319+
actions = {
320+
321+
}
322+
)
323+
}
324+
) { innerPadding ->
325+
Box(
326+
contentAlignment = Alignment.BottomCenter,
327+
modifier = Modifier
328+
.fillMaxSize()
329+
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
330+
.padding(innerPadding)
331+
) {
332+
frameBitmap?.let { bitmap ->
333+
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
334+
AnimatedVisibility(loadingFrame, enter = fadeIn(), exit = fadeOut()) {
335+
CircularProgressIndicator()
336+
}
337+
Image(
338+
bitmap = bitmap.asImageBitmap(),
339+
contentDescription = null,
340+
modifier = Modifier
341+
.fillMaxSize()
342+
.zIndex(-1f)
343+
)
344+
}
345+
346+
Slider(
347+
value = frameIndex.toFloat(),
348+
valueRange = 0f..lastFrameIndex.toFloat(),
349+
onValueChange = { frameIndex = it.roundToInt() },
350+
modifier = Modifier
351+
.padding(horizontal = 8.dp)
352+
.clip(MaterialTheme.shapes.small)
353+
.background(Color(0f, 0f, 0f, 0.25f))
354+
)
355+
}
356+
}
357+
}
358+
}
149359
}
150360

151361
@Composable
152-
private fun rememberImagePickerLauncher(
153-
onName: (ImageName) -> Unit,
154-
context: Context = LocalContext.current
155-
) =
362+
private fun rememberMultipleImagePickerLauncher(onUris: (NonEmptyList<Uri>) -> Unit) =
363+
rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { uris ->
364+
uris.asNonEmptyOrNull()?.let(onUris)
365+
}
366+
367+
@Composable
368+
private fun rememberImagePickerLauncher(onUri: (Uri) -> Unit) =
156369
rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
157370
if (uri != null) {
158-
val name = context.copyFileToStorage(uri, context.getOrCreateImagesDir())?.name
159-
if (name != null) {
160-
onName(ImageName(name))
161-
}
371+
onUri(uri)
162372
}
163-
}
373+
}
374+
375+
private fun saveImageUris(context: Context, uris: List<Uri>): List<ImageName> {
376+
val imagesDir = context.getOrCreateImagesDir()
377+
return uris.mapNotNull { uri ->
378+
context.copyFileToStorage(uri, imagesDir)?.name?.let { ImageName(it) }
379+
}
380+
}

app/src/main/java/com/inky/fitnesscalendar/util/file_storage.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ package com.inky.fitnesscalendar.util
22

33
import android.content.ContentResolver
44
import android.content.Context
5+
import android.graphics.Bitmap
56
import android.net.Uri
67
import android.util.Log
78
import androidx.core.net.toUri
89
import java.io.File
910
import java.io.FileNotFoundException
11+
import java.io.FileOutputStream
1012
import java.time.temporal.ChronoUnit
1113
import java.util.UUID
1214

@@ -25,6 +27,21 @@ fun Context.copyFileToStorage(input: Uri, targetDir: File): InternalFile? {
2527
}
2628
}
2729

30+
fun Context.writeBitmapToStorage(bitmap: Bitmap): InternalFile? {
31+
val filename = UUID.randomUUID().toString()
32+
val file = File(cacheDir, filename)
33+
34+
val success = FileOutputStream(file).use {
35+
bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)
36+
}
37+
38+
return if (success) {
39+
InternalFile(name = filename, uri = file.toUri())
40+
} else {
41+
null
42+
}
43+
}
44+
2845
fun Context.copyFile(input: Uri, output: Uri): Uri? = copyFile(contentResolver, input, output)
2946

3047
private fun copyFile(contentResolver: ContentResolver, input: Uri, output: Uri): Uri? {

0 commit comments

Comments
 (0)