react-native-photo-video-editor 0.1.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.
Files changed (162) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/CONTRIBUTING.md +101 -0
  3. package/LICENSE +20 -0
  4. package/PhotoVideoEditor.podspec +23 -0
  5. package/README.md +53 -0
  6. package/android/build.gradle +61 -0
  7. package/android/src/main/AndroidManifest.xml +12 -0
  8. package/android/src/main/java/com/photovideoeditor/PhotoVideoEditorModule.kt +126 -0
  9. package/android/src/main/java/com/photovideoeditor/PhotoVideoEditorPackage.kt +31 -0
  10. package/android/src/main/java/com/photovideoeditor/editor/PhotoVideoEditorActivity.kt +2521 -0
  11. package/android/src/main/java/com/photovideoeditor/editor/README.md +5 -0
  12. package/android/src/main/java/com/photovideoeditor/files/SourceResolver.kt +29 -0
  13. package/android/src/main/java/com/photovideoeditor/media/BitmapSampling.kt +11 -0
  14. package/android/src/main/java/com/photovideoeditor/model/EditorRequest.kt +3 -0
  15. package/android/src/main/java/com/photovideoeditor/photo/export/PhotoExporter.kt +201 -0
  16. package/android/src/main/java/com/photovideoeditor/photo/render/OverlayGeometry.kt +22 -0
  17. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoAdjustmentRenderer.kt +160 -0
  18. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoAdjustments.kt +55 -0
  19. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoEditSession.kt +121 -0
  20. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoFilterPresets.kt +64 -0
  21. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayer.kt +54 -0
  22. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerRenderer.kt +108 -0
  23. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerStack.kt +60 -0
  24. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoTransformState.kt +52 -0
  25. package/android/src/main/java/com/photovideoeditor/photo/ui/CropOverlayView.kt +168 -0
  26. package/android/src/main/java/com/photovideoeditor/photo/ui/FilterThumbnailLoader.kt +57 -0
  27. package/android/src/main/java/com/photovideoeditor/photo/ui/LayerOverlayView.kt +202 -0
  28. package/android/src/main/java/com/photovideoeditor/photo/ui/PhotoPreviewRenderCoordinator.kt +57 -0
  29. package/android/src/main/java/com/photovideoeditor/photo/ui/ZoomableImageView.kt +96 -0
  30. package/android/src/main/java/com/photovideoeditor/stickers/OnlineStickerSheet.kt +310 -0
  31. package/android/src/main/java/com/photovideoeditor/stickers/OpenMojiCatalog.kt +172 -0
  32. package/android/src/main/java/com/photovideoeditor/stickers/RemoteImageLoader.kt +110 -0
  33. package/android/src/main/java/com/photovideoeditor/ui/DesignTokens.kt +93 -0
  34. package/android/src/main/java/com/photovideoeditor/ui/components/EditorComponents.kt +119 -0
  35. package/android/src/main/java/com/photovideoeditor/validation/EditorRequestValidator.kt +11 -0
  36. package/android/src/main/java/com/photovideoeditor/video/cache/TimelineThumbnailRepository.kt +130 -0
  37. package/android/src/main/java/com/photovideoeditor/video/export/VideoExporter.kt +319 -0
  38. package/android/src/main/java/com/photovideoeditor/video/render/VideoClip.kt +20 -0
  39. package/android/src/main/java/com/photovideoeditor/video/render/VideoEditSession.kt +136 -0
  40. package/android/src/main/java/com/photovideoeditor/video/render/VideoThumbnailGenerator.kt +31 -0
  41. package/android/src/main/java/com/photovideoeditor/video/render/VideoTransformState.kt +48 -0
  42. package/android/src/main/java/com/photovideoeditor/video/ui/TrimRangeView.kt +104 -0
  43. package/android/src/main/res/drawable/ic_add.xml +9 -0
  44. package/android/src/main/res/drawable/ic_arrow_back.xml +9 -0
  45. package/android/src/main/res/drawable/ic_aspect_ratio.xml +9 -0
  46. package/android/src/main/res/drawable/ic_auto_fix_high.xml +9 -0
  47. package/android/src/main/res/drawable/ic_check.xml +9 -0
  48. package/android/src/main/res/drawable/ic_chevron_left.xml +9 -0
  49. package/android/src/main/res/drawable/ic_chevron_right.xml +9 -0
  50. package/android/src/main/res/drawable/ic_close.xml +9 -0
  51. package/android/src/main/res/drawable/ic_compare.xml +9 -0
  52. package/android/src/main/res/drawable/ic_content_copy.xml +9 -0
  53. package/android/src/main/res/drawable/ic_content_cut.xml +9 -0
  54. package/android/src/main/res/drawable/ic_crop.xml +9 -0
  55. package/android/src/main/res/drawable/ic_crop_square.xml +9 -0
  56. package/android/src/main/res/drawable/ic_delete.xml +9 -0
  57. package/android/src/main/res/drawable/ic_drag_indicator.xml +9 -0
  58. package/android/src/main/res/drawable/ic_face.xml +9 -0
  59. package/android/src/main/res/drawable/ic_filter_center_focus.xml +9 -0
  60. package/android/src/main/res/drawable/ic_flare.xml +9 -0
  61. package/android/src/main/res/drawable/ic_format_size.xml +9 -0
  62. package/android/src/main/res/drawable/ic_fullscreen.xml +9 -0
  63. package/android/src/main/res/drawable/ic_layers.xml +9 -0
  64. package/android/src/main/res/drawable/ic_light_mode.xml +9 -0
  65. package/android/src/main/res/drawable/ic_lock.xml +9 -0
  66. package/android/src/main/res/drawable/ic_magic_button.xml +9 -0
  67. package/android/src/main/res/drawable/ic_movie.xml +9 -0
  68. package/android/src/main/res/drawable/ic_opacity.xml +9 -0
  69. package/android/src/main/res/drawable/ic_palette.xml +9 -0
  70. package/android/src/main/res/drawable/ic_photo_filter.xml +9 -0
  71. package/android/src/main/res/drawable/ic_redo.xml +9 -0
  72. package/android/src/main/res/drawable/ic_rotate_90_degrees_ccw.xml +9 -0
  73. package/android/src/main/res/drawable/ic_rotate_left.xml +9 -0
  74. package/android/src/main/res/drawable/ic_rotate_right.xml +9 -0
  75. package/android/src/main/res/drawable/ic_sentiment_satisfied.xml +9 -0
  76. package/android/src/main/res/drawable/ic_share.xml +9 -0
  77. package/android/src/main/res/drawable/ic_speed.xml +9 -0
  78. package/android/src/main/res/drawable/ic_straighten.xml +9 -0
  79. package/android/src/main/res/drawable/ic_title.xml +9 -0
  80. package/android/src/main/res/drawable/ic_tune.xml +9 -0
  81. package/android/src/main/res/drawable/ic_undo.xml +9 -0
  82. package/android/src/main/res/drawable/ic_visibility_off.xml +9 -0
  83. package/android/src/main/res/drawable/ic_wallpaper.xml +9 -0
  84. package/android/src/main/res/values/styles.xml +10 -0
  85. package/docs/api.md +9 -0
  86. package/docs/architecture.md +9 -0
  87. package/docs/customization.md +18 -0
  88. package/docs/development.md +21 -0
  89. package/docs/formats.md +27 -0
  90. package/docs/performance.md +3 -0
  91. package/docs/permissions.md +3 -0
  92. package/docs/photo-editor.md +41 -0
  93. package/docs/roadmap.md +12 -0
  94. package/docs/technology-decisions.md +17 -0
  95. package/docs/video-editor.md +76 -0
  96. package/ios/Editor/DesignTokens.swift +75 -0
  97. package/ios/Editor/PhotoVideoEditorOutcome.swift +9 -0
  98. package/ios/Editor/PhotoVideoEditorViewController.swift +2064 -0
  99. package/ios/Editor/README.md +5 -0
  100. package/ios/Editor/UI/EditorComponents.swift +138 -0
  101. package/ios/Files/SourceResolver.swift +13 -0
  102. package/ios/Models/EditorRequest.swift +3 -0
  103. package/ios/Photo/Export/PhotoExporter.swift +144 -0
  104. package/ios/Photo/Rendering/OverlayGeometry.swift +22 -0
  105. package/ios/Photo/Rendering/PhotoAdjustmentRenderer.swift +141 -0
  106. package/ios/Photo/Rendering/PhotoAdjustments.swift +57 -0
  107. package/ios/Photo/Rendering/PhotoEditSession.swift +121 -0
  108. package/ios/Photo/Rendering/PhotoFilterPresets.swift +45 -0
  109. package/ios/Photo/Rendering/PhotoLayer.swift +59 -0
  110. package/ios/Photo/Rendering/PhotoLayerRenderer.swift +94 -0
  111. package/ios/Photo/Rendering/PhotoLayerStack.swift +60 -0
  112. package/ios/Photo/Rendering/PhotoTransformState.swift +49 -0
  113. package/ios/Photo/Rendering/PixelBuffer.swift +68 -0
  114. package/ios/Photo/UI/CropOverlayView.swift +161 -0
  115. package/ios/Photo/UI/LayerOverlayView.swift +176 -0
  116. package/ios/Photo/UI/ZoomableImageView.swift +79 -0
  117. package/ios/PhotoVideoEditor.mm +20 -0
  118. package/ios/PhotoVideoEditor.swift +70 -0
  119. package/ios/Stickers/OnlineStickerSheetViewController.swift +418 -0
  120. package/ios/Stickers/OpenMojiCatalog.swift +188 -0
  121. package/ios/Stickers/RemoteImageLoader.swift +127 -0
  122. package/ios/Validation/EditorRequestValidator.swift +9 -0
  123. package/ios/Video/Cache/TimelineThumbnailRepository.swift +148 -0
  124. package/ios/Video/Export/VideoExporter.swift +275 -0
  125. package/ios/Video/Render/VideoClip.swift +21 -0
  126. package/ios/Video/Render/VideoEditSession.swift +99 -0
  127. package/ios/Video/Render/VideoThumbnailGenerator.swift +25 -0
  128. package/ios/Video/Render/VideoTransformState.swift +23 -0
  129. package/ios/Video/UI/TrimRangeView.swift +91 -0
  130. package/lib/module/NativePhotoVideoEditor.js +5 -0
  131. package/lib/module/NativePhotoVideoEditor.js.map +1 -0
  132. package/lib/module/errors.js +29 -0
  133. package/lib/module/errors.js.map +1 -0
  134. package/lib/module/events.js +27 -0
  135. package/lib/module/events.js.map +1 -0
  136. package/lib/module/index.js +91 -0
  137. package/lib/module/index.js.map +1 -0
  138. package/lib/module/package.json +1 -0
  139. package/lib/module/project.js +4 -0
  140. package/lib/module/project.js.map +1 -0
  141. package/lib/module/types.js +2 -0
  142. package/lib/module/types.js.map +1 -0
  143. package/lib/typescript/package.json +1 -0
  144. package/lib/typescript/src/NativePhotoVideoEditor.d.ts +11 -0
  145. package/lib/typescript/src/NativePhotoVideoEditor.d.ts.map +1 -0
  146. package/lib/typescript/src/errors.d.ts +11 -0
  147. package/lib/typescript/src/errors.d.ts.map +1 -0
  148. package/lib/typescript/src/events.d.ts +5 -0
  149. package/lib/typescript/src/events.d.ts.map +1 -0
  150. package/lib/typescript/src/index.d.ts +15 -0
  151. package/lib/typescript/src/index.d.ts.map +1 -0
  152. package/lib/typescript/src/project.d.ts +24 -0
  153. package/lib/typescript/src/project.d.ts.map +1 -0
  154. package/lib/typescript/src/types.d.ts +108 -0
  155. package/lib/typescript/src/types.d.ts.map +1 -0
  156. package/package.json +154 -0
  157. package/src/NativePhotoVideoEditor.ts +12 -0
  158. package/src/errors.ts +78 -0
  159. package/src/events.ts +43 -0
  160. package/src/index.ts +123 -0
  161. package/src/project.ts +17 -0
  162. package/src/types.ts +121 -0
