11package com.inky.fitnesscalendar.ui.components
22
33import android.content.Context
4+ import android.graphics.Bitmap
5+ import android.media.MediaMetadataRetriever
46import android.net.Uri
57import androidx.activity.compose.rememberLauncherForActivityResult
68import androidx.activity.result.PickVisualMediaRequest
79import 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
815import androidx.compose.foundation.clickable
916import androidx.compose.foundation.layout.Arrangement
17+ import androidx.compose.foundation.layout.Box
1018import androidx.compose.foundation.layout.BoxWithConstraints
1119import androidx.compose.foundation.layout.aspectRatio
20+ import androidx.compose.foundation.layout.fillMaxSize
21+ import androidx.compose.foundation.layout.padding
1222import androidx.compose.foundation.layout.width
1323import androidx.compose.foundation.lazy.LazyRow
1424import androidx.compose.foundation.lazy.items
25+ import androidx.compose.material3.BottomAppBar
26+ import androidx.compose.material3.BottomAppBarDefaults
27+ import androidx.compose.material3.CircularProgressIndicator
1528import androidx.compose.material3.DropdownMenuItem
29+ import androidx.compose.material3.ExperimentalMaterial3Api
30+ import androidx.compose.material3.FloatingActionButton
31+ import androidx.compose.material3.FloatingActionButtonDefaults
1632import androidx.compose.material3.Icon
1733import androidx.compose.material3.MaterialTheme
34+ import androidx.compose.material3.Scaffold
35+ import androidx.compose.material3.Slider
1836import androidx.compose.material3.Text
37+ import androidx.compose.material3.TopAppBar
38+ import androidx.compose.material3.TopAppBarDefaults
1939import androidx.compose.runtime.Composable
2040import 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
2246import androidx.compose.runtime.saveable.rememberSaveable
47+ import androidx.compose.runtime.setValue
48+ import androidx.compose.runtime.snapshotFlow
49+ import androidx.compose.ui.Alignment
2350import androidx.compose.ui.Modifier
2451import androidx.compose.ui.draw.clip
52+ import androidx.compose.ui.graphics.Color
53+ import androidx.compose.ui.graphics.asImageBitmap
2554import androidx.compose.ui.layout.ContentScale
2655import androidx.compose.ui.platform.LocalContext
2756import androidx.compose.ui.res.painterResource
2857import androidx.compose.ui.res.stringResource
2958import 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
3062import coil.compose.AsyncImage
3163import coil.compose.AsyncImagePainter
3264import com.inky.fitnesscalendar.R
3365import com.inky.fitnesscalendar.data.ImageName
66+ import com.inky.fitnesscalendar.ui.util.Icons
3467import com.inky.fitnesscalendar.util.NonEmptyList
3568import com.inky.fitnesscalendar.util.asNonEmptyOrNull
3669import com.inky.fitnesscalendar.util.copyFileToStorage
3770import 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
3979const 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+ }
0 commit comments