@imgly/editor-react-native 1.80.0 → 1.81.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/build.gradle +1 -1
- package/android/src/main/assets/ly.img.memories.style/content.json +50 -0
- package/android/src/main/assets/ly.img.memories.style/thumbnails/bubblegum.png +0 -0
- package/android/src/main/assets/ly.img.memories.style/thumbnails/hologram.png +0 -0
- package/android/src/main/assets/ly.img.memories.style/thumbnails/noir.png +0 -0
- package/android/src/main/assets/ly.img.memories.style/videos/bubblegum.mp4 +0 -0
- package/android/src/main/assets/ly.img.memories.style/videos/hologram.mp4 +0 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/MemoriesApp.kt +77 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/MemoriesConfiguration.kt +38 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/MemoriesEditor.kt +79 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/MemoriesViewModel.kt +469 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/callback/OnCreate.kt +69 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/callback/OnExport.kt +46 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/component/BottomPanel.kt +151 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/component/Dock.kt +145 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/component/ExportOverlay.kt +229 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/component/NavigationBar.kt +80 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/component/Overlay.kt +138 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/extension/EditorScopeExt.kt +111 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/AddPhotoAlternate.kt +79 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/ArrowForward.kt +45 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/Audiotrack.kt +43 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/Check.kt +39 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/CheckCircle.kt +44 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/Close.kt +43 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/Delete.kt +54 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/DeleteOutline.kt +53 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/Fullscreen.kt +74 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/Image.kt +51 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/Info.kt +48 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/Pause.kt +46 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/PlayArrow.kt +38 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/RepeatOff.kt +80 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/RepeatOn.kt +59 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/SkipPrevious.kt +46 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/Sort.kt +55 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/Style.kt +59 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/Title.kt +43 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/TouchTriple.kt +134 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/VolumeDown.kt +48 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/iconPack/VolumeUp.kt +60 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/model/ExportStatus.kt +20 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/model/ImageItem.kt +8 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/model/TimelineImage.kt +18 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/scene/Events.kt +59 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/scene/Playback.kt +29 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/scene/SceneSetup.kt +79 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/scene/Timeline.kt +27 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/scene/Title.kt +162 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/scene/TrackEditor.kt +740 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/screen/ImageSelectionScreen.kt +387 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/screen/LoadingScreen.kt +258 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/sheet/FilterStylesSheet.kt +167 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/sheet/FilterStylesSheetType.kt +21 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/sheet/VolumeSheet.kt +104 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/style/StyleApplier.kt +220 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/style/StyleAssetSource.kt +43 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/style/VideoStyle.kt +118 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/util/Animations.kt +58 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/util/Constants.kt +20 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/util/ImageDate.kt +81 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/util/Utils.kt +27 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/widget/CenteredAddImageButton.kt +82 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/widget/DraggableImageItem.kt +145 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/widget/FullscreenVideoControls.kt +136 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/widget/ImageGrid.kt +122 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/widget/ImageGridOverlay.kt +149 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/widget/ImageTimelineStrip.kt +323 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/widget/ImageToolbar.kt +401 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/widget/ReorderableGridState.kt +183 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/widget/VideoControlPanel.kt +180 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/memories/widget/VideoThumbnail.kt +110 -0
- package/android/src/main/kotlin/ly/img/editor/configuration/video/callback/OnCreate.kt +0 -7
- package/android/src/main/kotlin/ly/img/editor/configuration/video/component/InspectorBar.kt +2 -0
- package/ios/StarterKits/Video/callbacks/OnCreate+Video.swift +1 -1
- package/ios/StarterKits/Video/components/Dock+Video.swift +1 -0
- package/ios/StarterKits/Video/components/InspectorBar+Video.swift +4 -0
- package/package.json +1 -1
- package/src/NativeIMGLYEditor.ts +1 -1
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
package ly.img.editor.configuration.memories.util
|
|
2
|
+
|
|
3
|
+
import ly.img.engine.AnimationType
|
|
4
|
+
import ly.img.engine.Engine
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The slide animations for the Memories slideshow — **this is the file to edit**.
|
|
8
|
+
*
|
|
9
|
+
* Every photo/clip gets an in-animation (as it appears) and an out-animation (as it leaves),
|
|
10
|
+
* bundled together as an [AnimationPair]. The kit picks one pair at random per slide
|
|
11
|
+
* ([getRandomAnimationPair]).
|
|
12
|
+
*
|
|
13
|
+
* An [AnimationPair] is a *recipe*, not a pair of engine blocks: [AnimationPair.createIn] /
|
|
14
|
+
* [AnimationPair.createOut] build a **fresh** engine animation each time they are called. Engine
|
|
15
|
+
* animations are single-owner (1:1 with a design block), so every slide must own its own animation
|
|
16
|
+
* instances — assigning one pooled animation block to several slides corrupts the scene (the engine
|
|
17
|
+
* re-points the animation to the newest block only) and later crashes: destroying a slide auto-
|
|
18
|
+
* destroys the shared animation, leaving every other slide holding a dangling id. [applySlideAnimation]
|
|
19
|
+
* calls these builders per slide so each clip gets its own blocks.
|
|
20
|
+
*
|
|
21
|
+
* To change the motion between slides, edit the list in [createAnimationPairs] — add, remove, or
|
|
22
|
+
* tweak entries (e.g. the `blurZoom` zoom strength), or write a whole new pair from two engine
|
|
23
|
+
* animations. The low-level builders below wrap the engine calls so the list stays readable.
|
|
24
|
+
*/
|
|
25
|
+
data class AnimationPair(
|
|
26
|
+
val createIn: (Engine) -> Int,
|
|
27
|
+
val createOut: (Engine) -> Int,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
object Animations {
|
|
31
|
+
/** The pool of animations a slide can use. Edit this list to change the slideshow's motion. */
|
|
32
|
+
fun createAnimationPairs(): List<AnimationPair> = listOf(
|
|
33
|
+
blurZoom(),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
/** Pick a random pair for the next slide. */
|
|
37
|
+
fun getRandomAnimationPair(animationPairs: List<AnimationPair>): AnimationPair = animationPairs.random()
|
|
38
|
+
|
|
39
|
+
// ---- Low-level builders (you usually don't need to touch these) --------------------------
|
|
40
|
+
|
|
41
|
+
/** Blur in, then crop-zoom out. [zoomScale] is how far the out-animation zooms (1.0 = none). */
|
|
42
|
+
private fun blurZoom(zoomScale: Float = 1.7f): AnimationPair = AnimationPair(
|
|
43
|
+
createIn = { engine ->
|
|
44
|
+
engine.block.createAnimation(AnimationType.Blur).also { inAnimation ->
|
|
45
|
+
engine.block.setDuration(inAnimation, IMAGE_DURATION * .5f)
|
|
46
|
+
engine.block.setEnum(inAnimation, "animationEasing", "EaseOut")
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
createOut = { engine ->
|
|
50
|
+
engine.block.createAnimation(AnimationType.CropZoom).also { outAnimation ->
|
|
51
|
+
engine.block.setDuration(outAnimation, IMAGE_DURATION * .5f)
|
|
52
|
+
engine.block.setBoolean(outAnimation, "animation/crop_zoom/fade", true)
|
|
53
|
+
engine.block.setEnum(outAnimation, "animationEasing", "EaseIn")
|
|
54
|
+
engine.block.setFloat(outAnimation, "animation/crop_zoom/scale", zoomScale)
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
)
|
|
58
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
package ly.img.editor.configuration.memories.util
|
|
2
|
+
|
|
3
|
+
// The knobs for the slideshow. Tweak these to retime, resize, or restyle the montage.
|
|
4
|
+
|
|
5
|
+
// Canvas — the slideshow page size. Portrait by default; square = 1080 x 1080, landscape = 1920 x 1080.
|
|
6
|
+
const val PAGE_WIDTH = 1080f
|
|
7
|
+
const val PAGE_HEIGHT = 1920f
|
|
8
|
+
|
|
9
|
+
// Inset (dp) keeping the page off the screen edges and bottom controls.
|
|
10
|
+
const val CANVAS_PADDING_DP = 10
|
|
11
|
+
|
|
12
|
+
// Timing (seconds).
|
|
13
|
+
const val IMAGE_DURATION = 8.0
|
|
14
|
+
val OVERLAP_DURATION = IMAGE_DURATION * 0.4
|
|
15
|
+
const val TITLE_DURATION = 3.0
|
|
16
|
+
|
|
17
|
+
// Title card.
|
|
18
|
+
const val TITLE_FONT_SIZE = 48f
|
|
19
|
+
const val BURST_IMAGE_COUNT = 10 // images flashed behind the title
|
|
20
|
+
const val BURST_TITLE_FRACTION = 0.9 // burst spans this fraction of the title duration
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
package ly.img.editor.configuration.memories.util
|
|
2
|
+
|
|
3
|
+
import android.content.ContentResolver
|
|
4
|
+
import android.net.Uri
|
|
5
|
+
import android.provider.MediaStore
|
|
6
|
+
import androidx.exifinterface.media.ExifInterface
|
|
7
|
+
import kotlinx.coroutines.Dispatchers
|
|
8
|
+
import kotlinx.coroutines.withContext
|
|
9
|
+
import ly.img.editor.configuration.memories.model.ImageItem
|
|
10
|
+
import java.text.SimpleDateFormat
|
|
11
|
+
import java.util.Locale
|
|
12
|
+
import java.util.TimeZone
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Build [ImageItem]s from freshly-picked [uris], assigning ids from [baseId] + 1 so they stay unique
|
|
16
|
+
* even after deletions. Runs entirely on [Dispatchers.IO] because resolving MIME types and reading
|
|
17
|
+
* EXIF dates hits the ContentResolver; callers dispatch the result back to the main thread.
|
|
18
|
+
*/
|
|
19
|
+
suspend fun buildImageItems(
|
|
20
|
+
contentResolver: ContentResolver,
|
|
21
|
+
uris: List<Uri>,
|
|
22
|
+
baseId: Int,
|
|
23
|
+
): List<ImageItem> = withContext(Dispatchers.IO) {
|
|
24
|
+
uris.mapIndexed { index, uri ->
|
|
25
|
+
val isVideo = contentResolver.getType(uri)?.startsWith("video/") == true
|
|
26
|
+
ImageItem(
|
|
27
|
+
id = baseId + index + 1,
|
|
28
|
+
url = uri.toString(),
|
|
29
|
+
isVideo = isVideo,
|
|
30
|
+
creationDate = if (isVideo) null else getImageCreationDate(contentResolver, uri),
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Extract creation date from an image URI using MediaStore and EXIF data.
|
|
37
|
+
* Returns timestamp in milliseconds since epoch, or null if not available.
|
|
38
|
+
*/
|
|
39
|
+
fun getImageCreationDate(
|
|
40
|
+
contentResolver: ContentResolver,
|
|
41
|
+
uri: Uri,
|
|
42
|
+
): Long? {
|
|
43
|
+
try {
|
|
44
|
+
// First try to get DATE_TAKEN from MediaStore (most reliable for gallery images)
|
|
45
|
+
val projection = arrayOf(MediaStore.Images.ImageColumns.DATE_TAKEN)
|
|
46
|
+
contentResolver.query(uri, projection, null, null, null)?.use { cursor ->
|
|
47
|
+
if (cursor.moveToFirst()) {
|
|
48
|
+
val dateIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.DATE_TAKEN)
|
|
49
|
+
val dateTaken = cursor.getLong(dateIndex)
|
|
50
|
+
if (dateTaken > 0) {
|
|
51
|
+
return dateTaken
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} catch (e: Exception) {
|
|
56
|
+
// MediaStore query failed, continue to EXIF fallback
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
// Fallback to EXIF data
|
|
61
|
+
contentResolver.openInputStream(uri)?.use { inputStream ->
|
|
62
|
+
val exif = ExifInterface(inputStream)
|
|
63
|
+
val dateTimeOriginal = exif.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL)
|
|
64
|
+
?: exif.getAttribute(ExifInterface.TAG_DATETIME)
|
|
65
|
+
?: exif.getAttribute(ExifInterface.TAG_DATETIME_DIGITIZED)
|
|
66
|
+
|
|
67
|
+
dateTimeOriginal?.let { dateString ->
|
|
68
|
+
// EXIF DateTimeOriginal is local wall-clock time with no zone. Interpret it in the
|
|
69
|
+
// device's zone so the epoch lines up with MediaStore's DATE_TAKEN (which we prefer
|
|
70
|
+
// above); parsing it as UTC instead skews mixed-source sorting by the UTC offset.
|
|
71
|
+
val sdf = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.US)
|
|
72
|
+
sdf.timeZone = TimeZone.getDefault()
|
|
73
|
+
return sdf.parse(dateString)?.time
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} catch (e: Exception) {
|
|
77
|
+
// EXIF extraction failed
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
package ly.img.editor.configuration.memories.util
|
|
2
|
+
|
|
3
|
+
import ly.img.editor.configuration.memories.model.ImageItem
|
|
4
|
+
|
|
5
|
+
/** Sort images chronologically by creation date; images without a date sort last (asc)/first (desc). */
|
|
6
|
+
fun sortByDate(
|
|
7
|
+
images: List<ImageItem>,
|
|
8
|
+
ascending: Boolean,
|
|
9
|
+
): List<ImageItem> = if (ascending) {
|
|
10
|
+
images.sortedWith(compareBy<ImageItem> { it.creationDate ?: Long.MAX_VALUE }.thenBy { it.id })
|
|
11
|
+
} else {
|
|
12
|
+
images.sortedWith(compareByDescending<ImageItem> { it.creationDate ?: Long.MIN_VALUE }.thenByDescending { it.id })
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Detect whether [images] is already in chronological order.
|
|
17
|
+
* Returns true for ascending (oldest first), false for descending (newest first),
|
|
18
|
+
* or null if the list is unsorted (or too small / has no dates to be meaningfully ordered).
|
|
19
|
+
*/
|
|
20
|
+
fun detectSortOrder(images: List<ImageItem>): Boolean? {
|
|
21
|
+
if (images.size < 2) return null
|
|
22
|
+
// Note: images without a creation date fall back to id order, so a freshly-added
|
|
23
|
+
// batch still reads as "in order" (ascending).
|
|
24
|
+
if (images == sortByDate(images, ascending = true)) return true
|
|
25
|
+
if (images == sortByDate(images, ascending = false)) return false
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
package ly.img.editor.configuration.memories.widget
|
|
2
|
+
|
|
3
|
+
import androidx.compose.foundation.background
|
|
4
|
+
import androidx.compose.foundation.clickable
|
|
5
|
+
import androidx.compose.foundation.layout.Arrangement
|
|
6
|
+
import androidx.compose.foundation.layout.Box
|
|
7
|
+
import androidx.compose.foundation.layout.Column
|
|
8
|
+
import androidx.compose.foundation.layout.Spacer
|
|
9
|
+
import androidx.compose.foundation.layout.fillMaxSize
|
|
10
|
+
import androidx.compose.foundation.layout.height
|
|
11
|
+
import androidx.compose.foundation.layout.offset
|
|
12
|
+
import androidx.compose.foundation.layout.padding
|
|
13
|
+
import androidx.compose.foundation.layout.size
|
|
14
|
+
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
15
|
+
import androidx.compose.material3.Card
|
|
16
|
+
import androidx.compose.material3.Icon
|
|
17
|
+
import androidx.compose.material3.MaterialTheme
|
|
18
|
+
import androidx.compose.material3.Text
|
|
19
|
+
import androidx.compose.runtime.Composable
|
|
20
|
+
import androidx.compose.ui.Alignment
|
|
21
|
+
import androidx.compose.ui.Modifier
|
|
22
|
+
import androidx.compose.ui.text.style.TextAlign
|
|
23
|
+
import androidx.compose.ui.unit.IntOffset
|
|
24
|
+
import androidx.compose.ui.unit.dp
|
|
25
|
+
import ly.img.editor.configuration.memories.iconPack.AddPhotoAlternate
|
|
26
|
+
import ly.img.editor.core.iconpack.IconPack
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Empty-state call to action shown when no photos are selected yet:
|
|
30
|
+
* a tappable card to add your media.
|
|
31
|
+
*/
|
|
32
|
+
@Composable
|
|
33
|
+
fun CenteredAddImageButton(
|
|
34
|
+
onClick: () -> Unit,
|
|
35
|
+
modifier: Modifier = Modifier,
|
|
36
|
+
columnHeight: Float = 0f,
|
|
37
|
+
) {
|
|
38
|
+
Box(
|
|
39
|
+
modifier = modifier.fillMaxSize(),
|
|
40
|
+
contentAlignment = Alignment.Center,
|
|
41
|
+
) {
|
|
42
|
+
Column(
|
|
43
|
+
modifier = Modifier.offset {
|
|
44
|
+
IntOffset(0, columnHeight.toInt() / 2)
|
|
45
|
+
},
|
|
46
|
+
horizontalAlignment = Alignment.CenterHorizontally,
|
|
47
|
+
) {
|
|
48
|
+
Card(
|
|
49
|
+
modifier = Modifier
|
|
50
|
+
.size(160.dp)
|
|
51
|
+
.clickable { onClick() },
|
|
52
|
+
shape = RoundedCornerShape(12.dp),
|
|
53
|
+
) {
|
|
54
|
+
Column(
|
|
55
|
+
modifier = Modifier
|
|
56
|
+
.fillMaxSize()
|
|
57
|
+
.background(
|
|
58
|
+
MaterialTheme.colorScheme.surfaceVariant,
|
|
59
|
+
RoundedCornerShape(12.dp),
|
|
60
|
+
)
|
|
61
|
+
.padding(16.dp),
|
|
62
|
+
horizontalAlignment = Alignment.CenterHorizontally,
|
|
63
|
+
verticalArrangement = Arrangement.Center,
|
|
64
|
+
) {
|
|
65
|
+
Icon(
|
|
66
|
+
imageVector = IconPack.AddPhotoAlternate,
|
|
67
|
+
contentDescription = null,
|
|
68
|
+
modifier = Modifier.size(48.dp),
|
|
69
|
+
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
70
|
+
)
|
|
71
|
+
Spacer(modifier = Modifier.height(16.dp))
|
|
72
|
+
Text(
|
|
73
|
+
text = "Add your media",
|
|
74
|
+
style = MaterialTheme.typography.bodyLarge,
|
|
75
|
+
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
76
|
+
textAlign = TextAlign.Center,
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
package/android/src/main/kotlin/ly/img/editor/configuration/memories/widget/DraggableImageItem.kt
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
package ly.img.editor.configuration.memories.widget
|
|
2
|
+
|
|
3
|
+
import androidx.compose.foundation.BorderStroke
|
|
4
|
+
import androidx.compose.foundation.background
|
|
5
|
+
import androidx.compose.foundation.border
|
|
6
|
+
import androidx.compose.foundation.clickable
|
|
7
|
+
import androidx.compose.foundation.layout.Box
|
|
8
|
+
import androidx.compose.foundation.layout.aspectRatio
|
|
9
|
+
import androidx.compose.foundation.layout.fillMaxSize
|
|
10
|
+
import androidx.compose.foundation.layout.padding
|
|
11
|
+
import androidx.compose.foundation.layout.size
|
|
12
|
+
import androidx.compose.foundation.shape.CircleShape
|
|
13
|
+
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
14
|
+
import androidx.compose.material3.Card
|
|
15
|
+
import androidx.compose.material3.CardDefaults
|
|
16
|
+
import androidx.compose.material3.Icon
|
|
17
|
+
import androidx.compose.material3.MaterialTheme
|
|
18
|
+
import androidx.compose.material3.Text
|
|
19
|
+
import androidx.compose.runtime.Composable
|
|
20
|
+
import androidx.compose.ui.Alignment
|
|
21
|
+
import androidx.compose.ui.Modifier
|
|
22
|
+
import androidx.compose.ui.graphics.Color
|
|
23
|
+
import androidx.compose.ui.layout.ContentScale
|
|
24
|
+
import androidx.compose.ui.text.font.FontWeight
|
|
25
|
+
import androidx.compose.ui.unit.Dp
|
|
26
|
+
import androidx.compose.ui.unit.dp
|
|
27
|
+
import androidx.compose.ui.unit.sp
|
|
28
|
+
import coil.compose.AsyncImage
|
|
29
|
+
import ly.img.editor.configuration.memories.iconPack.CheckCircle
|
|
30
|
+
import ly.img.editor.configuration.memories.iconPack.PlayArrow
|
|
31
|
+
import ly.img.editor.configuration.memories.model.ImageItem
|
|
32
|
+
import ly.img.editor.core.iconpack.IconPack
|
|
33
|
+
|
|
34
|
+
@Composable
|
|
35
|
+
fun DraggableImageItem(
|
|
36
|
+
image: ImageItem,
|
|
37
|
+
elevation: Dp,
|
|
38
|
+
index: Int,
|
|
39
|
+
isSelectedForDeletion: Boolean = false,
|
|
40
|
+
onToggleSelection: (() -> Unit)? = null,
|
|
41
|
+
) {
|
|
42
|
+
// The long-press-drag gesture is owned by the parent grid (ImageGrid); this item only renders.
|
|
43
|
+
Card(
|
|
44
|
+
modifier = Modifier
|
|
45
|
+
.aspectRatio(4 / 5f)
|
|
46
|
+
.then(
|
|
47
|
+
if (onToggleSelection != null) {
|
|
48
|
+
Modifier.clickable { onToggleSelection() }
|
|
49
|
+
} else {
|
|
50
|
+
Modifier
|
|
51
|
+
},
|
|
52
|
+
),
|
|
53
|
+
elevation = CardDefaults.cardElevation(defaultElevation = elevation),
|
|
54
|
+
shape = RoundedCornerShape(8.dp),
|
|
55
|
+
border = if (isSelectedForDeletion) {
|
|
56
|
+
BorderStroke(3.dp, MaterialTheme.colorScheme.primary)
|
|
57
|
+
} else {
|
|
58
|
+
null
|
|
59
|
+
},
|
|
60
|
+
) {
|
|
61
|
+
Box {
|
|
62
|
+
// For video, show a static middle frame; for images, load the URL directly.
|
|
63
|
+
val model: Any? = if (image.isVideo) rememberVideoThumbnail(image.url) else image.url
|
|
64
|
+
AsyncImage(
|
|
65
|
+
model = model,
|
|
66
|
+
contentDescription = if (image.isVideo) "Video ${image.id}" else "Image ${image.id}",
|
|
67
|
+
modifier = Modifier.fillMaxSize(),
|
|
68
|
+
contentScale = ContentScale.Crop,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
// Play badge to mark video items.
|
|
72
|
+
if (image.isVideo) {
|
|
73
|
+
Box(
|
|
74
|
+
modifier = Modifier
|
|
75
|
+
.align(Alignment.Center)
|
|
76
|
+
.size(34.dp)
|
|
77
|
+
.background(Color.Black.copy(alpha = 0.45f), CircleShape),
|
|
78
|
+
contentAlignment = Alignment.Center,
|
|
79
|
+
) {
|
|
80
|
+
Icon(
|
|
81
|
+
imageVector = IconPack.PlayArrow,
|
|
82
|
+
contentDescription = null,
|
|
83
|
+
tint = Color.White,
|
|
84
|
+
modifier = Modifier.size(22.dp),
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Highlight scrim when marked for deletion
|
|
90
|
+
if (isSelectedForDeletion) {
|
|
91
|
+
Box(
|
|
92
|
+
modifier = Modifier
|
|
93
|
+
.fillMaxSize()
|
|
94
|
+
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.25f)),
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Position index badge
|
|
99
|
+
Box(
|
|
100
|
+
modifier = Modifier
|
|
101
|
+
.align(Alignment.BottomStart)
|
|
102
|
+
.padding(4.dp)
|
|
103
|
+
.background(
|
|
104
|
+
Color.Black.copy(alpha = 0.7f),
|
|
105
|
+
RoundedCornerShape(4.dp),
|
|
106
|
+
)
|
|
107
|
+
.padding(horizontal = 6.dp, vertical = 2.dp),
|
|
108
|
+
) {
|
|
109
|
+
Text(
|
|
110
|
+
text = "${index + 1}",
|
|
111
|
+
color = Color.White,
|
|
112
|
+
fontSize = 12.sp,
|
|
113
|
+
fontWeight = FontWeight.Bold,
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Selection checkbox (only when selection is enabled by the host)
|
|
118
|
+
if (onToggleSelection != null) {
|
|
119
|
+
Box(
|
|
120
|
+
modifier = Modifier
|
|
121
|
+
.align(Alignment.TopEnd)
|
|
122
|
+
.padding(6.dp),
|
|
123
|
+
) {
|
|
124
|
+
if (isSelectedForDeletion) {
|
|
125
|
+
Icon(
|
|
126
|
+
imageVector = IconPack.CheckCircle,
|
|
127
|
+
contentDescription = "Selected for deletion",
|
|
128
|
+
tint = MaterialTheme.colorScheme.primary,
|
|
129
|
+
modifier = Modifier
|
|
130
|
+
.size(24.dp)
|
|
131
|
+
.background(Color.White, CircleShape),
|
|
132
|
+
)
|
|
133
|
+
} else {
|
|
134
|
+
Box(
|
|
135
|
+
modifier = Modifier
|
|
136
|
+
.size(24.dp)
|
|
137
|
+
.background(Color.Black.copy(alpha = 0.35f), CircleShape)
|
|
138
|
+
.border(2.dp, Color.White, CircleShape),
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
package ly.img.editor.configuration.memories.widget
|
|
2
|
+
|
|
3
|
+
import androidx.compose.foundation.background
|
|
4
|
+
import androidx.compose.foundation.clickable
|
|
5
|
+
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
6
|
+
import androidx.compose.foundation.layout.Arrangement
|
|
7
|
+
import androidx.compose.foundation.layout.Box
|
|
8
|
+
import androidx.compose.foundation.layout.Row
|
|
9
|
+
import androidx.compose.foundation.layout.fillMaxWidth
|
|
10
|
+
import androidx.compose.foundation.layout.height
|
|
11
|
+
import androidx.compose.foundation.layout.padding
|
|
12
|
+
import androidx.compose.foundation.layout.size
|
|
13
|
+
import androidx.compose.foundation.shape.CircleShape
|
|
14
|
+
import androidx.compose.material.ripple.rememberRipple
|
|
15
|
+
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
16
|
+
import androidx.compose.material3.Icon
|
|
17
|
+
import androidx.compose.material3.Slider
|
|
18
|
+
import androidx.compose.material3.SliderDefaults
|
|
19
|
+
import androidx.compose.material3.Text
|
|
20
|
+
import androidx.compose.runtime.Composable
|
|
21
|
+
import androidx.compose.runtime.getValue
|
|
22
|
+
import androidx.compose.runtime.mutableStateOf
|
|
23
|
+
import androidx.compose.runtime.remember
|
|
24
|
+
import androidx.compose.runtime.setValue
|
|
25
|
+
import androidx.compose.ui.Alignment
|
|
26
|
+
import androidx.compose.ui.Modifier
|
|
27
|
+
import androidx.compose.ui.draw.clip
|
|
28
|
+
import androidx.compose.ui.graphics.Brush
|
|
29
|
+
import androidx.compose.ui.graphics.Color
|
|
30
|
+
import androidx.compose.ui.tooling.preview.Preview
|
|
31
|
+
import androidx.compose.ui.unit.dp
|
|
32
|
+
import androidx.compose.ui.unit.sp
|
|
33
|
+
import ly.img.editor.configuration.memories.iconPack.Pause
|
|
34
|
+
import ly.img.editor.configuration.memories.iconPack.PlayArrow
|
|
35
|
+
import ly.img.editor.core.iconpack.IconPack
|
|
36
|
+
|
|
37
|
+
@OptIn(ExperimentalMaterial3Api::class)
|
|
38
|
+
@Composable
|
|
39
|
+
fun FullscreenVideoControls(
|
|
40
|
+
isPlaying: Boolean,
|
|
41
|
+
progress: Float,
|
|
42
|
+
currentTime: Double,
|
|
43
|
+
totalDuration: Double,
|
|
44
|
+
onPlayPauseClick: () -> Unit,
|
|
45
|
+
onSeek: (Float) -> Unit,
|
|
46
|
+
modifier: Modifier = Modifier,
|
|
47
|
+
) {
|
|
48
|
+
fun formatTime(seconds: Double): String {
|
|
49
|
+
val totalSeconds = seconds.toInt().coerceAtLeast(0)
|
|
50
|
+
return "%02d:%02d".format(totalSeconds / 60, totalSeconds % 60)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
Box(
|
|
54
|
+
modifier = modifier
|
|
55
|
+
.fillMaxWidth()
|
|
56
|
+
.height(80.dp)
|
|
57
|
+
.background(
|
|
58
|
+
brush = Brush.verticalGradient(
|
|
59
|
+
colors = listOf(
|
|
60
|
+
Color.Transparent,
|
|
61
|
+
Color.Black.copy(alpha = 0.6f),
|
|
62
|
+
),
|
|
63
|
+
),
|
|
64
|
+
),
|
|
65
|
+
) {
|
|
66
|
+
Row(
|
|
67
|
+
modifier = Modifier
|
|
68
|
+
.fillMaxWidth()
|
|
69
|
+
.align(Alignment.BottomCenter)
|
|
70
|
+
.padding(horizontal = 16.dp, vertical = 16.dp),
|
|
71
|
+
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
|
72
|
+
verticalAlignment = Alignment.CenterVertically,
|
|
73
|
+
) {
|
|
74
|
+
// Play/Pause Button
|
|
75
|
+
Box(
|
|
76
|
+
modifier = Modifier
|
|
77
|
+
.size(48.dp)
|
|
78
|
+
.clip(CircleShape)
|
|
79
|
+
.clickable(
|
|
80
|
+
interactionSource = remember { MutableInteractionSource() },
|
|
81
|
+
indication = rememberRipple(
|
|
82
|
+
bounded = false,
|
|
83
|
+
radius = 28.dp,
|
|
84
|
+
color = Color.White,
|
|
85
|
+
),
|
|
86
|
+
) { onPlayPauseClick() }
|
|
87
|
+
.background(Color.Black.copy(alpha = 0.3f)),
|
|
88
|
+
contentAlignment = Alignment.Center,
|
|
89
|
+
) {
|
|
90
|
+
Icon(
|
|
91
|
+
imageVector = if (isPlaying) IconPack.Pause else IconPack.PlayArrow,
|
|
92
|
+
contentDescription = if (isPlaying) "Pause" else "Play",
|
|
93
|
+
tint = Color.White,
|
|
94
|
+
modifier = Modifier.size(32.dp),
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Slider
|
|
99
|
+
Slider(
|
|
100
|
+
value = progress,
|
|
101
|
+
onValueChange = { newValue ->
|
|
102
|
+
onSeek(newValue)
|
|
103
|
+
},
|
|
104
|
+
colors = SliderDefaults.colors(
|
|
105
|
+
thumbColor = Color.White,
|
|
106
|
+
activeTrackColor = Color.White,
|
|
107
|
+
inactiveTrackColor = Color.White.copy(alpha = 0.3f),
|
|
108
|
+
),
|
|
109
|
+
modifier = Modifier.weight(1f),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
// Current time over total duration, to the right of the slider
|
|
113
|
+
Text(
|
|
114
|
+
text = "${formatTime(currentTime)} / ${formatTime(totalDuration)}",
|
|
115
|
+
color = Color.White,
|
|
116
|
+
fontSize = 12.sp,
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
@Preview(showBackground = true, backgroundColor = 0xFF1A1A1A)
|
|
123
|
+
@Composable
|
|
124
|
+
fun FullscreenVideoControlsPreview() {
|
|
125
|
+
var isPlaying by remember { mutableStateOf(false) }
|
|
126
|
+
var progress by remember { mutableStateOf(0.3f) }
|
|
127
|
+
|
|
128
|
+
FullscreenVideoControls(
|
|
129
|
+
isPlaying = isPlaying,
|
|
130
|
+
progress = progress,
|
|
131
|
+
currentTime = progress * 120.0,
|
|
132
|
+
totalDuration = 120.0,
|
|
133
|
+
onPlayPauseClick = { isPlaying = !isPlaying },
|
|
134
|
+
onSeek = { progress = it },
|
|
135
|
+
)
|
|
136
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
package ly.img.editor.configuration.memories.widget
|
|
2
|
+
|
|
3
|
+
import androidx.compose.animation.core.animateDpAsState
|
|
4
|
+
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
|
5
|
+
import androidx.compose.foundation.gestures.scrollBy
|
|
6
|
+
import androidx.compose.foundation.layout.Arrangement
|
|
7
|
+
import androidx.compose.foundation.layout.Box
|
|
8
|
+
import androidx.compose.foundation.layout.PaddingValues
|
|
9
|
+
import androidx.compose.foundation.layout.fillMaxHeight
|
|
10
|
+
import androidx.compose.foundation.lazy.grid.GridCells
|
|
11
|
+
import androidx.compose.foundation.lazy.grid.LazyGridState
|
|
12
|
+
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
|
13
|
+
import androidx.compose.foundation.lazy.grid.items
|
|
14
|
+
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
|
15
|
+
import androidx.compose.runtime.Composable
|
|
16
|
+
import androidx.compose.runtime.LaunchedEffect
|
|
17
|
+
import androidx.compose.runtime.getValue
|
|
18
|
+
import androidx.compose.ui.Modifier
|
|
19
|
+
import androidx.compose.ui.graphics.graphicsLayer
|
|
20
|
+
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
|
21
|
+
import androidx.compose.ui.input.pointer.pointerInput
|
|
22
|
+
import androidx.compose.ui.platform.LocalHapticFeedback
|
|
23
|
+
import androidx.compose.ui.unit.dp
|
|
24
|
+
import androidx.compose.ui.zIndex
|
|
25
|
+
import kotlinx.coroutines.delay
|
|
26
|
+
import kotlinx.coroutines.isActive
|
|
27
|
+
import ly.img.editor.configuration.memories.model.ImageItem
|
|
28
|
+
|
|
29
|
+
@Composable
|
|
30
|
+
fun ImageGrid(
|
|
31
|
+
modifier: Modifier = Modifier,
|
|
32
|
+
images: List<ImageItem>,
|
|
33
|
+
onImagesChanged: (List<ImageItem>) -> Unit,
|
|
34
|
+
contentPadding: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 72.dp),
|
|
35
|
+
lazyGridState: LazyGridState = rememberLazyGridState(),
|
|
36
|
+
selectedForDeletion: Set<Int> = emptySet(),
|
|
37
|
+
onToggleSelection: ((Int) -> Unit)? = null,
|
|
38
|
+
) {
|
|
39
|
+
// Empty state is owned by the caller (e.g. ImageSelectionScreen); render nothing here.
|
|
40
|
+
if (images.isEmpty()) return
|
|
41
|
+
|
|
42
|
+
val hapticFeedback = LocalHapticFeedback.current
|
|
43
|
+
|
|
44
|
+
// Hand-written drag-reorder engine (no third-party reorder library). The slop gate inside
|
|
45
|
+
// ReorderableGridState is what keeps the list from snapping when a top/bottom cell is picked up.
|
|
46
|
+
val dragState = rememberReorderableGridState(lazyGridState) { from, to ->
|
|
47
|
+
val newImages = images.toMutableList()
|
|
48
|
+
newImages.add(to, newImages.removeAt(from))
|
|
49
|
+
onImagesChanged(newImages)
|
|
50
|
+
|
|
51
|
+
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Continuous edge auto-scroll while dragging (only engages once the slop gate is satisfied).
|
|
55
|
+
LaunchedEffect(dragState.isDragging) {
|
|
56
|
+
if (!dragState.isDragging) return@LaunchedEffect
|
|
57
|
+
while (isActive) {
|
|
58
|
+
val speed = dragState.autoScrollSpeed
|
|
59
|
+
if (speed != 0f) lazyGridState.scrollBy(speed)
|
|
60
|
+
delay(16)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
LazyVerticalGrid(
|
|
65
|
+
columns = GridCells.Fixed(3),
|
|
66
|
+
state = lazyGridState,
|
|
67
|
+
contentPadding = contentPadding,
|
|
68
|
+
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
69
|
+
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
70
|
+
modifier = modifier
|
|
71
|
+
.fillMaxHeight()
|
|
72
|
+
.pointerInput(dragState) {
|
|
73
|
+
detectDragGesturesAfterLongPress(
|
|
74
|
+
onDragStart = { offset ->
|
|
75
|
+
dragState.onDragStart(offset)
|
|
76
|
+
if (dragState.isDragging) {
|
|
77
|
+
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
onDrag = { change, dragAmount ->
|
|
81
|
+
change.consume()
|
|
82
|
+
dragState.onDrag(dragAmount)
|
|
83
|
+
},
|
|
84
|
+
onDragEnd = dragState::onDragInterrupted,
|
|
85
|
+
onDragCancel = dragState::onDragInterrupted,
|
|
86
|
+
)
|
|
87
|
+
},
|
|
88
|
+
) {
|
|
89
|
+
items(images.size, key = { images[it].id }) { index ->
|
|
90
|
+
val isDragging = index == dragState.draggingItemIndex
|
|
91
|
+
val elevation by animateDpAsState(
|
|
92
|
+
targetValue = if (isDragging) 8.dp else 0.dp,
|
|
93
|
+
label = "elevation",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
val imageId = images[index].id
|
|
97
|
+
val itemModifier = if (isDragging) {
|
|
98
|
+
// Dragged cell is placed manually; raise it and translate it under the finger.
|
|
99
|
+
Modifier
|
|
100
|
+
.zIndex(1f)
|
|
101
|
+
.graphicsLayer {
|
|
102
|
+
translationX = dragState.draggingItemOffset.x
|
|
103
|
+
translationY = dragState.draggingItemOffset.y
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
// Non-dragged cells keep their default placement. A sliding reorder animation would
|
|
107
|
+
// need Modifier.animateItem() (Compose 1.7+); this module targets the older BOM.
|
|
108
|
+
Modifier
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
Box(itemModifier) {
|
|
112
|
+
DraggableImageItem(
|
|
113
|
+
image = images[index],
|
|
114
|
+
elevation = elevation,
|
|
115
|
+
index = index,
|
|
116
|
+
isSelectedForDeletion = imageId in selectedForDeletion,
|
|
117
|
+
onToggleSelection = onToggleSelection?.let { toggle -> { toggle(imageId) } },
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|