@@ -0,0 +1,202 @@
1
+ package com.photovideoeditor.photo.ui
2
+
3
+ import android.content.Context
4
+ import android.graphics.Canvas
5
+ import android.graphics.Color
6
+ import android.graphics.DashPathEffect
7
+ import android.graphics.Paint
8
+ import android.graphics.RectF
9
+ import android.view.MotionEvent
10
+ import android.view.View
11
+ import android.text.TextPaint
12
+ import com.photovideoeditor.photo.render.PhotoLayer
13
+ import com.photovideoeditor.photo.render.LayerType
14
+ import kotlin.math.atan2
15
+ import kotlin.math.cos
16
+ import kotlin.math.hypot
17
+ import kotlin.math.min
18
+ import kotlin.math.sin
19
+
20
+ /** Reusable native move/scale/rotate surface shared by every overlay type. */
21
+ class LayerOverlayView(context: Context) : View(context) {
22
+ private enum class Gesture { NONE, DRAG, HANDLE, MULTI }
23
+ private var imageBounds = RectF()
24
+ var minScale = 0.2f
25
+ var maxScale = 8f
26
+ var layers: List<PhotoLayer> = emptyList()
27
+ set(value) { field = value; invalidate() }
28
+ var selectedLayerId: String? = null
29
+ set(value) { field = value; invalidate() }
30
+ var onLayerTapped: ((String?) -> Unit)? = null
31
+ var onLayerTransformChanged: ((id: String, x: Float, y: Float, scale: Float, rotationDegrees: Float) -> Unit)? = null
32
+ var onLayerTransformEnded: ((id: String) -> Unit)? = null
33
+
34
+ private var gesture = Gesture.NONE
35
+ private var gestureId: String? = null
36
+ private var startLayer: PhotoLayer? = null
37
+ private var startPointX = 0f
38
+ private var startPointY = 0f
39
+ private var startDistance = 1f
40
+ private var startAngle = 0f
41
+ private var startMidX = 0f
42
+ private var startMidY = 0f
43
+ private val density = resources.displayMetrics.density
44
+ private val handleRadius = 11f * density
45
+ private val handleTouchRadius = 30f * density
46
+ private val selectionPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
47
+ color = Color.WHITE; style = Paint.Style.STROKE; strokeWidth = 2f * density
48
+ pathEffect = DashPathEffect(floatArrayOf(8f * density, 5f * density), 0f)
49
+ }
50
+ private val handlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.WHITE }
51
+ private val handleGlyphPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
52
+ color = Color.rgb(87, 55, 245); style = Paint.Style.STROKE; strokeWidth = 2f * density
53
+ }
54
+
55
+ fun setImageBounds(bounds: RectF) { imageBounds = RectF(bounds); invalidate() }
56
+
57
+ override fun onDraw(canvas: Canvas) {
58
+ super.onDraw(canvas)
59
+ val selected = selectedLayer() ?: return
60
+ val center = centerOf(selected)
61
+ val half = selectionHalfExtents(selected)
62
+ canvas.save()
63
+ canvas.translate(center.first, center.second)
64
+ canvas.rotate(selected.rotationDegrees)
65
+ canvas.drawRect(-half.first, -half.second, half.first, half.second, selectionPaint)
66
+ canvas.drawCircle(half.first, half.second, handleRadius, handlePaint)
67
+ val glyph = handleRadius * 0.52f
68
+ canvas.drawLine(half.first - glyph, half.second + glyph, half.first + glyph, half.second - glyph, handleGlyphPaint)
69
+ canvas.restore()
70
+ }
71
+
72
+ override fun onTouchEvent(event: MotionEvent): Boolean {
73
+ if (imageBounds.width() <= 0 || imageBounds.height() <= 0) return false
74
+ when (event.actionMasked) {
75
+ MotionEvent.ACTION_DOWN -> {
76
+ val selected = selectedLayer()
77
+ val handleHit = selected?.takeIf { !it.locked && isHandleHit(it, event.x, event.y) }
78
+ val hit = handleHit ?: hitTest(event.x, event.y)
79
+ onLayerTapped?.invoke(hit?.id)
80
+ if (hit == null || hit.locked) { resetGesture(); return false }
81
+ gestureId = hit.id
82
+ startLayer = hit.copy()
83
+ startPointX = event.x; startPointY = event.y
84
+ if (handleHit != null) {
85
+ gesture = Gesture.HANDLE
86
+ val center = centerOf(hit)
87
+ startDistance = hypot(event.x - center.first, event.y - center.second).coerceAtLeast(1f)
88
+ startAngle = angle(center.first, center.second, event.x, event.y)
89
+ } else gesture = Gesture.DRAG
90
+ parent?.requestDisallowInterceptTouchEvent(true)
91
+ return true
92
+ }
93
+ MotionEvent.ACTION_POINTER_DOWN -> {
94
+ val layer = currentGestureLayer() ?: return false
95
+ if (event.pointerCount >= 2) {
96
+ startLayer = layer.copy()
97
+ val metrics = multiMetrics(event)
98
+ startDistance = metrics.distance.coerceAtLeast(1f); startAngle = metrics.angle
99
+ startMidX = metrics.midX; startMidY = metrics.midY
100
+ gesture = Gesture.MULTI
101
+ return true
102
+ }
103
+ }
104
+ MotionEvent.ACTION_MOVE -> {
105
+ val initial = startLayer ?: return false
106
+ when (gesture) {
107
+ Gesture.DRAG -> {
108
+ val x = (initial.x + (event.x - startPointX) / imageBounds.width()).coerceIn(0f, 1f)
109
+ val y = (initial.y + (event.y - startPointY) / imageBounds.height()).coerceIn(0f, 1f)
110
+ emit(initial, x, y, initial.scale, initial.rotationDegrees)
111
+ }
112
+ Gesture.HANDLE -> {
113
+ val center = centerOf(initial)
114
+ val scale = (initial.scale * hypot(event.x - center.first, event.y - center.second) / startDistance).coerceIn(minScale, maxScale)
115
+ val rotation = normalizeDegrees(initial.rotationDegrees + angleDelta(startAngle, angle(center.first, center.second, event.x, event.y)))
116
+ emit(initial, initial.x, initial.y, scale, rotation)
117
+ }
118
+ Gesture.MULTI -> if (event.pointerCount >= 2) {
119
+ val metrics = multiMetrics(event)
120
+ val scale = (initial.scale * metrics.distance / startDistance).coerceIn(minScale, maxScale)
121
+ val rotation = normalizeDegrees(initial.rotationDegrees + angleDelta(startAngle, metrics.angle))
122
+ val x = (initial.x + (metrics.midX - startMidX) / imageBounds.width()).coerceIn(0f, 1f)
123
+ val y = (initial.y + (metrics.midY - startMidY) / imageBounds.height()).coerceIn(0f, 1f)
124
+ emit(initial, x, y, scale, rotation)
125
+ }
126
+ Gesture.NONE -> return false
127
+ }
128
+ return true
129
+ }
130
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { finishGesture(); return true }
131
+ MotionEvent.ACTION_POINTER_UP -> return true
132
+ }
133
+ return false
134
+ }
135
+
136
+ private fun emit(layer: PhotoLayer, x: Float, y: Float, scale: Float, rotation: Float) =
137
+ onLayerTransformChanged?.invoke(layer.id, x, y, scale, rotation)
138
+
139
+ private fun finishGesture() {
140
+ gestureId?.let { onLayerTransformEnded?.invoke(it) }
141
+ resetGesture()
142
+ parent?.requestDisallowInterceptTouchEvent(false)
143
+ }
144
+
145
+ private fun resetGesture() { gesture = Gesture.NONE; gestureId = null; startLayer = null }
146
+ private fun selectedLayer() = layers.firstOrNull { it.id == selectedLayerId && it.visible }
147
+ private fun currentGestureLayer() = layers.firstOrNull { it.id == gestureId && it.visible && !it.locked }
148
+ private fun centerOf(layer: PhotoLayer) = Pair(imageBounds.left + layer.x * imageBounds.width(), imageBounds.top + layer.y * imageBounds.height())
149
+ private fun selectionHalfExtents(layer: PhotoLayer): Pair<Float, Float> {
150
+ val shortSide = min(imageBounds.width(), imageBounds.height())
151
+ val base = when (layer.type) {
152
+ LayerType.TEXT -> {
153
+ val paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
154
+ textSize = layer.fontSize / 48f * shortSide * 0.06f
155
+ layer.fontFamily?.let { typeface = android.graphics.Typeface.create(it, android.graphics.Typeface.NORMAL) }
156
+ }
157
+ val lines = layer.text.ifBlank { " " }.split("\n")
158
+ Pair((lines.maxOfOrNull { paint.measureText(it) } ?: paint.textSize) / 2f + 8f * density, paint.fontSpacing * lines.size / 2f + 6f * density)
159
+ }
160
+ LayerType.STICKER -> {
161
+ val size = shortSide * 0.18f
162
+ Pair(size / 2f, size / 2f)
163
+ }
164
+ LayerType.OVERLAY -> {
165
+ val aspect = layer.overlayAspectRatio ?: 1f
166
+ val baseSize = shortSide * 0.6f
167
+ if (aspect >= 1f) Pair(baseSize / 2f, (baseSize / aspect) / 2f) else Pair((baseSize * aspect) / 2f, baseSize / 2f)
168
+ }
169
+ }
170
+ return Pair((base.first * layer.scale).coerceAtLeast(handleRadius), (base.second * layer.scale).coerceAtLeast(handleRadius))
171
+ }
172
+
173
+ private fun handlePoint(layer: PhotoLayer): Pair<Float, Float> {
174
+ val center = centerOf(layer); val half = selectionHalfExtents(layer)
175
+ val radians = Math.toRadians(layer.rotationDegrees.toDouble())
176
+ return Pair(center.first + (half.first * cos(radians) - half.second * sin(radians)).toFloat(), center.second + (half.first * sin(radians) + half.second * cos(radians)).toFloat())
177
+ }
178
+
179
+ private fun isHandleHit(layer: PhotoLayer, x: Float, y: Float): Boolean {
180
+ val handle = handlePoint(layer)
181
+ return hypot(x - handle.first, y - handle.second) <= handleTouchRadius
182
+ }
183
+
184
+ private fun hitTest(x: Float, y: Float): PhotoLayer? = layers.asReversed().firstOrNull { layer ->
185
+ if (!layer.visible) return@firstOrNull false
186
+ val center = centerOf(layer); val radians = Math.toRadians((-layer.rotationDegrees).toDouble())
187
+ val dx = x - center.first; val dy = y - center.second
188
+ val localX = dx * cos(radians).toFloat() - dy * sin(radians).toFloat()
189
+ val localY = dx * sin(radians).toFloat() + dy * cos(radians).toFloat()
190
+ val half = selectionHalfExtents(layer)
191
+ localX in -half.first..half.first && localY in -half.second..half.second
192
+ }
193
+
194
+ private data class MultiMetrics(val midX: Float, val midY: Float, val distance: Float, val angle: Float)
195
+ private fun multiMetrics(event: MotionEvent): MultiMetrics {
196
+ val x0 = event.getX(0); val y0 = event.getY(0); val x1 = event.getX(1); val y1 = event.getY(1)
197
+ return MultiMetrics((x0 + x1) / 2f, (y0 + y1) / 2f, hypot(x1 - x0, y1 - y0), angle(x0, y0, x1, y1))
198
+ }
199
+ private fun angle(x0: Float, y0: Float, x1: Float, y1: Float) = Math.toDegrees(atan2(y1 - y0, x1 - x0).toDouble()).toFloat()
200
+ private fun angleDelta(from: Float, to: Float): Float = ((to - from + 540f) % 360f) - 180f
201
+ private fun normalizeDegrees(value: Float): Float = ((value % 360f) + 360f) % 360f
202
+ }
@@ -0,0 +1,57 @@
1
+ package com.photovideoeditor.photo.ui
2
+
3
+ import android.graphics.Bitmap
4
+ import android.os.Handler
5
+ import android.os.Looper
6
+ import java.util.concurrent.Executors
7
+ import java.util.concurrent.atomic.AtomicBoolean
8
+
9
+ /**
10
+ * Runs the expensive photo pipeline away from the main thread and collapses a
11
+ * burst of slider/drag updates into the newest requested frame.
12
+ */
13
+ class PhotoPreviewRenderCoordinator(
14
+ private val render: () -> Bitmap?,
15
+ private val display: (Bitmap?) -> Unit,
16
+ ) {
17
+ private val executor = Executors.newSingleThreadExecutor { task ->
18
+ Thread(task, "photo-preview-renderer").apply { priority = Thread.NORM_PRIORITY - 1 }
19
+ }
20
+ private val mainHandler = Handler(Looper.getMainLooper())
21
+ private val rendering = AtomicBoolean(false)
22
+ private val dirty = AtomicBoolean(false)
23
+ private val closed = AtomicBoolean(false)
24
+
25
+ fun request() {
26
+ if (closed.get()) return
27
+ dirty.set(true)
28
+ startIfIdle()
29
+ }
30
+
31
+ private fun startIfIdle() {
32
+ if (!rendering.compareAndSet(false, true)) return
33
+ executor.execute {
34
+ try {
35
+ do {
36
+ dirty.set(false)
37
+ val bitmap = if (closed.get()) null else render()
38
+ if (!closed.get()) mainHandler.post {
39
+ if (!closed.get()) display(bitmap)
40
+ }
41
+ } while (dirty.get() && !closed.get())
42
+ } finally {
43
+ rendering.set(false)
44
+ if (dirty.get() && !closed.get()) startIfIdle()
45
+ }
46
+ }
47
+ }
48
+
49
+ /** Runs cleanup after any in-flight render, preventing bitmap recycle races. */
50
+ fun close(onRendererDrained: () -> Unit) {
51
+ if (!closed.compareAndSet(false, true)) return
52
+ dirty.set(false)
53
+ mainHandler.removeCallbacksAndMessages(null)
54
+ executor.execute(onRendererDrained)
55
+ executor.shutdown()
56
+ }
57
+ }
@@ -0,0 +1,96 @@
1
+ package com.photovideoeditor.photo.ui
2
+
3
+ import android.content.Context
4
+ import android.graphics.Matrix
5
+ import android.graphics.RectF
6
+ import android.view.MotionEvent
7
+ import android.view.ScaleGestureDetector
8
+ import android.widget.ImageView
9
+
10
+ /**
11
+ * ImageView supporting pinch-to-zoom and single-finger pan for the photo
12
+ * preview, reporting the current on-screen image rect via [onBoundsChanged]
13
+ * so the crop overlay can stay aligned to it.
14
+ */
15
+ class ZoomableImageView(context: Context) : ImageView(context) {
16
+ private val matrixValues = FloatArray(9)
17
+ private var minScale = 1f
18
+ private var maxScale = 4f
19
+ private var lastX = 0f
20
+ private var lastY = 0f
21
+ private var panning = false
22
+ var onBoundsChanged: ((RectF) -> Unit)? = null
23
+ var panZoomEnabled = true
24
+
25
+ private val scaleDetector = ScaleGestureDetector(
26
+ context,
27
+ object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
28
+ override fun onScale(detector: ScaleGestureDetector): Boolean {
29
+ val targetScale = (currentScale() * detector.scaleFactor).coerceIn(minScale, maxScale)
30
+ val appliedFactor = targetScale / currentScale()
31
+ imageMatrix = Matrix(imageMatrix).apply { postScale(appliedFactor, appliedFactor, detector.focusX, detector.focusY) }
32
+ onBoundsChanged?.invoke(currentImageBounds())
33
+ return true
34
+ }
35
+ }
36
+ )
37
+
38
+ init {
39
+ scaleType = ScaleType.MATRIX
40
+ }
41
+
42
+ /** Fits the current drawable to the view bounds and resets zoom/pan. */
43
+ fun resetToFit() {
44
+ val bitmapDrawable = drawable ?: return
45
+ val viewWidth = width.toFloat()
46
+ val viewHeight = height.toFloat()
47
+ val drawableWidth = bitmapDrawable.intrinsicWidth.toFloat()
48
+ val drawableHeight = bitmapDrawable.intrinsicHeight.toFloat()
49
+ if (viewWidth <= 0 || viewHeight <= 0 || drawableWidth <= 0 || drawableHeight <= 0) return
50
+ val scale = minOf(viewWidth / drawableWidth, viewHeight / drawableHeight)
51
+ val dx = (viewWidth - drawableWidth * scale) / 2f
52
+ val dy = (viewHeight - drawableHeight * scale) / 2f
53
+ imageMatrix = Matrix().apply {
54
+ postScale(scale, scale)
55
+ postTranslate(dx, dy)
56
+ }
57
+ minScale = scale
58
+ maxScale = scale * 4f
59
+ onBoundsChanged?.invoke(currentImageBounds())
60
+ }
61
+
62
+ fun currentImageBounds(): RectF {
63
+ val bitmapDrawable = drawable ?: return RectF(0f, 0f, width.toFloat(), height.toFloat())
64
+ val rect = RectF(0f, 0f, bitmapDrawable.intrinsicWidth.toFloat(), bitmapDrawable.intrinsicHeight.toFloat())
65
+ imageMatrix.mapRect(rect)
66
+ return rect
67
+ }
68
+
69
+ private fun currentScale(): Float {
70
+ imageMatrix.getValues(matrixValues)
71
+ return matrixValues[Matrix.MSCALE_X]
72
+ }
73
+
74
+ override fun onTouchEvent(event: MotionEvent): Boolean {
75
+ if (!panZoomEnabled) return false
76
+ scaleDetector.onTouchEvent(event)
77
+ when (event.actionMasked) {
78
+ MotionEvent.ACTION_DOWN -> {
79
+ lastX = event.x
80
+ lastY = event.y
81
+ panning = true
82
+ }
83
+ MotionEvent.ACTION_POINTER_DOWN -> panning = false
84
+ MotionEvent.ACTION_MOVE -> if (panning && event.pointerCount == 1 && !scaleDetector.isInProgress) {
85
+ val dx = event.x - lastX
86
+ val dy = event.y - lastY
87
+ lastX = event.x
88
+ lastY = event.y
89
+ imageMatrix = Matrix(imageMatrix).apply { postTranslate(dx, dy) }
90
+ onBoundsChanged?.invoke(currentImageBounds())
91
+ }
92
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> panning = event.pointerCount <= 1
93
+ }
94
+ return true
95
+ }
96
+ }
@@ -0,0 +1,310 @@
1
+ package com.photovideoeditor.stickers
2
+
3
+ import android.app.Dialog
4
+ import android.content.Context
5
+ import android.graphics.Color
6
+ import android.os.Bundle
7
+ import android.os.Handler
8
+ import android.os.Looper
9
+ import android.text.Editable
10
+ import android.text.TextWatcher
11
+ import android.view.Gravity
12
+ import android.view.View
13
+ import android.view.ViewGroup
14
+ import android.view.Window
15
+ import android.view.WindowManager
16
+ import android.widget.EditText
17
+ import android.widget.FrameLayout
18
+ import android.widget.HorizontalScrollView
19
+ import android.widget.ImageView
20
+ import android.widget.LinearLayout
21
+ import android.widget.ProgressBar
22
+ import android.widget.TextView
23
+ import android.widget.Toast
24
+ import androidx.recyclerview.widget.GridLayoutManager
25
+ import androidx.recyclerview.widget.RecyclerView
26
+ import com.photovideoeditor.ui.DesignTokens
27
+ import com.photovideoeditor.ui.components.dpToPx
28
+ import com.photovideoeditor.ui.components.editorChip
29
+ import com.photovideoeditor.ui.components.editorPrimaryButton
30
+ import com.photovideoeditor.ui.components.roundedDrawable
31
+
32
+ /**
33
+ * Bottom sheet listing OpenMoji's free sticker catalog (search + category filter + grid),
34
+ * built as a plain [Dialog] (no Material `BottomSheetDialog`, which isn't a dependency here) that
35
+ * slides up from the bottom and covers ~85% of the screen height.
36
+ */
37
+ class OnlineStickerSheet(
38
+ context: Context,
39
+ private val onStickerPicked: (localFilePath: String) -> Unit
40
+ ) : Dialog(context, android.R.style.Theme_Black_NoTitleBar) {
41
+
42
+ private val mainHandler = Handler(Looper.getMainLooper())
43
+ private var searchDebounce: Runnable? = null
44
+
45
+ private var allEntries: List<OpenMojiEntry> = emptyList()
46
+ private var selectedGroup: String? = null // null == "All"
47
+ private var searchQuery: String = ""
48
+ private var loadError: Exception? = null
49
+ private var loading = true
50
+
51
+ private lateinit var searchInput: EditText
52
+ private lateinit var chipRow: LinearLayout
53
+ private lateinit var recycler: RecyclerView
54
+ private lateinit var progress: ProgressBar
55
+ private lateinit var statusText: TextView
56
+ private lateinit var retryButton: View
57
+ private lateinit var adapter: StickerAdapter
58
+
59
+ override fun onCreate(savedInstanceState: Bundle?) {
60
+ super.onCreate(savedInstanceState)
61
+ setContentView(buildRootView())
62
+ configureWindow()
63
+ loadCatalog()
64
+ }
65
+
66
+ private fun configureWindow() {
67
+ val window = this.window ?: return
68
+ window.setBackgroundDrawableResource(android.R.color.transparent)
69
+ window.setLayout(WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT)
70
+ window.setGravity(Gravity.BOTTOM)
71
+ window.setWindowAnimations(android.R.style.Animation_InputMethod)
72
+ setCanceledOnTouchOutside(true)
73
+ }
74
+
75
+ private fun dp(value: Int) = dpToPx(value, context)
76
+
77
+ private fun buildRootView(): View {
78
+ val screenHeight = context.resources.displayMetrics.heightPixels
79
+ val sheetHeight = (screenHeight * 0.85f).toInt()
80
+
81
+ val dimBackground = FrameLayout(context).apply {
82
+ setBackgroundColor(Color.argb(140, 0, 0, 0))
83
+ setOnClickListener { dismiss() }
84
+ }
85
+
86
+ val sheet = LinearLayout(context).apply {
87
+ orientation = LinearLayout.VERTICAL
88
+ background = roundedTopDrawable()
89
+ isClickable = true
90
+ setPadding(dp(DesignTokens.spaceLg), dp(DesignTokens.spaceMd), dp(DesignTokens.spaceLg), dp(DesignTokens.spaceLg))
91
+ }
92
+
93
+ // Decorative drag handle.
94
+ sheet.addView(
95
+ View(context).apply { setBackgroundColor(DesignTokens.outlineVariant) },
96
+ LinearLayout.LayoutParams(dp(36), dp(4)).apply {
97
+ gravity = Gravity.CENTER_HORIZONTAL
98
+ bottomMargin = dp(DesignTokens.spaceMd)
99
+ }
100
+ )
101
+
102
+ sheet.addView(TextView(context).apply {
103
+ text = "Browse stickers"
104
+ setTextColor(DesignTokens.textPrimary)
105
+ textSize = 16f
106
+ setTypeface(typeface, android.graphics.Typeface.BOLD)
107
+ })
108
+
109
+ searchInput = EditText(context).apply {
110
+ hint = "Search stickers"
111
+ setHintTextColor(DesignTokens.outline)
112
+ setTextColor(DesignTokens.onSurface)
113
+ background = roundedDrawable(DesignTokens.surfaceContainerHigh, DesignTokens.radiusLg, context)
114
+ setPadding(dp(DesignTokens.spaceMd), dp(DesignTokens.spaceSm), dp(DesignTokens.spaceMd), dp(DesignTokens.spaceSm))
115
+ setSingleLine(true)
116
+ addTextChangedListener(object : TextWatcher {
117
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
118
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
119
+ override fun afterTextChanged(s: Editable?) {
120
+ searchDebounce?.let { mainHandler.removeCallbacks(it) }
121
+ val runnable = Runnable {
122
+ searchQuery = s?.toString().orEmpty()
123
+ refreshList()
124
+ }
125
+ searchDebounce = runnable
126
+ mainHandler.postDelayed(runnable, 300)
127
+ }
128
+ })
129
+ }
130
+ sheet.addView(searchInput, LinearLayout.LayoutParams(MATCH, WRAP).apply {
131
+ topMargin = dp(DesignTokens.spaceMd)
132
+ })
133
+
134
+ chipRow = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL }
135
+ sheet.addView(HorizontalScrollView(context).apply {
136
+ isHorizontalScrollBarEnabled = false
137
+ addView(chipRow)
138
+ }, LinearLayout.LayoutParams(MATCH, WRAP).apply { topMargin = dp(DesignTokens.spaceMd) })
139
+
140
+ val gridContainer = FrameLayout(context)
141
+
142
+ recycler = RecyclerView(context).apply {
143
+ layoutManager = GridLayoutManager(context, 4)
144
+ }
145
+ adapter = StickerAdapter()
146
+ recycler.adapter = adapter
147
+ gridContainer.addView(recycler, FrameLayout.LayoutParams(MATCH, MATCH))
148
+
149
+ progress = ProgressBar(context)
150
+ gridContainer.addView(progress, FrameLayout.LayoutParams(WRAP, WRAP, Gravity.CENTER))
151
+
152
+ val statusContainer = LinearLayout(context).apply {
153
+ orientation = LinearLayout.VERTICAL
154
+ gravity = Gravity.CENTER
155
+ }
156
+ statusText = TextView(context).apply {
157
+ setTextColor(DesignTokens.textSecondary)
158
+ textSize = 13f
159
+ gravity = Gravity.CENTER
160
+ }
161
+ statusContainer.addView(statusText, LinearLayout.LayoutParams(WRAP, WRAP))
162
+ retryButton = editorPrimaryButton(context, "Retry") { loadCatalog() }
163
+ statusContainer.addView(retryButton, LinearLayout.LayoutParams(WRAP, dp(DesignTokens.touchTargetMin)).apply {
164
+ topMargin = dp(DesignTokens.spaceMd)
165
+ })
166
+ gridContainer.addView(statusContainer, FrameLayout.LayoutParams(WRAP, WRAP, Gravity.CENTER))
167
+
168
+ sheet.addView(gridContainer, LinearLayout.LayoutParams(MATCH, 0, 1f).apply {
169
+ topMargin = dp(DesignTokens.spaceMd)
170
+ })
171
+
172
+ sheet.addView(TextView(context).apply {
173
+ text = "Stickers by OpenMoji (CC BY-SA 4.0)"
174
+ setTextColor(DesignTokens.textSubtle)
175
+ textSize = 10f
176
+ gravity = Gravity.CENTER
177
+ }, LinearLayout.LayoutParams(MATCH, WRAP).apply { topMargin = dp(DesignTokens.spaceSm) })
178
+
179
+ updateStatusViews()
180
+
181
+ val root = FrameLayout(context)
182
+ root.addView(dimBackground, FrameLayout.LayoutParams(MATCH, MATCH))
183
+ root.addView(sheet, FrameLayout.LayoutParams(MATCH, sheetHeight, Gravity.BOTTOM))
184
+ return root
185
+ }
186
+
187
+ private fun roundedTopDrawable(): android.graphics.drawable.GradientDrawable =
188
+ android.graphics.drawable.GradientDrawable().apply {
189
+ setColor(DesignTokens.elevatedPanel)
190
+ cornerRadii = floatArrayOf(
191
+ dp(DesignTokens.radiusSheet).toFloat(), dp(DesignTokens.radiusSheet).toFloat(),
192
+ dp(DesignTokens.radiusSheet).toFloat(), dp(DesignTokens.radiusSheet).toFloat(),
193
+ 0f, 0f, 0f, 0f
194
+ )
195
+ }
196
+
197
+ private fun loadCatalog() {
198
+ loading = true
199
+ loadError = null
200
+ updateStatusViews()
201
+ OpenMojiCatalog.load(context) { entries, error ->
202
+ loading = false
203
+ if (entries != null) {
204
+ allEntries = entries
205
+ loadError = null
206
+ rebuildChips()
207
+ refreshList()
208
+ } else {
209
+ loadError = error
210
+ }
211
+ updateStatusViews()
212
+ }
213
+ }
214
+
215
+ private fun rebuildChips() {
216
+ chipRow.removeAllViews()
217
+ val groups = listOf<String?>(null) + OpenMojiCatalog.groups()
218
+ groups.forEach { group ->
219
+ val label = group?.let { humanizeGroup(it) } ?: "All"
220
+ val chip = editorChip(context, label, selected = group == selectedGroup) {
221
+ selectedGroup = group
222
+ rebuildChips()
223
+ refreshList()
224
+ }
225
+ chipRow.addView(chip, LinearLayout.LayoutParams(WRAP, WRAP).apply { marginEnd = dp(DesignTokens.spaceSm) })
226
+ }
227
+ }
228
+
229
+ private fun humanizeGroup(group: String): String =
230
+ group.split("-").joinToString(" ") { it.replaceFirstChar(Char::uppercase) }
231
+
232
+ private fun refreshList() {
233
+ val query = searchQuery.trim()
234
+ val list = if (query.isNotEmpty()) {
235
+ OpenMojiCatalog.search(query)
236
+ } else {
237
+ val group = selectedGroup
238
+ if (group == null) allEntries else OpenMojiCatalog.byGroup(group)
239
+ }
240
+ adapter.submit(list)
241
+ updateStatusViews()
242
+ }
243
+
244
+ private fun updateStatusViews() {
245
+ progress.visibility = if (loading) View.VISIBLE else View.GONE
246
+ val error = loadError
247
+ val empty = !loading && error == null && adapter.itemCount == 0
248
+ recycler.visibility = if (!loading && error == null && !empty) View.VISIBLE else View.GONE
249
+ statusText.visibility = if (!loading && (error != null || empty)) View.VISIBLE else View.GONE
250
+ retryButton.visibility = if (!loading && error != null) View.VISIBLE else View.GONE
251
+ statusText.text = when {
252
+ error != null -> "Couldn't load stickers — check your connection."
253
+ empty -> "No stickers found."
254
+ else -> ""
255
+ }
256
+ }
257
+
258
+ private inner class StickerViewHolder(itemView: FrameLayout) : RecyclerView.ViewHolder(itemView) {
259
+ val imageView: ImageView = itemView.getChildAt(0) as ImageView
260
+ }
261
+
262
+ private inner class StickerAdapter : RecyclerView.Adapter<StickerViewHolder>() {
263
+ private var items: List<OpenMojiEntry> = emptyList()
264
+
265
+ fun submit(newItems: List<OpenMojiEntry>) {
266
+ items = newItems
267
+ notifyDataSetChanged()
268
+ }
269
+
270
+ override fun getItemCount(): Int = items.size
271
+
272
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StickerViewHolder {
273
+ val cardSize = dp(72)
274
+ val image = ImageView(context).apply {
275
+ scaleType = ImageView.ScaleType.CENTER_INSIDE
276
+ }
277
+ val cell = FrameLayout(context).apply {
278
+ background = roundedDrawable(DesignTokens.surfaceContainer, DesignTokens.radiusMd, context)
279
+ addView(image, FrameLayout.LayoutParams(cardSize - dp(16), cardSize - dp(16), Gravity.CENTER))
280
+ }
281
+ cell.layoutParams = RecyclerView.LayoutParams(cardSize, cardSize).apply {
282
+ setMargins(dp(4), dp(4), dp(4), dp(4))
283
+ }
284
+ return StickerViewHolder(cell)
285
+ }
286
+
287
+ override fun onBindViewHolder(holder: StickerViewHolder, position: Int) {
288
+ val entry = items[position]
289
+ RemoteImageLoader.loadThumbnail(context, OpenMojiCatalog.thumbnailUrl(entry.hexcode), holder.imageView)
290
+ holder.itemView.setOnClickListener { onStickerTapped(entry) }
291
+ }
292
+ }
293
+
294
+ private fun onStickerTapped(entry: OpenMojiEntry) {
295
+ Toast.makeText(context, "Downloading sticker…", Toast.LENGTH_SHORT).show()
296
+ RemoteImageLoader.downloadFull(context, OpenMojiCatalog.fullUrl(entry.hexcode)) { path, error ->
297
+ if (path != null) {
298
+ onStickerPicked(path)
299
+ dismiss()
300
+ } else {
301
+ Toast.makeText(context, "Couldn't download that sticker, try another.", Toast.LENGTH_SHORT).show()
302
+ }
303
+ }
304
+ }
305
+
306
+ companion object {
307
+ private const val MATCH = ViewGroup.LayoutParams.MATCH_PARENT
308
+ private const val WRAP = ViewGroup.LayoutParams.WRAP_CONTENT
309
+ }
310
+ }