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,2521 @@
1
+ package com.photovideoeditor.editor
2
+
3
+ import com.photovideoeditor.R
4
+ import android.app.Activity
5
+ import android.app.AlertDialog
6
+ import android.content.Intent
7
+ import android.graphics.Bitmap
8
+ import android.graphics.BitmapFactory
9
+ import android.graphics.Color
10
+ import android.net.Uri
11
+ import android.os.Bundle
12
+ import android.text.InputType
13
+ import android.view.Gravity
14
+ import android.view.MotionEvent
15
+ import android.view.View
16
+ import android.view.ViewGroup
17
+ import android.view.ViewTreeObserver
18
+ import android.widget.Button
19
+ import android.widget.EditText
20
+ import android.widget.FrameLayout
21
+ import android.widget.HorizontalScrollView
22
+ import android.widget.ImageButton
23
+ import android.widget.ImageView
24
+ import android.widget.LinearLayout
25
+ import android.widget.ProgressBar
26
+ import android.widget.SeekBar
27
+ import android.widget.TextView
28
+ import android.widget.Toast
29
+ import androidx.core.view.ViewCompat
30
+ import androidx.core.view.WindowInsetsCompat
31
+ import androidx.media3.common.Player
32
+ import androidx.media3.ui.PlayerView
33
+ import com.photovideoeditor.files.SourceResolver
34
+ import com.photovideoeditor.photo.export.PhotoExportException
35
+ import com.photovideoeditor.photo.export.PhotoExporter
36
+ import com.photovideoeditor.photo.render.LayerType
37
+ import com.photovideoeditor.photo.render.PhotoAdjustments
38
+ import com.photovideoeditor.photo.render.PhotoEditSession
39
+ import com.photovideoeditor.photo.render.PhotoFilterPresets
40
+ import com.photovideoeditor.photo.render.PhotoLayer
41
+ import com.photovideoeditor.photo.render.PhotoLayerRenderer
42
+ import com.photovideoeditor.photo.ui.CropOverlayView
43
+ import com.photovideoeditor.photo.ui.FilterThumbnailLoader
44
+ import com.photovideoeditor.video.export.VideoExportException
45
+ import com.photovideoeditor.video.export.VideoExporter
46
+ import com.photovideoeditor.video.cache.TimelineThumbnailRepository
47
+ import com.photovideoeditor.video.render.VideoClip
48
+ import com.photovideoeditor.video.render.VideoEditSession
49
+ import com.photovideoeditor.video.ui.TrimRangeView
50
+ import com.photovideoeditor.photo.ui.LayerOverlayView
51
+ import com.photovideoeditor.photo.ui.PhotoPreviewRenderCoordinator
52
+ import com.photovideoeditor.photo.ui.ZoomableImageView
53
+ import com.photovideoeditor.stickers.OnlineStickerSheet
54
+ import com.photovideoeditor.ui.DesignTokens
55
+ import com.photovideoeditor.ui.components.applyPressScale
56
+ import com.photovideoeditor.ui.components.editorChip
57
+ import com.photovideoeditor.ui.components.editorPrimaryButton
58
+ import org.json.JSONObject
59
+ import java.io.File
60
+ import kotlin.math.roundToInt
61
+
62
+ class PhotoVideoEditorActivity : Activity() {
63
+ private val sourceUri by lazy { intent.getStringExtra(EXTRA_URI).orEmpty() }
64
+ private val mediaType by lazy { intent.getStringExtra(EXTRA_TYPE).orEmpty() }
65
+ private val request by lazy { JSONObject(intent.getStringExtra(EXTRA_REQUEST).orEmpty()) }
66
+ private val theme by lazy { request.optJSONObject("theme") }
67
+
68
+ private var photoSession: PhotoEditSession? = null
69
+ private var cropMode = false
70
+ private var adjustMode = false
71
+ private var filtersMode = false
72
+ private var stickersMode = false
73
+ private lateinit var stickersSubBar: View
74
+ private var activeAdjustmentKey = "brightness"
75
+ private var selectedLayerId: String? = null
76
+ private var activeLayerPropertyKey = "scale"
77
+ private var dragStartSnapshot: List<PhotoLayer>? = null
78
+ private lateinit var photoImageView: ZoomableImageView
79
+ private lateinit var cropOverlay: CropOverlayView
80
+ private lateinit var layerOverlay: LayerOverlayView
81
+ private lateinit var straightenSeekBar: SeekBar
82
+ private lateinit var cropSubBar: View
83
+ private lateinit var adjustmentSeekBar: SeekBar
84
+ private lateinit var filtersSubBar: View
85
+ private lateinit var adjustSubBar: View
86
+ private lateinit var layerToolBar: View
87
+ private lateinit var layerPropertySeekBar: SeekBar
88
+ private lateinit var layerColorSwatchRow: View
89
+ private lateinit var mainToolBar: View
90
+ private lateinit var undoButton: ImageButton
91
+ private lateinit var redoButton: ImageButton
92
+ private lateinit var exportProgressView: View
93
+ private var photoPreviewRenderer: PhotoPreviewRenderCoordinator? = null
94
+ private val filterThumbnailLoader = FilterThumbnailLoader()
95
+ private var comparingOriginal = false
96
+
97
+ private var videoSession: VideoEditSession? = null
98
+ private var videoExporter: VideoExporter? = null
99
+ private var videoAspectMode = false
100
+ private lateinit var trimRangeView: TrimRangeView
101
+ private lateinit var positionSeekBar: SeekBar
102
+ private lateinit var playPauseButton: Button
103
+ private lateinit var timeLabel: TextView
104
+ private lateinit var videoAspectSubBar: View
105
+ private lateinit var videoToolBar: View
106
+ private lateinit var videoExportProgressView: View
107
+ private lateinit var videoExportCancelButton: Button
108
+ private lateinit var videoOverlayImageView: ImageView
109
+ private var videoOverlayBitmap: Bitmap? = null
110
+ private var videoOverlayRenderKey: String? = null
111
+ private val positionPollHandler = android.os.Handler(android.os.Looper.getMainLooper())
112
+ private var positionPollRunnable: Runnable? = null
113
+ private var seeking = false
114
+
115
+ private val photoToolButtons = mutableMapOf<String, ToolNode>()
116
+ private var photoToolScroll: HorizontalScrollView? = null
117
+ private val cropAspectButtons = mutableMapOf<Float?, Button>()
118
+ private val filterAdjustmentButtons = mutableMapOf<String, Button>()
119
+ /** Preset id ("" = Original) -> (thumbnail frame, label) for the Filters preset-card scroller. */
120
+ private val filterPresetCards = mutableMapOf<String, Pair<FrameLayout, TextView>>()
121
+ private var resizeMode = false
122
+ private lateinit var resizeSubBar: View
123
+ private var resizePresetSize: Pair<Int, Int>? = null
124
+ private val resizePresetButtons = mutableMapOf<Pair<Int, Int>?, Button>()
125
+ private val layerPropertyButtons = mutableMapOf<String, Button>()
126
+ /** Non-Delete/Duplicate layer action icons, hidden for TEXT layers (only Opacity/Colors/Duplicate/Delete apply there). */
127
+ private val layerIconButtons = mutableMapOf<String, View>()
128
+ private val videoToolButtons = mutableMapOf<String, ToolNode>()
129
+ private val videoAspectButtons = mutableMapOf<Float?, Button>()
130
+ private val videoFilterButtons = mutableMapOf<String, Button>()
131
+ private var activeVideoFilterKey = "brightness"
132
+ private var videoFiltersMode = false
133
+ private lateinit var videoFiltersSubBar: View
134
+ private lateinit var videoFilterSeekBar: SeekBar
135
+ private lateinit var videoLayerOverlay: LayerOverlayView
136
+ private lateinit var videoLayerToolBar: View
137
+ private lateinit var videoLayerPropertySeekBar: SeekBar
138
+ private var selectedVideoLayerId: String? = null
139
+ private var activeVideoLayerPropertyKey = "scale"
140
+ private var videoDragStartSnapshot: List<PhotoLayer>? = null
141
+ private val videoLayerPropertyButtons = mutableMapOf<String, Button>()
142
+ private var selectedClipIndex = 0
143
+ private lateinit var clipStripBar: LinearLayout
144
+ private var timelineThumbnailRepository: TimelineThumbnailRepository? = null
145
+ private var timelineThumbnailRequest: TimelineThumbnailRepository.Request? = null
146
+ private val videoImageLayerCache = mutableMapOf<String, Bitmap?>()
147
+
148
+ override fun onCreate(savedInstanceState: Bundle?) {
149
+ super.onCreate(savedInstanceState)
150
+ if (sourceUri.isBlank() || mediaType !in setOf("photo", "video")) {
151
+ finishWithError("The selected media could not be opened.", "E_INVALID_URI")
152
+ return
153
+ }
154
+ applyStatusBarStyle()
155
+ setContentView(createEditorView())
156
+ }
157
+
158
+ private fun applyStatusBarStyle() {
159
+ val isLight = theme?.optString("statusBarStyle") == "dark"
160
+ @Suppress("DEPRECATION")
161
+ window.decorView.systemUiVisibility = if (isLight) {
162
+ window.decorView.systemUiVisibility or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
163
+ } else {
164
+ window.decorView.systemUiVisibility and View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv()
165
+ }
166
+ }
167
+
168
+ /** Parses a "#RRGGBB"/"#AARRGGBB" theme string, falling back when absent or malformed. */
169
+ private fun themeColor(key: String, fallback: Int): Int {
170
+ val value = theme?.optString(key)?.takeIf { it.isNotBlank() } ?: return fallback
171
+ return try {
172
+ Color.parseColor(value)
173
+ } catch (_: IllegalArgumentException) {
174
+ fallback
175
+ }
176
+ }
177
+
178
+ private fun createEditorView(): View = if (mediaType == "photo") createPhotoEditorView() else createVideoEditorView()
179
+
180
+ // ---------------------------------------------------------------------
181
+ // Photo editor: crop, rotate, flip, straighten, and export (Milestone 2).
182
+ // ---------------------------------------------------------------------
183
+
184
+ private fun createPhotoEditorView(): View {
185
+ val backgroundColor = themeColor("backgroundColor", DesignTokens.surfaceContainerLowest)
186
+ val root = LinearLayout(this).apply {
187
+ orientation = LinearLayout.VERTICAL
188
+ setBackgroundColor(backgroundColor)
189
+ }
190
+
191
+ val headerHeight = dp(64)
192
+ val header = createTopBar()
193
+ root.addView(header, LinearLayout.LayoutParams(MATCH, headerHeight))
194
+
195
+ val previewContainer = FrameLayout(this).apply {
196
+ background = roundedDrawable(Color.BLACK, DesignTokens.radiusLg)
197
+ clipToOutline = true
198
+ }
199
+ root.addView(previewContainer, LinearLayout.LayoutParams(MATCH, 0, 1f).apply {
200
+ setMargins(dp(DesignTokens.spaceLg), dp(DesignTokens.spaceSm), dp(DesignTokens.spaceLg), dp(DesignTokens.spaceSm))
201
+ })
202
+
203
+ val session = PhotoEditSession(this, sourceUri)
204
+ photoSession = session
205
+
206
+ val outer = FrameLayout(this)
207
+ val progressOverlay = FrameLayout(this).apply {
208
+ visibility = View.GONE
209
+ setBackgroundColor(Color.argb(160, 0, 0, 0))
210
+ addView(
211
+ ProgressBar(this@PhotoVideoEditorActivity),
212
+ FrameLayout.LayoutParams(WRAP, WRAP, Gravity.CENTER)
213
+ )
214
+ }
215
+ exportProgressView = progressOverlay
216
+ outer.addView(root, FrameLayout.LayoutParams(MATCH, MATCH))
217
+ outer.addView(progressOverlay, FrameLayout.LayoutParams(MATCH, MATCH))
218
+
219
+ if (session.baseBitmap == null) {
220
+ finishWithError("The selected photo could not be decoded.", "E_SOURCE_UNREADABLE")
221
+ return outer
222
+ }
223
+
224
+ val imageView = ZoomableImageView(this).apply {
225
+ contentDescription = "Selected photo preview"
226
+ setImageBitmap(session.renderPreview())
227
+ }
228
+ photoImageView = imageView
229
+ photoPreviewRenderer = PhotoPreviewRenderCoordinator(
230
+ render = { session.renderPreview() },
231
+ display = { bitmap ->
232
+ if (!comparingOriginal && !isFinishing && !isDestroyed) imageView.setImageBitmap(bitmap)
233
+ },
234
+ )
235
+ previewContainer.addView(imageView, FrameLayout.LayoutParams(MATCH, MATCH))
236
+
237
+ previewContainer.addView(TextView(this).apply {
238
+ text = "● ORIGINAL • 100%"
239
+ textSize = 10f
240
+ letterSpacing = 0.08f
241
+ setTextColor(DesignTokens.onSurfaceVariant)
242
+ gravity = Gravity.CENTER
243
+ background = roundedDrawable(DesignTokens.withAlphaPercent(DesignTokens.surfaceContainerHigh, 92), DesignTokens.radiusLg)
244
+ setPadding(dp(DesignTokens.spaceMd), dp(DesignTokens.spaceXs), dp(DesignTokens.spaceMd), dp(DesignTokens.spaceXs))
245
+ }, FrameLayout.LayoutParams(WRAP, WRAP, Gravity.TOP or Gravity.CENTER_HORIZONTAL).apply {
246
+ topMargin = dp(DesignTokens.spaceSm)
247
+ })
248
+
249
+ val overlay = CropOverlayView(this).apply { visibility = View.GONE }
250
+ cropOverlay = overlay
251
+ previewContainer.addView(overlay, FrameLayout.LayoutParams(MATCH, MATCH))
252
+
253
+ val layers = LayerOverlayView(this)
254
+ layerOverlay = layers
255
+ previewContainer.addView(layers, FrameLayout.LayoutParams(MATCH, MATCH))
256
+
257
+ imageView.onBoundsChanged = { bounds ->
258
+ if (cropMode) overlay.setImageBounds(bounds, resetCrop = false)
259
+ layers.setImageBounds(bounds)
260
+ }
261
+ overlay.onCropChanged = { left, top, right, bottom -> session.update { it.withCrop(left, top, right, bottom) } }
262
+ layers.onLayerTapped = { id -> selectLayer(id) }
263
+ layers.onLayerTransformChanged = { id, x, y, scale, rotation ->
264
+ if (dragStartSnapshot == null) dragStartSnapshot = session.layerStack.layers
265
+ session.layerStack.updateLive { list ->
266
+ list.map { if (it.id == id) it.copy(x = x, y = y, scale = scale, rotationDegrees = rotation) else it }
267
+ }
268
+ layerOverlay.layers = session.layerStack.layers
269
+ schedulePhotoPreviewRender()
270
+ }
271
+ layers.onLayerTransformEnded = {
272
+ dragStartSnapshot?.let { session.layerStack.commitSnapshot(it) }
273
+ dragStartSnapshot = null
274
+ onLayerStackChanged()
275
+ }
276
+ imageView.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
277
+ override fun onGlobalLayout() {
278
+ imageView.viewTreeObserver.removeOnGlobalLayoutListener(this)
279
+ imageView.resetToFit()
280
+ }
281
+ })
282
+
283
+ val straighten = SeekBar(this).apply {
284
+ max = 90
285
+ progress = 45
286
+ visibility = View.GONE
287
+ setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
288
+ override fun onProgressChanged(seekBar: SeekBar?, value: Int, fromUser: Boolean) {
289
+ if (!fromUser) return
290
+ session.update { it.withStraighten((value - 45).toFloat()) }
291
+ schedulePhotoPreviewRender()
292
+ }
293
+ override fun onStartTrackingTouch(seekBar: SeekBar?) {}
294
+ override fun onStopTrackingTouch(seekBar: SeekBar?) {}
295
+ })
296
+ }
297
+ straightenSeekBar = straighten
298
+ root.addView(
299
+ straighten,
300
+ LinearLayout.LayoutParams(MATCH, WRAP).apply { setMargins(dp(16), dp(4), dp(16), 0) }
301
+ )
302
+
303
+ val cropBar = createCropSubBar(session, imageView, overlay)
304
+ cropSubBar = cropBar
305
+ cropBar.visibility = View.GONE
306
+ root.addView(cropBar, LinearLayout.LayoutParams(MATCH, dp(CROP_SUB_BAR_HEIGHT_DP)))
307
+
308
+ val resizeBar = createResizeSubBar(session, imageView, overlay)
309
+ resizeSubBar = resizeBar
310
+ resizeBar.visibility = View.GONE
311
+ root.addView(resizeBar, LinearLayout.LayoutParams(MATCH, dp(56)))
312
+
313
+ val adjustmentSeek = SeekBar(this).apply {
314
+ visibility = View.GONE
315
+ setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
316
+ override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
317
+ if (!fromUser) return
318
+ val (min, _) = PhotoAdjustments.rangeFor(activeAdjustmentKey)
319
+ val value = min + progress
320
+ session.updateAdjustments { it.with(activeAdjustmentKey, value) }
321
+ schedulePhotoPreviewRender()
322
+ }
323
+ override fun onStartTrackingTouch(seekBar: SeekBar?) {}
324
+ override fun onStopTrackingTouch(seekBar: SeekBar?) {}
325
+ })
326
+ }
327
+ adjustmentSeekBar = adjustmentSeek
328
+ root.addView(adjustmentSeek, LinearLayout.LayoutParams(MATCH, WRAP).apply { setMargins(dp(16), dp(4), dp(16), 0) })
329
+
330
+ val adjustBar = createAdjustSubBar(session, imageView)
331
+ adjustSubBar = adjustBar
332
+ adjustBar.visibility = View.GONE
333
+ root.addView(adjustBar, LinearLayout.LayoutParams(MATCH, dp(ADJUST_SUB_BAR_HEIGHT_DP)))
334
+
335
+ val filtersBar = createFiltersSubBar(session, imageView)
336
+ filtersSubBar = filtersBar
337
+ filtersBar.visibility = View.GONE
338
+ root.addView(filtersBar, LinearLayout.LayoutParams(MATCH, dp(FILTERS_SUB_BAR_HEIGHT_DP)))
339
+
340
+ val stickersBar = createStickersSubBar(session)
341
+ stickersSubBar = stickersBar
342
+ stickersBar.visibility = View.GONE
343
+ root.addView(stickersBar, LinearLayout.LayoutParams(MATCH, dp(ASSET_GRID_BAR_HEIGHT_DP)))
344
+
345
+ val layerPropertySeek = SeekBar(this).apply {
346
+ visibility = View.GONE
347
+ setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
348
+ override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
349
+ if (!fromUser) return
350
+ onLayerPropertyChanged(progress)
351
+ }
352
+ override fun onStartTrackingTouch(seekBar: SeekBar?) {}
353
+ override fun onStopTrackingTouch(seekBar: SeekBar?) {}
354
+ })
355
+ }
356
+ layerPropertySeekBar = layerPropertySeek
357
+ root.addView(layerPropertySeek, LinearLayout.LayoutParams(MATCH, WRAP).apply { setMargins(dp(16), dp(4), dp(16), 0) })
358
+
359
+ val colorRow = createTextColorSwatchRow(session)
360
+ layerColorSwatchRow = colorRow
361
+ colorRow.visibility = View.GONE
362
+ root.addView(colorRow, LinearLayout.LayoutParams(MATCH, dp(48)))
363
+
364
+ val layerBar = createLayerToolBar(session)
365
+ layerToolBar = layerBar
366
+ layerBar.visibility = View.GONE
367
+ root.addView(layerBar, LinearLayout.LayoutParams(MATCH, dp(56)))
368
+
369
+ val toolbarHeight = dp(76)
370
+ val toolbar = createPhotoToolBar(session, imageView, overlay)
371
+ mainToolBar = toolbar
372
+ root.addView(toolbar, LinearLayout.LayoutParams(MATCH, toolbarHeight))
373
+
374
+ applySystemBarInsets(
375
+ header,
376
+ headerHeight,
377
+ listOf(
378
+ toolbar to toolbarHeight,
379
+ cropBar to dp(CROP_SUB_BAR_HEIGHT_DP),
380
+ adjustBar to dp(ADJUST_SUB_BAR_HEIGHT_DP),
381
+ resizeBar to dp(56),
382
+ filtersBar to dp(FILTERS_SUB_BAR_HEIGHT_DP),
383
+ stickersBar to dp(ASSET_GRID_BAR_HEIGHT_DP),
384
+ layerBar to dp(56)
385
+ )
386
+ )
387
+ return outer
388
+ }
389
+
390
+ /** Full Studio Violet tool rail (see main_photo_editor_default_state mockup). Rotate lives inside the Crop sub-bar, not as a top-level tool. */
391
+ private val photoToolDefinitions = listOf(
392
+ Triple("crop", "Crop", R.drawable.ic_crop),
393
+ Triple("adjust", "Adjust", R.drawable.ic_tune),
394
+ Triple("filters", "Filters", R.drawable.ic_photo_filter),
395
+ Triple("text", "Text", R.drawable.ic_title),
396
+ Triple("stickers", "Stickers", R.drawable.ic_sentiment_satisfied),
397
+ Triple("overlay", "Overlay", R.drawable.ic_wallpaper),
398
+ Triple("retouch", "Retouch", R.drawable.ic_face),
399
+ Triple("layers", "Layers", R.drawable.ic_layers),
400
+ Triple("resize", "Resize", R.drawable.ic_aspect_ratio)
401
+ )
402
+
403
+ private fun createPhotoToolBar(session: PhotoEditSession, imageView: ZoomableImageView, overlay: CropOverlayView): LinearLayout {
404
+ val backgroundColor = themeColor("toolbarColor", DesignTokens.withAlphaPercent(DesignTokens.surfaceContainerLowest, 95))
405
+ return LinearLayout(this).apply {
406
+ orientation = LinearLayout.VERTICAL
407
+ setBackgroundColor(backgroundColor)
408
+ setPadding(dp(DesignTokens.spaceXs), dp(DesignTokens.spaceSm), dp(DesignTokens.spaceXs), dp(DesignTokens.spaceLg))
409
+
410
+ addView(
411
+ View(this@PhotoVideoEditorActivity).apply { background = roundedDrawable(DesignTokens.surfaceVariant, DesignTokens.radiusSm) },
412
+ LinearLayout.LayoutParams(dp(36), dp(4)).apply { gravity = Gravity.CENTER_HORIZONTAL; bottomMargin = dp(DesignTokens.spaceXs) }
413
+ )
414
+
415
+ val features = request.optJSONObject("features")
416
+ addView(HorizontalScrollView(this@PhotoVideoEditorActivity).apply {
417
+ isHorizontalScrollBarEnabled = false
418
+ photoToolScroll = this
419
+ addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
420
+ orientation = LinearLayout.HORIZONTAL
421
+ gravity = Gravity.CENTER_VERTICAL
422
+ setPadding(dp(DesignTokens.spaceSm), dp(DesignTokens.spaceXs), dp(DesignTokens.spaceSm), dp(DesignTokens.spaceXs))
423
+ photoToolDefinitions.filter { features?.optBoolean(it.first, true) != false }.forEach { (key, label, iconRes) ->
424
+ val node = createToolNode(iconRes, label) { onPhotoToolTapped(key, session, imageView, overlay) }
425
+ photoToolButtons[key] = node
426
+ addView(node.root, LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), WRAP).apply { marginEnd = dp(DesignTokens.spaceSm) })
427
+ }
428
+ })
429
+ }, LinearLayout.LayoutParams(MATCH, WRAP))
430
+ }
431
+ }
432
+
433
+ private fun refreshPhotoToolSelection() {
434
+ val textColor = themeColor("textColor", DesignTokens.onSurface)
435
+ photoToolButtons.forEach { (key, node) ->
436
+ val selected = when (key) {
437
+ "crop" -> cropMode
438
+ "adjust" -> adjustMode
439
+ "filters" -> filtersMode
440
+ "stickers" -> stickersMode
441
+ "resize" -> resizeMode
442
+ else -> false
443
+ }
444
+ setToolNodeSelected(node, selected, textColor)
445
+ if (selected) scrollToolNodeIntoView(node)
446
+ }
447
+ }
448
+
449
+ /** Keeps the active tool-rail node fully visible, never partially clipped at the scroll edge. */
450
+ private fun scrollToolNodeIntoView(node: ToolNode) {
451
+ val scroll = photoToolScroll ?: return
452
+ scroll.post {
453
+ val left = node.root.left
454
+ val right = node.root.right
455
+ when {
456
+ left < scroll.scrollX -> scroll.smoothScrollTo(left, 0)
457
+ right > scroll.scrollX + scroll.width -> scroll.smoothScrollTo(right - scroll.width, 0)
458
+ }
459
+ }
460
+ }
461
+
462
+ private fun onPhotoToolTapped(key: String, session: PhotoEditSession, imageView: ZoomableImageView, overlay: CropOverlayView) {
463
+ when (key) {
464
+ "crop" -> setCropMode(true, session, imageView, overlay)
465
+ "adjust" -> setAdjustMode(true, session)
466
+ "filters" -> setFiltersMode(true, session)
467
+ "text" -> showTextInputDialog(session)
468
+ "stickers" -> setStickersMode(true, session)
469
+ "overlay" -> launchOverlayPicker(REQUEST_CODE_OVERLAY_UPLOAD)
470
+ "resize" -> setResizeMode(true, session, imageView, overlay)
471
+ "retouch" -> setAdjustMode(true, session)
472
+ "layers" -> openLayerManager(session)
473
+ else -> Toast.makeText(this, "$key is coming in a later milestone.", Toast.LENGTH_SHORT).show()
474
+ }
475
+ }
476
+
477
+ private fun openLayerManager(session: PhotoEditSession) {
478
+ val top = session.layerStack.layers.lastOrNull()
479
+ if (top == null) Toast.makeText(this, "Add content to start a layer stack.", Toast.LENGTH_LONG).show()
480
+ else selectLayer(top.id)
481
+ }
482
+
483
+ // ---------------------------------------------------------------------
484
+ // Layers: text, stickers, shapes, freehand drawing, selection, and
485
+ // transform/duplicate/reorder/lock/hide/delete with undo/redo (Milestone 4).
486
+ // ---------------------------------------------------------------------
487
+
488
+ private fun showTextInputDialog(session: PhotoEditSession) {
489
+ val input = EditText(this).apply {
490
+ inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE
491
+ setTextColor(DesignTokens.onSurface)
492
+ setHintTextColor(DesignTokens.outline)
493
+ hint = "Type something..."
494
+ setPadding(dp(DesignTokens.spaceLg), dp(DesignTokens.spaceLg), dp(DesignTokens.spaceLg), dp(DesignTokens.spaceLg))
495
+ }
496
+ val container = FrameLayout(this).apply {
497
+ background = roundedDrawable(DesignTokens.surfaceContainerHigh, DesignTokens.radiusLg)
498
+ addView(input, FrameLayout.LayoutParams(MATCH, WRAP))
499
+ }
500
+ AlertDialog.Builder(this, android.R.style.Theme_Material_Dialog_Alert)
501
+ .setTitle("Add text")
502
+ .setView(container)
503
+ .setPositiveButton("Add") { _, _ ->
504
+ val text = input.text?.toString().orEmpty()
505
+ if (text.isNotBlank()) {
506
+ val layer = PhotoLayer(type = LayerType.TEXT, text = text)
507
+ session.layerStack.commit { it + layer }
508
+ selectLayer(layer.id)
509
+ }
510
+ }
511
+ .setNegativeButton("Cancel", null)
512
+ .show()
513
+ }
514
+
515
+ private fun setStickersMode(enabled: Boolean, session: PhotoEditSession) {
516
+ stickersMode = enabled
517
+ stickersSubBar.visibility = if (enabled) View.VISIBLE else View.GONE
518
+ mainToolBar.visibility = if (enabled) View.GONE else View.VISIBLE
519
+ refreshPhotoToolSelection()
520
+ }
521
+
522
+ /** One asset card in the Stickers library grid (see stickers_shapes_library mockup). */
523
+ private fun createAssetCard(
524
+ faceView: View,
525
+ captionText: String?,
526
+ faceParams: FrameLayout.LayoutParams = FrameLayout.LayoutParams(WRAP, WRAP, Gravity.CENTER),
527
+ onClick: () -> Unit
528
+ ): LinearLayout {
529
+ val cardSize = dp(64)
530
+ val face = FrameLayout(this).apply {
531
+ background = roundedDrawable(DesignTokens.surfaceContainer, DesignTokens.radiusLg)
532
+ addView(faceView, faceParams)
533
+ }
534
+ return LinearLayout(this).apply {
535
+ orientation = LinearLayout.VERTICAL
536
+ gravity = Gravity.CENTER_HORIZONTAL
537
+ isClickable = true
538
+ setPadding(dp(4), 0, dp(4), 0)
539
+ addView(face, LinearLayout.LayoutParams(cardSize, cardSize))
540
+ if (captionText != null) {
541
+ addView(TextView(this@PhotoVideoEditorActivity).apply {
542
+ text = captionText
543
+ textSize = 10f
544
+ maxLines = 1
545
+ gravity = Gravity.CENTER
546
+ setTextColor(DesignTokens.outline)
547
+ setPadding(0, dp(DesignTokens.spaceXs), 0, 0)
548
+ }, LinearLayout.LayoutParams(dp(72), WRAP))
549
+ }
550
+ setOnClickListener { onClick() }
551
+ }
552
+ }
553
+
554
+ private fun createStickersSubBar(session: PhotoEditSession): LinearLayout {
555
+ val toolbarColor = themeColor("toolbarColor", DesignTokens.surfaceContainerLow)
556
+ val primaryColor = themeColor("primaryColor", DesignTokens.primaryContainer)
557
+ val consumerAssets = request.optJSONArray("stickerAssets")
558
+ return LinearLayout(this).apply {
559
+ gravity = Gravity.CENTER_VERTICAL
560
+ setPadding(dp(12), dp(8), dp(12), dp(8))
561
+ setBackgroundColor(toolbarColor)
562
+ addView(HorizontalScrollView(this@PhotoVideoEditorActivity).apply {
563
+ isHorizontalScrollBarEnabled = false
564
+ addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
565
+ orientation = LinearLayout.HORIZONTAL
566
+ PhotoLayerRenderer.builtinStickerIds().forEach { id ->
567
+ val emoji = TextView(this@PhotoVideoEditorActivity).apply { text = PhotoLayerRenderer.glyphFor(id); textSize = 26f }
568
+ addView(
569
+ createAssetCard(emoji, null) {
570
+ val layer = PhotoLayer(type = LayerType.STICKER, stickerId = id)
571
+ session.layerStack.commit { it + layer }
572
+ selectLayer(layer.id)
573
+ setStickersMode(false, session)
574
+ },
575
+ LinearLayout.LayoutParams(WRAP, WRAP).apply { marginEnd = dp(DesignTokens.spaceSm) }
576
+ )
577
+ }
578
+ if (consumerAssets != null) {
579
+ for (i in 0 until consumerAssets.length()) {
580
+ val asset = consumerAssets.optJSONObject(i) ?: continue
581
+ val uri = asset.optString("uri").takeIf { it.isNotBlank() } ?: continue
582
+ val id = asset.optString("id", "asset$i")
583
+ val icon = ImageView(this@PhotoVideoEditorActivity).apply {
584
+ setImageResource(R.drawable.ic_sentiment_satisfied)
585
+ setColorFilter(DesignTokens.tertiary)
586
+ }
587
+ addView(
588
+ createAssetCard(icon, id) {
589
+ val layer = PhotoLayer(type = LayerType.STICKER, stickerUri = uri)
590
+ session.layerStack.commit { it + layer }
591
+ selectLayer(layer.id)
592
+ setStickersMode(false, session)
593
+ },
594
+ LinearLayout.LayoutParams(WRAP, WRAP).apply { marginEnd = dp(DesignTokens.spaceSm) }
595
+ )
596
+ }
597
+ }
598
+ val uploadIcon = ImageView(this@PhotoVideoEditorActivity).apply {
599
+ setImageResource(R.drawable.ic_wallpaper)
600
+ setColorFilter(DesignTokens.tertiary)
601
+ }
602
+ addView(
603
+ createAssetCard(uploadIcon, "Upload") {
604
+ launchOverlayPicker(REQUEST_CODE_STICKER_UPLOAD)
605
+ },
606
+ LinearLayout.LayoutParams(WRAP, WRAP).apply { marginEnd = dp(DesignTokens.spaceSm) }
607
+ )
608
+ val features = request.optJSONObject("features")
609
+ if (features?.optBoolean("onlineStickers", true) != false) {
610
+ val browseIcon = ImageView(this@PhotoVideoEditorActivity).apply {
611
+ setImageResource(R.drawable.ic_photo_filter)
612
+ setColorFilter(DesignTokens.tertiary)
613
+ }
614
+ addView(
615
+ createAssetCard(browseIcon, "Browse") {
616
+ OnlineStickerSheet(this@PhotoVideoEditorActivity) { path ->
617
+ val layer = PhotoLayer(type = LayerType.STICKER, stickerUri = Uri.fromFile(File(path)).toString())
618
+ session.layerStack.commit { it + layer }
619
+ selectLayer(layer.id)
620
+ setStickersMode(false, session)
621
+ }.show()
622
+ },
623
+ LinearLayout.LayoutParams(WRAP, WRAP).apply { marginEnd = dp(DesignTokens.spaceSm) }
624
+ )
625
+ }
626
+ })
627
+ }, LinearLayout.LayoutParams(0, MATCH, 1f))
628
+ addView(
629
+ actionButton("Done") { setStickersMode(false, session) }.apply { setTextColor(primaryColor) },
630
+ LinearLayout.LayoutParams(dp(72), MATCH)
631
+ )
632
+ }
633
+ }
634
+
635
+ /** Launches the SAF image picker for either the sticker-upload or overlay-upload flow, dispatched in [onActivityResult]. */
636
+ private fun launchOverlayPicker(requestCode: Int) {
637
+ val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).setType("image/*").addCategory(Intent.CATEGORY_OPENABLE)
638
+ try {
639
+ startActivityForResult(intent, requestCode)
640
+ } catch (_: Exception) {
641
+ Toast.makeText(this, "No app available to pick an image.", Toast.LENGTH_SHORT).show()
642
+ }
643
+ }
644
+
645
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
646
+ super.onActivityResult(requestCode, resultCode, data)
647
+ if (resultCode != Activity.RESULT_OK) return
648
+ val pickedUri = data?.data?.toString() ?: return
649
+ val path = SourceResolver.resolvePath(this, pickedUri, "pve_picked_image") ?: run {
650
+ Toast.makeText(this, "The selected image could not be opened.", Toast.LENGTH_SHORT).show()
651
+ return
652
+ }
653
+ val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
654
+ BitmapFactory.decodeFile(path, bounds)
655
+ val aspectRatio = if (bounds.outWidth > 0 && bounds.outHeight > 0) {
656
+ bounds.outWidth.toFloat() / bounds.outHeight.toFloat()
657
+ } else 1f
658
+ val resolvedUri = Uri.fromFile(File(path)).toString()
659
+ when (requestCode) {
660
+ REQUEST_CODE_STICKER_UPLOAD -> {
661
+ val session = photoSession ?: return
662
+ val layer = PhotoLayer(type = LayerType.STICKER, stickerUri = resolvedUri)
663
+ session.layerStack.commit { it + layer }
664
+ selectLayer(layer.id)
665
+ setStickersMode(false, session)
666
+ }
667
+ REQUEST_CODE_OVERLAY_UPLOAD -> {
668
+ val session = photoSession ?: return
669
+ val layer = PhotoLayer(
670
+ type = LayerType.OVERLAY,
671
+ overlayUri = resolvedUri,
672
+ overlayAspectRatio = aspectRatio,
673
+ x = 0.5f,
674
+ y = 0.5f,
675
+ scale = 1f
676
+ )
677
+ session.layerStack.commit { it + layer }
678
+ selectLayer(layer.id)
679
+ }
680
+ REQUEST_CODE_VIDEO_STICKER_UPLOAD -> {
681
+ val session = videoSession ?: return
682
+ val layer = PhotoLayer(type = LayerType.STICKER, stickerUri = resolvedUri)
683
+ session.layerStack.commit { it + layer }
684
+ onVideoLayerStackChanged()
685
+ selectVideoLayer(layer.id, session)
686
+ }
687
+ REQUEST_CODE_VIDEO_OVERLAY_UPLOAD -> {
688
+ val session = videoSession ?: return
689
+ val layer = PhotoLayer(
690
+ type = LayerType.OVERLAY,
691
+ overlayUri = resolvedUri,
692
+ overlayAspectRatio = aspectRatio,
693
+ x = 0.5f,
694
+ y = 0.5f,
695
+ scale = 1f
696
+ )
697
+ session.layerStack.commit { it + layer }
698
+ onVideoLayerStackChanged()
699
+ selectVideoLayer(layer.id, session)
700
+ }
701
+ }
702
+ }
703
+
704
+ private fun selectLayer(id: String?) {
705
+ val session = photoSession
706
+ selectedLayerId = id
707
+ layerOverlay.selectedLayerId = id
708
+ if (session != null) {
709
+ layerOverlay.layers = session.layerStack.layers
710
+ schedulePhotoPreviewRender()
711
+ }
712
+ setLayerToolBarVisible(id != null)
713
+ }
714
+
715
+ private fun setLayerToolBarVisible(visible: Boolean) {
716
+ layerToolBar.visibility = if (visible) View.VISIBLE else View.GONE
717
+ mainToolBar.visibility = if (visible) View.GONE else View.VISIBLE
718
+ if (visible) {
719
+ // TEXT layers show only Opacity/Colors/Duplicate/Delete; STICKER layers show only
720
+ // Opacity/Duplicate/Delete (no Colors either) — Scale/Rotate/Size and the
721
+ // lock/visibility/reorder icons don't apply to either and are hidden per each reduced list.
722
+ val selectedType = currentSelectedLayer()?.type
723
+ val isText = selectedType == LayerType.TEXT
724
+ val isSticker = selectedType == LayerType.STICKER
725
+ val reducedControls = isText || isSticker
726
+ layerPropertyButtons["scale"]?.visibility = if (reducedControls) View.GONE else View.VISIBLE
727
+ layerPropertyButtons["rotation"]?.visibility = if (reducedControls) View.GONE else View.VISIBLE
728
+ layerPropertyButtons["fontSize"]?.visibility = View.GONE
729
+ layerPropertyButtons["color"]?.visibility = if (isText) View.VISIBLE else View.GONE
730
+ listOf("lock", "visibility", "front", "back").forEach { key ->
731
+ layerIconButtons[key]?.visibility = if (reducedControls) View.GONE else View.VISIBLE
732
+ }
733
+ val allowedKeys = when {
734
+ isText -> setOf("opacity", "color")
735
+ isSticker -> setOf("opacity")
736
+ else -> setOf("scale", "rotation", "opacity")
737
+ }
738
+ if (activeLayerPropertyKey !in allowedKeys) {
739
+ activeLayerPropertyKey = "opacity"
740
+ }
741
+ layerPropertySeekBar.visibility = if (activeLayerPropertyKey == "color") View.GONE else View.VISIBLE
742
+ layerColorSwatchRow.visibility = if (activeLayerPropertyKey == "color") View.VISIBLE else View.GONE
743
+ syncLayerPropertySeekBar()
744
+ refreshSelection(layerPropertyButtons, activeLayerPropertyKey, themeColor("textColor", DesignTokens.onSurface), themeColor("primaryColor", DesignTokens.primaryContainer))
745
+ } else {
746
+ layerPropertySeekBar.visibility = View.GONE
747
+ layerColorSwatchRow.visibility = View.GONE
748
+ }
749
+ }
750
+
751
+ /** Circular color swatch, matching the drawing_brush_studio mockup's palette dots. */
752
+ private fun createColorSwatch(color: Int, onClick: () -> Unit): FrameLayout {
753
+ val outer = FrameLayout(this).apply {
754
+ background = circleDrawable(DesignTokens.surfaceContainerHigh)
755
+ isClickable = true
756
+ setOnClickListener { onClick() }
757
+ }
758
+ val inner = View(this).apply { background = circleDrawable(color) }
759
+ outer.addView(inner, FrameLayout.LayoutParams(dp(24), dp(24), Gravity.CENTER))
760
+ return outer
761
+ }
762
+
763
+ /** Text color swatch row, shown when a TEXT layer's "Color" property is active (text_editor_typography_studio mockup). */
764
+ private fun createTextColorSwatchRow(session: PhotoEditSession): LinearLayout {
765
+ val toolbarColor = themeColor("toolbarColor", DesignTokens.surfaceContainerLow)
766
+ val colors = listOf(
767
+ Color.WHITE, Color.BLACK, Color.RED, Color.rgb(255, 214, 51),
768
+ Color.rgb(76, 217, 100), Color.rgb(0, 145, 255), DesignTokens.primary, DesignTokens.tertiary
769
+ )
770
+ return LinearLayout(this).apply {
771
+ gravity = Gravity.CENTER_VERTICAL
772
+ setPadding(dp(12), 0, dp(12), 0)
773
+ setBackgroundColor(toolbarColor)
774
+ addView(HorizontalScrollView(this@PhotoVideoEditorActivity).apply {
775
+ isHorizontalScrollBarEnabled = false
776
+ addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
777
+ orientation = LinearLayout.HORIZONTAL
778
+ gravity = Gravity.CENTER_VERTICAL
779
+ colors.forEach { color ->
780
+ addView(
781
+ createColorSwatch(color) {
782
+ val id = selectedLayerId ?: return@createColorSwatch
783
+ session.layerStack.commit { list -> list.map { if (it.id == id) it.copy(textColor = color) else it } }
784
+ onLayerStackChanged()
785
+ },
786
+ LinearLayout.LayoutParams(dp(32), dp(32)).apply { marginEnd = dp(DesignTokens.spaceSm) }
787
+ )
788
+ }
789
+ })
790
+ }, LinearLayout.LayoutParams(0, MATCH, 1f))
791
+ addView(
792
+ actionButton("Done") { selectLayer(null) }.apply { setTextColor(themeColor("primaryColor", DesignTokens.primaryContainer)) },
793
+ LinearLayout.LayoutParams(dp(72), MATCH)
794
+ )
795
+ }
796
+ }
797
+
798
+ private fun currentSelectedLayer(): PhotoLayer? {
799
+ val session = photoSession ?: return null
800
+ return session.layerStack.layers.firstOrNull { it.id == selectedLayerId }
801
+ }
802
+
803
+ private fun syncLayerPropertySeekBar() {
804
+ val layer = currentSelectedLayer() ?: return
805
+ val (min, max, value) = when (activeLayerPropertyKey) {
806
+ "rotation" -> Triple(-180f, 180f, layer.rotationDegrees)
807
+ "opacity" -> Triple(0f, 100f, layer.opacity * 100f)
808
+ "fontSize" -> Triple(12f, 160f, layer.fontSize)
809
+ else -> Triple(20f, 800f, layer.scale * 100f)
810
+ }
811
+ layerPropertySeekBar.max = (max - min).roundToInt()
812
+ layerPropertySeekBar.progress = (value - min).roundToInt()
813
+ }
814
+
815
+ private fun onLayerPropertyChanged(progress: Int) {
816
+ val session = photoSession ?: return
817
+ val id = selectedLayerId ?: return
818
+ val min = when (activeLayerPropertyKey) {
819
+ "rotation" -> -180f
820
+ "opacity" -> 0f
821
+ "fontSize" -> 12f
822
+ else -> 20f
823
+ }
824
+ val value = min + progress
825
+ session.layerStack.updateLive { list ->
826
+ list.map { layer ->
827
+ if (layer.id != id) return@map layer
828
+ when (activeLayerPropertyKey) {
829
+ "rotation" -> layer.copy(rotationDegrees = value)
830
+ "opacity" -> layer.copy(opacity = value / 100f)
831
+ "fontSize" -> layer.copy(fontSize = value)
832
+ else -> layer.copy(scale = value / 100f)
833
+ }
834
+ }
835
+ }
836
+ layerOverlay.layers = session.layerStack.layers
837
+ schedulePhotoPreviewRender()
838
+ }
839
+
840
+ private fun createLayerToolBar(session: PhotoEditSession): LinearLayout {
841
+ val toolbarColor = themeColor("toolbarColor", DesignTokens.surfaceContainerLow)
842
+ val textColor = themeColor("textColor", DesignTokens.onSurface)
843
+ val primaryColor = themeColor("primaryColor", DesignTokens.primaryContainer)
844
+ // "fontSize"/"color" only make sense for TEXT layers; their chips are hidden/shown per-selection in `setLayerToolBarVisible`.
845
+ val properties = listOf("scale" to "Scale", "rotation" to "Rotate", "opacity" to "Opacity", "fontSize" to "Size", "color" to "Color")
846
+ return LinearLayout(this).apply {
847
+ gravity = Gravity.CENTER_VERTICAL
848
+ setPadding(dp(12), 0, dp(12), 0)
849
+ setBackgroundColor(toolbarColor)
850
+ addView(HorizontalScrollView(this@PhotoVideoEditorActivity).apply {
851
+ isHorizontalScrollBarEnabled = false
852
+ addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
853
+ orientation = LinearLayout.HORIZONTAL
854
+ gravity = Gravity.CENTER_VERTICAL
855
+ properties.forEach { (key, label) ->
856
+ val chip = actionButton(label) {
857
+ activeLayerPropertyKey = key
858
+ layerPropertySeekBar.visibility = if (key == "color") View.GONE else View.VISIBLE
859
+ layerColorSwatchRow.visibility = if (key == "color") View.VISIBLE else View.GONE
860
+ if (key != "color") syncLayerPropertySeekBar()
861
+ refreshSelection(layerPropertyButtons, activeLayerPropertyKey, textColor, primaryColor)
862
+ }.apply { setTextColor(textColor) }
863
+ layerPropertyButtons[key] = chip
864
+ addView(chip, LinearLayout.LayoutParams(dp(80), MATCH))
865
+ }
866
+ addView(
867
+ createIconButton(R.drawable.ic_content_copy, "Duplicate layer", textColor) {
868
+ val original = currentSelectedLayer() ?: return@createIconButton
869
+ val copy = original.copy(id = java.util.UUID.randomUUID().toString(), x = (original.x + 0.04f).coerceIn(0f, 1f), y = (original.y + 0.04f).coerceIn(0f, 1f))
870
+ session.layerStack.commit { it + copy }
871
+ selectLayer(copy.id)
872
+ },
873
+ LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin))
874
+ )
875
+ addView(
876
+ createIconButton(R.drawable.ic_lock, "Toggle lock", textColor) {
877
+ val id = selectedLayerId ?: return@createIconButton
878
+ session.layerStack.commit { list -> list.map { if (it.id == id) it.copy(locked = !it.locked) else it } }
879
+ onLayerStackChanged()
880
+ }.also { layerIconButtons["lock"] = it },
881
+ LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin))
882
+ )
883
+ addView(
884
+ createIconButton(R.drawable.ic_visibility_off, "Toggle visibility", textColor) {
885
+ val id = selectedLayerId ?: return@createIconButton
886
+ session.layerStack.commit { list -> list.map { if (it.id == id) it.copy(visible = !it.visible) else it } }
887
+ onLayerStackChanged()
888
+ }.also { layerIconButtons["visibility"] = it },
889
+ LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin))
890
+ )
891
+ addView(
892
+ createIconButton(R.drawable.ic_chevron_right, "Bring to front", textColor) {
893
+ val id = selectedLayerId ?: return@createIconButton
894
+ session.layerStack.commit { list -> (list.filter { it.id != id } + list.first { it.id == id }) }
895
+ onLayerStackChanged()
896
+ }.also { layerIconButtons["front"] = it },
897
+ LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin))
898
+ )
899
+ addView(
900
+ createIconButton(R.drawable.ic_chevron_left, "Send to back", textColor) {
901
+ val id = selectedLayerId ?: return@createIconButton
902
+ session.layerStack.commit { list -> (listOf(list.first { it.id == id }) + list.filter { it.id != id }) }
903
+ onLayerStackChanged()
904
+ }.also { layerIconButtons["back"] = it },
905
+ LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin))
906
+ )
907
+ addView(
908
+ createIconButton(R.drawable.ic_delete, "Delete layer", textColor) {
909
+ val id = selectedLayerId ?: return@createIconButton
910
+ session.layerStack.commit { list -> list.filterNot { it.id == id } }
911
+ selectLayer(null)
912
+ onLayerStackChanged()
913
+ },
914
+ LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin))
915
+ )
916
+ })
917
+ }, LinearLayout.LayoutParams(0, MATCH, 1f))
918
+ addView(
919
+ actionButton("Done") { selectLayer(null) }.apply { setTextColor(primaryColor) },
920
+ LinearLayout.LayoutParams(dp(72), MATCH)
921
+ )
922
+ }
923
+ }
924
+
925
+ /** Adjust (image_adjustments_workspace mockup) — Brightness/Contrast/Saturation/etc sliders + Mirror. */
926
+ private fun setAdjustMode(enabled: Boolean, session: PhotoEditSession) {
927
+ adjustMode = enabled
928
+ adjustSubBar.visibility = if (enabled) View.VISIBLE else View.GONE
929
+ adjustmentSeekBar.visibility = if (enabled) View.VISIBLE else View.GONE
930
+ mainToolBar.visibility = if (enabled) View.GONE else View.VISIBLE
931
+ layerOverlay.visibility = if (enabled) View.GONE else View.VISIBLE
932
+ if (enabled) { activeAdjustmentKey = "brightness"; syncAdjustmentSeekBar(session) }
933
+ refreshPhotoToolSelection()
934
+ refreshAdjustSelection()
935
+ }
936
+
937
+ /** Filters (filter_browser_library mockup) — preset thumbnail chips + strength slider. */
938
+ private fun setFiltersMode(enabled: Boolean, session: PhotoEditSession) {
939
+ filtersMode = enabled
940
+ filtersSubBar.visibility = if (enabled) View.VISIBLE else View.GONE
941
+ adjustmentSeekBar.visibility = if (enabled) View.VISIBLE else View.GONE
942
+ mainToolBar.visibility = if (enabled) View.GONE else View.VISIBLE
943
+ layerOverlay.visibility = if (enabled) View.GONE else View.VISIBLE
944
+ if (enabled) { activeAdjustmentKey = "filterStrength"; syncAdjustmentSeekBar(session) }
945
+ refreshPhotoToolSelection()
946
+ refreshFiltersSelection(session)
947
+ }
948
+
949
+ private fun refreshAdjustSelection() {
950
+ val textColor = themeColor("textColor", DesignTokens.onSurface)
951
+ val primaryColor = themeColor("primaryColor", DesignTokens.primaryContainer)
952
+ refreshSelection(filterAdjustmentButtons, activeAdjustmentKey, textColor, primaryColor)
953
+ }
954
+
955
+ private fun refreshFiltersSelection(session: PhotoEditSession) {
956
+ val textColor = themeColor("textColor", DesignTokens.onSurface)
957
+ val primaryColor = themeColor("primaryColor", DesignTokens.primaryContainer)
958
+ val activePreset = session.adjustments.filterPreset ?: ""
959
+ filterPresetCards.forEach { (id, pair) ->
960
+ val (frame, label) = pair
961
+ val selected = id == activePreset
962
+ frame.background = roundedDrawable(DesignTokens.surfaceContainerHighest, DesignTokens.radiusLg)
963
+ frame.foreground = if (selected) {
964
+ android.graphics.drawable.GradientDrawable().apply {
965
+ cornerRadius = dp(DesignTokens.radiusLg).toFloat()
966
+ setStroke(dp(2), DesignTokens.primaryContainer)
967
+ }
968
+ } else {
969
+ null
970
+ }
971
+ label.setTextColor(if (selected) primaryColor else DesignTokens.outline)
972
+ label.setTypeface(label.typeface, if (selected) android.graphics.Typeface.BOLD else android.graphics.Typeface.NORMAL)
973
+ }
974
+ }
975
+
976
+ private fun syncAdjustmentSeekBar(session: PhotoEditSession) {
977
+ val (min, max) = PhotoAdjustments.rangeFor(activeAdjustmentKey)
978
+ adjustmentSeekBar.max = (max - min).roundToInt()
979
+ adjustmentSeekBar.progress = (session.adjustments.value(activeAdjustmentKey) - min).roundToInt()
980
+ }
981
+
982
+ private fun createAdjustSubBar(session: PhotoEditSession, imageView: ZoomableImageView): LinearLayout {
983
+ val toolbarColor = themeColor("toolbarColor", DesignTokens.surfaceContainerLow)
984
+ val textColor = themeColor("textColor", DesignTokens.onSurface)
985
+ val primaryColor = themeColor("primaryColor", DesignTokens.primaryContainer)
986
+ val adjustmentChips = listOf(
987
+ "brightness" to "Brightness", "contrast" to "Contrast", "saturation" to "Saturation",
988
+ "exposure" to "Exposure", "temperature" to "Temp", "blurRadius" to "Blur"
989
+ )
990
+ return LinearLayout(this).apply {
991
+ gravity = Gravity.CENTER_VERTICAL
992
+ setPadding(dp(12), 0, dp(12), 0)
993
+ setBackgroundColor(toolbarColor)
994
+ addView(HorizontalScrollView(this@PhotoVideoEditorActivity).apply {
995
+ isHorizontalScrollBarEnabled = false
996
+ addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
997
+ orientation = LinearLayout.HORIZONTAL
998
+ adjustmentChips.forEach { (key, label) ->
999
+ val chip = actionButton(label) {
1000
+ activeAdjustmentKey = key
1001
+ syncAdjustmentSeekBar(session)
1002
+ refreshAdjustSelection()
1003
+ }.apply { setTextColor(textColor) }
1004
+ filterAdjustmentButtons[key] = chip
1005
+ addView(chip, LinearLayout.LayoutParams(dp(84), MATCH))
1006
+ }
1007
+ addView(
1008
+ actionButton("Mirror") {
1009
+ session.updateAdjustments { it.copy(mirror = !it.mirror) }
1010
+ schedulePhotoPreviewRender()
1011
+ }.apply { setTextColor(textColor) },
1012
+ LinearLayout.LayoutParams(dp(84), MATCH)
1013
+ )
1014
+ addView(
1015
+ actionButton("Reset") {
1016
+ session.updateAdjustments { it.copy(brightness = 0f, contrast = 0f, saturation = 0f, exposure = 0f, temperature = 0f, blurRadius = 0f, mirror = false) }
1017
+ syncAdjustmentSeekBar(session)
1018
+ schedulePhotoPreviewRender()
1019
+ refreshAdjustSelection()
1020
+ }.apply { setTextColor(textColor) },
1021
+ LinearLayout.LayoutParams(dp(72), MATCH)
1022
+ )
1023
+ })
1024
+ }, LinearLayout.LayoutParams(0, MATCH, 1f))
1025
+ addView(
1026
+ actionButton("Done") { setAdjustMode(false, session) }.apply { setTextColor(primaryColor) },
1027
+ LinearLayout.LayoutParams(dp(72), MATCH)
1028
+ )
1029
+ }
1030
+ }
1031
+
1032
+ /** Preset thumbnail card (64x64 preview + label), matching the "Preset Thumbnail Cards" component. */
1033
+ private fun createPresetCard(thumbnail: Bitmap?, labelText: String, textColor: Int, onClick: () -> Unit): Triple<LinearLayout, FrameLayout, TextView> {
1034
+ val thumbSize = dp(64)
1035
+ val image = ImageView(this).apply {
1036
+ thumbnail?.let { setImageBitmap(it) }
1037
+ scaleType = ImageView.ScaleType.CENTER_CROP
1038
+ }
1039
+ val frame = FrameLayout(this).apply {
1040
+ background = roundedDrawable(DesignTokens.surfaceContainerHighest, DesignTokens.radiusLg)
1041
+ clipToOutline = true
1042
+ addView(image, FrameLayout.LayoutParams(MATCH, MATCH))
1043
+ }
1044
+ val label = TextView(this).apply {
1045
+ text = labelText
1046
+ textSize = 11f
1047
+ gravity = Gravity.CENTER
1048
+ setTextColor(DesignTokens.outline)
1049
+ setPadding(0, dp(DesignTokens.spaceXs), 0, 0)
1050
+ maxLines = 1
1051
+ }
1052
+ val root = LinearLayout(this).apply {
1053
+ orientation = LinearLayout.VERTICAL
1054
+ gravity = Gravity.CENTER_HORIZONTAL
1055
+ isClickable = true
1056
+ setPadding(dp(4), 0, dp(4), 0)
1057
+ addView(frame, LinearLayout.LayoutParams(thumbSize, thumbSize))
1058
+ addView(label, LinearLayout.LayoutParams(dp(72), WRAP))
1059
+ setOnClickListener { onClick() }
1060
+ }
1061
+ return Triple(root, frame, label)
1062
+ }
1063
+
1064
+ private fun createFiltersSubBar(session: PhotoEditSession, imageView: ZoomableImageView): LinearLayout {
1065
+ val toolbarColor = themeColor("toolbarColor", DesignTokens.surfaceContainerLow)
1066
+ val textColor = themeColor("textColor", DesignTokens.onSurface)
1067
+ val primaryColor = themeColor("primaryColor", DesignTokens.primaryContainer)
1068
+ return LinearLayout(this).apply {
1069
+ gravity = Gravity.CENTER_VERTICAL
1070
+ setPadding(dp(12), dp(8), dp(12), dp(8))
1071
+ setBackgroundColor(toolbarColor)
1072
+ addView(HorizontalScrollView(this@PhotoVideoEditorActivity).apply {
1073
+ isHorizontalScrollBarEnabled = false
1074
+ addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
1075
+ orientation = LinearLayout.HORIZONTAL
1076
+ gravity = Gravity.CENTER_VERTICAL
1077
+ val (originalCard, originalFrame, originalLabel) = createPresetCard(session.baseBitmap, "Original", textColor) {
1078
+ session.updateAdjustments { it.copy(filterPreset = null, filterStrength = 100f) }
1079
+ syncAdjustmentSeekBar(session)
1080
+ schedulePhotoPreviewRender()
1081
+ refreshFiltersSelection(session)
1082
+ }
1083
+ addView(originalCard, LinearLayout.LayoutParams(WRAP, WRAP).apply { marginEnd = dp(DesignTokens.spaceSm) })
1084
+ PhotoFilterPresets.PRESET_IDS.forEach { id ->
1085
+ val (card, frame, label) = createPresetCard(null, PhotoFilterPresets.labelFor(id), textColor) {
1086
+ session.updateAdjustments { it.copy(filterPreset = if (it.filterPreset == id) null else id) }
1087
+ activeAdjustmentKey = "filterStrength"
1088
+ syncAdjustmentSeekBar(session)
1089
+ schedulePhotoPreviewRender()
1090
+ refreshFiltersSelection(session)
1091
+ }
1092
+ val thumbnailView = frame.getChildAt(0) as ImageView
1093
+ filterThumbnailLoader.load(session.baseBitmap, id, dp(64)) { thumbnailView.setImageBitmap(it) }
1094
+ filterPresetCards[id] = Pair(frame, label)
1095
+ addView(card, LinearLayout.LayoutParams(WRAP, WRAP).apply { marginEnd = dp(DesignTokens.spaceSm) })
1096
+ }
1097
+ filterPresetCards[""] = Pair(originalFrame, originalLabel)
1098
+ })
1099
+ }, LinearLayout.LayoutParams(0, MATCH, 1f))
1100
+ addView(
1101
+ actionButton("Done") { setFiltersMode(false, session) }.apply { setTextColor(primaryColor) },
1102
+ LinearLayout.LayoutParams(dp(72), MATCH)
1103
+ )
1104
+ }
1105
+ }
1106
+
1107
+ private fun setCropMode(enabled: Boolean, session: PhotoEditSession, imageView: ZoomableImageView, overlay: CropOverlayView) {
1108
+ cropMode = enabled
1109
+ imageView.panZoomEnabled = !enabled
1110
+ overlay.visibility = if (enabled) View.VISIBLE else View.GONE
1111
+ straightenSeekBar.visibility = if (enabled) View.VISIBLE else View.GONE
1112
+ cropSubBar.visibility = if (enabled) View.VISIBLE else View.GONE
1113
+ mainToolBar.visibility = if (enabled) View.GONE else View.VISIBLE
1114
+ layerOverlay.visibility = if (enabled) View.GONE else View.VISIBLE
1115
+ if (enabled) {
1116
+ imageView.resetToFit()
1117
+ val state = session.state
1118
+ val isIdentityCrop = state.cropLeft == 0f && state.cropTop == 0f && state.cropRight == 1f && state.cropBottom == 1f
1119
+ overlay.setImageBounds(imageView.currentImageBounds(), resetCrop = isIdentityCrop)
1120
+ }
1121
+ refreshPhotoToolSelection()
1122
+ refreshSelection(cropAspectButtons, session.state.aspectRatio, themeColor("textColor", DesignTokens.onSurface), themeColor("primaryColor", DesignTokens.primaryContainer))
1123
+ }
1124
+
1125
+ private fun createCropSubBar(session: PhotoEditSession, imageView: ZoomableImageView, overlay: CropOverlayView): LinearLayout {
1126
+ val toolbarColor = themeColor("toolbarColor", DesignTokens.surfaceContainerLow)
1127
+ val textColor = themeColor("textColor", DesignTokens.onSurface)
1128
+ val primaryColor = themeColor("primaryColor", DesignTokens.primaryContainer)
1129
+ val aspectPresets = listOf<Pair<String, Float?>>(
1130
+ "Free" to null, "1:1" to 1f, "4:5" to 4f / 5f, "3:4" to 3f / 4f, "9:16" to 9f / 16f, "16:9" to 16f / 9f
1131
+ )
1132
+ return LinearLayout(this).apply {
1133
+ orientation = LinearLayout.VERTICAL
1134
+ setPadding(0, dp(6), 0, dp(6))
1135
+ setBackgroundColor(toolbarColor)
1136
+
1137
+ addView(TextView(this@PhotoVideoEditorActivity).apply {
1138
+ text = "Crop Mode • TRANSFORM & ASPECT RATIO"
1139
+ textSize = 14f
1140
+ setTypeface(typeface, android.graphics.Typeface.BOLD)
1141
+ setTextColor(textColor)
1142
+ gravity = Gravity.CENTER_VERTICAL
1143
+ setPadding(dp(DesignTokens.spaceLg), 0, dp(DesignTokens.spaceLg), 0)
1144
+ }, LinearLayout.LayoutParams(MATCH, dp(36)))
1145
+
1146
+ // Rotate/Flip row — moved here from the main tool rail to match the crop_transform_editor mockup.
1147
+ addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
1148
+ orientation = LinearLayout.HORIZONTAL
1149
+ gravity = Gravity.CENTER
1150
+ addView(
1151
+ createIconButton(R.drawable.ic_rotate_left, "Rotate left", textColor) {
1152
+ session.update { it.rotatedRight().rotatedRight().rotatedRight() }
1153
+ schedulePhotoPreviewRender()
1154
+ imageView.post { imageView.resetToFit(); overlay.setImageBounds(imageView.currentImageBounds(), resetCrop = true) }
1155
+ },
1156
+ LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin))
1157
+ )
1158
+ addView(
1159
+ createIconButton(R.drawable.ic_rotate_right, "Rotate right", textColor) {
1160
+ session.update { it.rotatedRight() }
1161
+ schedulePhotoPreviewRender()
1162
+ imageView.post { imageView.resetToFit(); overlay.setImageBounds(imageView.currentImageBounds(), resetCrop = true) }
1163
+ },
1164
+ LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin))
1165
+ )
1166
+ }, LinearLayout.LayoutParams(MATCH, dp(DesignTokens.touchTargetMin)))
1167
+
1168
+ addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
1169
+ gravity = Gravity.CENTER_VERTICAL
1170
+ setPadding(dp(12), 0, dp(12), 0)
1171
+ addView(HorizontalScrollView(this@PhotoVideoEditorActivity).apply {
1172
+ isHorizontalScrollBarEnabled = false
1173
+ addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
1174
+ orientation = LinearLayout.HORIZONTAL
1175
+ aspectPresets.forEach { (label, ratio) ->
1176
+ val chip = actionButton(label) {
1177
+ overlay.setAspectRatio(ratio)
1178
+ session.update { it.withAspectRatio(ratio) }
1179
+ refreshSelection(cropAspectButtons, ratio, textColor, primaryColor)
1180
+ }.apply { setTextColor(textColor) }
1181
+ cropAspectButtons[ratio] = chip
1182
+ addView(chip, LinearLayout.LayoutParams(dp(64), MATCH))
1183
+ }
1184
+ addView(
1185
+ actionButton("Reset") {
1186
+ session.update { it.reset() }
1187
+ overlay.setAspectRatio(null)
1188
+ straightenSeekBar.progress = 45
1189
+ schedulePhotoPreviewRender()
1190
+ imageView.resetToFit()
1191
+ overlay.setImageBounds(imageView.currentImageBounds(), resetCrop = true)
1192
+ refreshSelection(cropAspectButtons, null, textColor, primaryColor)
1193
+ }.apply { setTextColor(textColor) },
1194
+ LinearLayout.LayoutParams(dp(72), MATCH)
1195
+ )
1196
+ })
1197
+ }, LinearLayout.LayoutParams(0, MATCH, 1f))
1198
+ addView(
1199
+ actionButton("Done") { setCropMode(false, session, imageView, overlay) }.apply { setTextColor(primaryColor) },
1200
+ LinearLayout.LayoutParams(dp(72), MATCH)
1201
+ )
1202
+ }, LinearLayout.LayoutParams(MATCH, dp(56)))
1203
+ }
1204
+ }
1205
+
1206
+ /** Canvas Resize & Social Presets (canvas_resize_social_presets mockup): reuses the crop-aspect mechanism to frame the shot, plus an exact export pixel size for the chosen platform. */
1207
+ private fun setResizeMode(enabled: Boolean, session: PhotoEditSession, imageView: ZoomableImageView, overlay: CropOverlayView) {
1208
+ resizeMode = enabled
1209
+ imageView.panZoomEnabled = !enabled
1210
+ overlay.visibility = if (enabled) View.VISIBLE else View.GONE
1211
+ resizeSubBar.visibility = if (enabled) View.VISIBLE else View.GONE
1212
+ mainToolBar.visibility = if (enabled) View.GONE else View.VISIBLE
1213
+ layerOverlay.visibility = if (enabled) View.GONE else View.VISIBLE
1214
+ if (enabled) {
1215
+ imageView.resetToFit()
1216
+ overlay.setImageBounds(imageView.currentImageBounds(), resetCrop = false)
1217
+ overlay.setAspectRatio(session.state.aspectRatio)
1218
+ }
1219
+ refreshPhotoToolSelection()
1220
+ refreshSelection(resizePresetButtons, resizePresetSize, themeColor("textColor", DesignTokens.onSurface), themeColor("primaryColor", DesignTokens.primaryContainer))
1221
+ }
1222
+
1223
+ private fun createResizeSubBar(session: PhotoEditSession, imageView: ZoomableImageView, overlay: CropOverlayView): LinearLayout {
1224
+ val toolbarColor = themeColor("toolbarColor", DesignTokens.surfaceContainerLow)
1225
+ val textColor = themeColor("textColor", DesignTokens.onSurface)
1226
+ val primaryColor = themeColor("primaryColor", DesignTokens.primaryContainer)
1227
+ // (label, aspect ratio for cropping, exact export pixel size or null for "Original" = no forced resize)
1228
+ val presets = listOf<Triple<String, Float?, Pair<Int, Int>?>>(
1229
+ Triple("Original", null, null),
1230
+ Triple("IG Post", 4f / 5f, 1080 to 1350),
1231
+ Triple("IG Story", 9f / 16f, 1080 to 1920),
1232
+ Triple("IG Square", 1f, 1080 to 1080),
1233
+ Triple("YouTube", 16f / 9f, 1280 to 720),
1234
+ Triple("Twitter", 3f / 1f, 1500 to 500)
1235
+ )
1236
+ return LinearLayout(this).apply {
1237
+ gravity = Gravity.CENTER_VERTICAL
1238
+ setPadding(dp(12), 0, dp(12), 0)
1239
+ setBackgroundColor(toolbarColor)
1240
+ addView(HorizontalScrollView(this@PhotoVideoEditorActivity).apply {
1241
+ isHorizontalScrollBarEnabled = false
1242
+ addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
1243
+ orientation = LinearLayout.HORIZONTAL
1244
+ presets.forEach { (label, ratio, size) ->
1245
+ val chip = actionButton(label) {
1246
+ overlay.setAspectRatio(ratio)
1247
+ session.update { it.withAspectRatio(ratio) }
1248
+ resizePresetSize = size
1249
+ refreshSelection(resizePresetButtons, size, textColor, primaryColor)
1250
+ }.apply { setTextColor(textColor) }
1251
+ resizePresetButtons[size] = chip
1252
+ addView(chip, LinearLayout.LayoutParams(dp(80), MATCH))
1253
+ }
1254
+ })
1255
+ }, LinearLayout.LayoutParams(0, MATCH, 1f))
1256
+ addView(
1257
+ actionButton("Done") { setResizeMode(false, session, imageView, overlay) }.apply { setTextColor(primaryColor) },
1258
+ LinearLayout.LayoutParams(dp(72), MATCH)
1259
+ )
1260
+ }
1261
+ }
1262
+
1263
+ private fun exportPhoto() {
1264
+ val session = photoSession
1265
+ if (session == null) {
1266
+ finishWithError("Nothing to export.", "E_INTERNAL")
1267
+ return
1268
+ }
1269
+ exportProgressView.visibility = View.VISIBLE
1270
+ val state = session.state
1271
+ val adjustments = session.adjustments
1272
+ val layers = session.layerStack.layers
1273
+ // A chosen Resize/social preset overrides the consumer's own maxWidth/maxHeight, since the
1274
+ // user explicitly picked an exact output size (matching the crop aspect ratio set alongside it).
1275
+ val exportOptions = (request.optJSONObject("export") ?: JSONObject()).apply {
1276
+ resizePresetSize?.let { (width, height) -> put("maxWidth", width); put("maxHeight", height) }
1277
+ }
1278
+ Thread {
1279
+ try {
1280
+ val result = PhotoExporter.export(applicationContext, sourceUri, state, adjustments, layers, exportOptions)
1281
+ runOnUiThread {
1282
+ val intent = Intent()
1283
+ .putExtra(EXTRA_URI, result.uri)
1284
+ .putExtra(EXTRA_TYPE, mediaType)
1285
+ .putExtra(EXTRA_MIME_TYPE, result.mimeType)
1286
+ .putExtra(EXTRA_WIDTH, result.width)
1287
+ .putExtra(EXTRA_HEIGHT, result.height)
1288
+ .putExtra(EXTRA_FILE_SIZE, result.fileSize)
1289
+ setResult(RESULT_OK, intent)
1290
+ finish()
1291
+ }
1292
+ } catch (error: PhotoExportException) {
1293
+ runOnUiThread { finishWithError(error.message ?: "Unable to export the photo.", error.code) }
1294
+ } catch (error: Exception) {
1295
+ runOnUiThread { finishWithError("Unable to export the photo.", "E_EXPORT_FAILED") }
1296
+ }
1297
+ }.start()
1298
+ }
1299
+
1300
+ // ---------------------------------------------------------------------
1301
+ // Video editor: preview, trim, mute, cover frame, rotate/flip/aspect,
1302
+ // and H.264/AAC MP4 export (Milestone 5). Overlays/filters are Milestone 6+.
1303
+ // ---------------------------------------------------------------------
1304
+
1305
+ private fun createVideoEditorView(): View {
1306
+ val backgroundColor = themeColor("backgroundColor", DesignTokens.surfaceContainerLowest)
1307
+ val toolbarColor = themeColor("toolbarColor", DesignTokens.surfaceContainerLow)
1308
+ val textColor = themeColor("textColor", DesignTokens.onSurface)
1309
+ val root = LinearLayout(this).apply {
1310
+ orientation = LinearLayout.VERTICAL
1311
+ setBackgroundColor(backgroundColor)
1312
+ }
1313
+
1314
+ val headerHeight = dp(64)
1315
+ val header = createTopBar()
1316
+ root.addView(header, LinearLayout.LayoutParams(MATCH, headerHeight))
1317
+
1318
+ val preview = FrameLayout(this).apply { setBackgroundColor(Color.BLACK) }
1319
+ root.addView(preview, LinearLayout.LayoutParams(MATCH, 0, 1f))
1320
+
1321
+ val session = VideoEditSession(this, sourceUri)
1322
+ videoSession = session
1323
+
1324
+ val outer = FrameLayout(this)
1325
+ val progress = FrameLayout(this).apply {
1326
+ visibility = View.GONE
1327
+ setBackgroundColor(Color.argb(160, 0, 0, 0))
1328
+ val column = LinearLayout(this@PhotoVideoEditorActivity).apply {
1329
+ orientation = LinearLayout.VERTICAL
1330
+ gravity = Gravity.CENTER
1331
+ }
1332
+ column.addView(ProgressBar(this@PhotoVideoEditorActivity))
1333
+ val cancelButton = Button(this@PhotoVideoEditorActivity).apply {
1334
+ text = "Cancel export"
1335
+ setTextColor(Color.WHITE)
1336
+ setBackgroundColor(Color.TRANSPARENT)
1337
+ setOnClickListener { cancelVideoExport() }
1338
+ }
1339
+ videoExportCancelButton = cancelButton
1340
+ column.addView(cancelButton)
1341
+ addView(column, FrameLayout.LayoutParams(WRAP, WRAP, Gravity.CENTER))
1342
+ }
1343
+ videoExportProgressView = progress
1344
+ outer.addView(root, FrameLayout.LayoutParams(MATCH, MATCH))
1345
+ outer.addView(progress, FrameLayout.LayoutParams(MATCH, MATCH))
1346
+
1347
+ val playerView = PlayerView(this).apply {
1348
+ player = session.player
1349
+ useController = false
1350
+ contentDescription = "Selected video preview"
1351
+ }
1352
+ preview.addView(playerView, FrameLayout.LayoutParams(MATCH, MATCH))
1353
+
1354
+ val overlayView = ImageView(this).apply {
1355
+ contentDescription = "Video overlays"
1356
+ // Match PlayerView's default RESIZE_MODE_FIT letterboxing so the overlay bitmap (sized to the
1357
+ // video's own aspect ratio) lines up with the actual displayed video frame, not the full view.
1358
+ scaleType = ImageView.ScaleType.FIT_CENTER
1359
+ }
1360
+ videoOverlayImageView = overlayView
1361
+ preview.addView(overlayView, FrameLayout.LayoutParams(MATCH, MATCH))
1362
+
1363
+ val layerOverlay = LayerOverlayView(this)
1364
+ videoLayerOverlay = layerOverlay
1365
+ preview.addView(layerOverlay, FrameLayout.LayoutParams(MATCH, MATCH))
1366
+ layerOverlay.onLayerTapped = { id -> selectVideoLayer(id, session) }
1367
+ layerOverlay.onLayerTransformChanged = { id, x, y, scale, rotation ->
1368
+ if (videoDragStartSnapshot == null) videoDragStartSnapshot = session.layerStack.layers
1369
+ session.layerStack.updateLive { list ->
1370
+ list.map { if (it.id == id) it.copy(x = x, y = y, scale = scale, rotationDegrees = rotation) else it }
1371
+ }
1372
+ videoLayerOverlay.layers = session.layerStack.layers
1373
+ refreshVideoOverlayPreview(session)
1374
+ }
1375
+ layerOverlay.onLayerTransformEnded = {
1376
+ videoDragStartSnapshot?.let { session.layerStack.commitSnapshot(it) }
1377
+ videoDragStartSnapshot = null
1378
+ onVideoLayerStackChanged()
1379
+ }
1380
+
1381
+ val playPause = Button(this).apply {
1382
+ text = "Play"
1383
+ setTextColor(Color.WHITE)
1384
+ setBackgroundColor(Color.TRANSPARENT)
1385
+ setOnClickListener {
1386
+ if (session.player.isPlaying) session.player.pause() else session.player.play()
1387
+ }
1388
+ }
1389
+ playPauseButton = playPause
1390
+ val time = TextView(this).apply {
1391
+ text = "0:00 / 0:00"
1392
+ setTextColor(textColor)
1393
+ textSize = 12f
1394
+ }
1395
+ timeLabel = time
1396
+ val transport = LinearLayout(this).apply {
1397
+ orientation = LinearLayout.HORIZONTAL
1398
+ gravity = Gravity.CENTER_VERTICAL
1399
+ setBackgroundColor(toolbarColor)
1400
+ setPadding(dp(8), 0, dp(8), 0)
1401
+ addView(playPause, LinearLayout.LayoutParams(dp(72), WRAP))
1402
+ addView(time, LinearLayout.LayoutParams(WRAP, WRAP))
1403
+ }
1404
+ root.addView(transport, LinearLayout.LayoutParams(MATCH, dp(40)))
1405
+
1406
+ val seek = SeekBar(this).apply {
1407
+ max = 1000
1408
+ setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
1409
+ override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
1410
+ if (!fromUser || session.durationMs <= 0) return
1411
+ session.seekToGlobalMs(session.durationMs * progress / 1000)
1412
+ }
1413
+ override fun onStartTrackingTouch(seekBar: SeekBar?) { seeking = true }
1414
+ override fun onStopTrackingTouch(seekBar: SeekBar?) { seeking = false }
1415
+ })
1416
+ }
1417
+ positionSeekBar = seek
1418
+ root.addView(seek, LinearLayout.LayoutParams(MATCH, WRAP).apply { setMargins(dp(8), 0, dp(8), 0) })
1419
+
1420
+ val trim = TrimRangeView(this).apply {
1421
+ // Bound to the currently selected clip (Milestone 7): fractions are relative to that clip's
1422
+ // own `originalDurationMs`, not the composed multi-clip timeline.
1423
+ onRangeChanged = { startFraction, endFraction ->
1424
+ session.clips.getOrNull(selectedClipIndex)?.let { clip ->
1425
+ val newStart = (startFraction * clip.originalDurationMs).toLong()
1426
+ val newEnd = (endFraction * clip.originalDurationMs).toLong()
1427
+ session.updateClips { clips ->
1428
+ clips.mapIndexed { index, c -> if (index == selectedClipIndex) c.copy(trimStartMs = newStart, trimEndMs = newEnd) else c }
1429
+ }
1430
+ updateTimeLabel()
1431
+ refreshClipStrip(session)
1432
+ }
1433
+ }
1434
+ }
1435
+ trimRangeView = trim
1436
+ root.addView(trim, LinearLayout.LayoutParams(MATCH, dp(48)).apply { setMargins(dp(8), dp(4), dp(8), 0) })
1437
+
1438
+ val strip = LinearLayout(this).apply {
1439
+ orientation = LinearLayout.HORIZONTAL
1440
+ setPadding(dp(8), dp(4), dp(8), dp(4))
1441
+ }
1442
+ clipStripBar = strip
1443
+ root.addView(strip, LinearLayout.LayoutParams(MATCH, WRAP))
1444
+
1445
+ session.onClipsReady = {
1446
+ runOnUiThread {
1447
+ if (isFinishing || isDestroyed) return@runOnUiThread
1448
+ selectedClipIndex = 0
1449
+ updateTimeLabel()
1450
+ bindTrimViewToSelectedClip(session)
1451
+ refreshClipStrip(session)
1452
+ }
1453
+ }
1454
+ session.onPlaybackError = { message ->
1455
+ runOnUiThread {
1456
+ if (!isFinishing && !isDestroyed) {
1457
+ Toast.makeText(this, message, Toast.LENGTH_LONG).show()
1458
+ finishWithError(message, "E_VIDEO_PLAYBACK_FAILED")
1459
+ }
1460
+ }
1461
+ }
1462
+
1463
+ val aspectBar = createVideoAspectSubBar(session, playerView)
1464
+ aspectBar.visibility = View.GONE
1465
+ videoAspectSubBar = aspectBar
1466
+ root.addView(aspectBar, LinearLayout.LayoutParams(MATCH, dp(CROP_SUB_BAR_HEIGHT_DP)))
1467
+
1468
+ val filterSeek = SeekBar(this).apply {
1469
+ visibility = View.GONE
1470
+ setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
1471
+ override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
1472
+ if (!fromUser) return
1473
+ session.update { it.withFilter(activeVideoFilterKey, (progress - 100).toFloat()) }
1474
+ }
1475
+ override fun onStartTrackingTouch(seekBar: SeekBar?) {}
1476
+ override fun onStopTrackingTouch(seekBar: SeekBar?) {}
1477
+ })
1478
+ }
1479
+ videoFilterSeekBar = filterSeek
1480
+ root.addView(filterSeek, LinearLayout.LayoutParams(MATCH, WRAP).apply { setMargins(dp(8), dp(4), dp(8), 0) })
1481
+
1482
+ val filtersBar = createVideoFiltersSubBar(session)
1483
+ filtersBar.visibility = View.GONE
1484
+ videoFiltersSubBar = filtersBar
1485
+ root.addView(filtersBar, LinearLayout.LayoutParams(MATCH, dp(56)))
1486
+
1487
+ val layerPropertySeek = SeekBar(this).apply {
1488
+ visibility = View.GONE
1489
+ setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
1490
+ override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
1491
+ if (fromUser) onVideoLayerPropertyChanged(progress, session)
1492
+ }
1493
+ override fun onStartTrackingTouch(seekBar: SeekBar?) {}
1494
+ override fun onStopTrackingTouch(seekBar: SeekBar?) {}
1495
+ })
1496
+ }
1497
+ videoLayerPropertySeekBar = layerPropertySeek
1498
+ root.addView(layerPropertySeek, LinearLayout.LayoutParams(MATCH, WRAP).apply { setMargins(dp(8), dp(4), dp(8), 0) })
1499
+
1500
+ val layerBar = createVideoLayerToolBar(session)
1501
+ layerBar.visibility = View.GONE
1502
+ videoLayerToolBar = layerBar
1503
+ root.addView(layerBar, LinearLayout.LayoutParams(MATCH, dp(56)))
1504
+
1505
+ val toolbarHeight = dp(76)
1506
+ val toolbar = createVideoToolBar(session, playerView)
1507
+ videoToolBar = toolbar
1508
+ root.addView(toolbar, LinearLayout.LayoutParams(MATCH, toolbarHeight))
1509
+ applySystemBarInsets(
1510
+ header,
1511
+ headerHeight,
1512
+ listOf(toolbar to toolbarHeight, aspectBar to dp(CROP_SUB_BAR_HEIGHT_DP), filtersBar to dp(56), layerBar to dp(56))
1513
+ )
1514
+
1515
+ startPositionPolling(session)
1516
+ return outer
1517
+ }
1518
+
1519
+ private fun startPositionPolling(session: VideoEditSession) {
1520
+ val runnable = object : Runnable {
1521
+ override fun run() {
1522
+ if (!seeking && session.durationMs > 0) {
1523
+ positionSeekBar.progress = (session.globalPositionMs() * 1000 / session.durationMs).toInt().coerceIn(0, 1000)
1524
+ updateTimeLabel()
1525
+ }
1526
+ playPauseButton.text = if (session.player.isPlaying) "Pause" else "Play"
1527
+ refreshVideoOverlayPreview(session)
1528
+ positionPollHandler.postDelayed(this, 250)
1529
+ }
1530
+ }
1531
+ positionPollRunnable = runnable
1532
+ positionPollHandler.postDelayed(runnable, 250)
1533
+ }
1534
+
1535
+ private fun updateTimeLabel() {
1536
+ val session = videoSession ?: return
1537
+ timeLabel.text = "${formatMs(session.globalPositionMs())} / ${formatMs(session.durationMs)}"
1538
+ }
1539
+
1540
+ private fun formatMs(ms: Long): String {
1541
+ val totalSeconds = (ms / 1000).coerceAtLeast(0)
1542
+ val minutes = totalSeconds / 60
1543
+ val seconds = totalSeconds % 60
1544
+ return "%d:%02d".format(minutes, seconds)
1545
+ }
1546
+
1547
+ /**
1548
+ * Renders whichever overlays are active at the current playhead onto a
1549
+ * transparent bitmap sized to the video's own aspect ratio (so it letterboxes
1550
+ * identically to the `PlayerView` beneath it). Reuses [PhotoLayerRenderer]
1551
+ * unchanged — overlays are just [PhotoLayer]s with a time range.
1552
+ */
1553
+ private fun refreshVideoOverlayPreview(session: VideoEditSession) {
1554
+ val activeLayers = session.layerStack.layers.filter { it.isActiveAt(session.globalPositionMs(), session.durationMs) }
1555
+ videoLayerOverlay.layers = activeLayers
1556
+ videoLayerOverlay.setImageBounds(computeVideoLetterboxBounds(session, videoOverlayImageView))
1557
+ if (activeLayers.isEmpty()) {
1558
+ videoOverlayImageView.setImageBitmap(null)
1559
+ videoOverlayBitmap?.takeIf { !it.isRecycled }?.recycle()
1560
+ videoOverlayBitmap = null
1561
+ videoOverlayRenderKey = null
1562
+ return
1563
+ }
1564
+ val videoSize = session.player.videoSize
1565
+ val sourceWidth = videoSize.width.takeIf { it > 0 } ?: 1280
1566
+ val sourceHeight = videoSize.height.takeIf { it > 0 } ?: 720
1567
+ val previewScale = minOf(1f, 1280f / maxOf(sourceWidth, sourceHeight))
1568
+ val width = (sourceWidth * previewScale).roundToInt().coerceAtLeast(2)
1569
+ val height = (sourceHeight * previewScale).roundToInt().coerceAtLeast(2)
1570
+ val renderKey = "${session.layerStack.revision}:$width:$height:${activeLayers.joinToString { it.id }}"
1571
+ if (renderKey == videoOverlayRenderKey) return
1572
+ val transparent = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
1573
+ val rendered = PhotoLayerRenderer.render(transparent, activeLayers, ::resolveVideoImageUri)
1574
+ transparent.recycle()
1575
+ val previous = videoOverlayBitmap
1576
+ videoOverlayBitmap = rendered
1577
+ videoOverlayRenderKey = renderKey
1578
+ videoOverlayImageView.setImageBitmap(rendered)
1579
+ previous?.takeIf { it !== rendered && !it.isRecycled }?.recycle()
1580
+ }
1581
+
1582
+ /** Resolves a sticker-uri or overlay-uri layer's image URI for the video overlay preview, caching by URI. */
1583
+ private fun resolveVideoImageUri(uri: String): Bitmap? {
1584
+ videoImageLayerCache[uri]?.let { return it }
1585
+ if (videoImageLayerCache.containsKey(uri)) return null
1586
+ val path = SourceResolver.resolvePath(this, uri, "pve_video_layer_image")
1587
+ val bitmap = path?.let { BitmapFactory.decodeFile(it) }
1588
+ videoImageLayerCache[uri] = bitmap
1589
+ return bitmap
1590
+ }
1591
+
1592
+ /** Where the video actually renders within [view] under `FIT_CENTER`-style letterboxing (mirrors `ZoomableImageView.resetToFit`'s math). */
1593
+ private fun computeVideoLetterboxBounds(session: VideoEditSession, view: View): android.graphics.RectF {
1594
+ val viewWidth = view.width.toFloat()
1595
+ val viewHeight = view.height.toFloat()
1596
+ val videoSize = session.player.videoSize
1597
+ if (viewWidth <= 0 || viewHeight <= 0 || videoSize.width <= 0 || videoSize.height <= 0) {
1598
+ return android.graphics.RectF(0f, 0f, viewWidth, viewHeight)
1599
+ }
1600
+ val scale = minOf(viewWidth / videoSize.width, viewHeight / videoSize.height)
1601
+ val fittedWidth = videoSize.width * scale
1602
+ val fittedHeight = videoSize.height * scale
1603
+ val left = (viewWidth - fittedWidth) / 2f
1604
+ val top = (viewHeight - fittedHeight) / 2f
1605
+ return android.graphics.RectF(left, top, left + fittedWidth, top + fittedHeight)
1606
+ }
1607
+
1608
+ /** Mirrors [onLayerStackChanged] for the video overlay stack (Milestone 6). */
1609
+ private fun onVideoLayerStackChanged() {
1610
+ val session = videoSession ?: return
1611
+ if (selectedVideoLayerId != null && session.layerStack.layers.none { it.id == selectedVideoLayerId }) {
1612
+ selectedVideoLayerId = null
1613
+ }
1614
+ videoLayerOverlay.selectedLayerId = selectedVideoLayerId
1615
+ refreshVideoOverlayPreview(session)
1616
+ undoButton.isEnabled = session.layerStack.canUndo
1617
+ redoButton.isEnabled = session.layerStack.canRedo
1618
+ undoButton.alpha = if (session.layerStack.canUndo) 1f else 0.4f
1619
+ redoButton.alpha = if (session.layerStack.canRedo) 1f else 0.4f
1620
+ if (selectedVideoLayerId == null && videoLayerToolBar.visibility == View.VISIBLE) setVideoLayerToolBarVisible(false, session)
1621
+ }
1622
+
1623
+ private fun selectVideoLayer(id: String?, session: VideoEditSession) {
1624
+ selectedVideoLayerId = id
1625
+ videoLayerOverlay.selectedLayerId = id
1626
+ setVideoLayerToolBarVisible(id != null, session)
1627
+ }
1628
+
1629
+ private fun setVideoLayerToolBarVisible(visible: Boolean, session: VideoEditSession) {
1630
+ videoLayerToolBar.visibility = if (visible) View.VISIBLE else View.GONE
1631
+ videoLayerPropertySeekBar.visibility = if (visible) View.VISIBLE else View.GONE
1632
+ videoToolBar.visibility = if (visible) View.GONE else View.VISIBLE
1633
+ if (visible) syncVideoLayerPropertySeekBar(session)
1634
+ }
1635
+
1636
+ private fun currentSelectedVideoLayer(session: VideoEditSession): PhotoLayer? =
1637
+ session.layerStack.layers.firstOrNull { it.id == selectedVideoLayerId }
1638
+
1639
+ private fun syncVideoLayerPropertySeekBar(session: VideoEditSession) {
1640
+ val layer = currentSelectedVideoLayer(session) ?: return
1641
+ val (min, max, value) = when (activeVideoLayerPropertyKey) {
1642
+ "rotation" -> Triple(-180f, 180f, layer.rotationDegrees)
1643
+ "opacity" -> Triple(0f, 100f, layer.opacity * 100f)
1644
+ "start" -> Triple(0f, session.durationMs.toFloat(), layer.startMs.toFloat())
1645
+ "end" -> Triple(0f, session.durationMs.toFloat(), session.durationMs.effectiveEndOr(layer.endMs).toFloat())
1646
+ else -> Triple(20f, 800f, layer.scale * 100f)
1647
+ }
1648
+ videoLayerPropertySeekBar.max = (max - min).roundToInt()
1649
+ videoLayerPropertySeekBar.progress = (value - min).roundToInt()
1650
+ }
1651
+
1652
+ private fun Long.effectiveEndOr(endMs: Long): Long = if (endMs in 1..this) endMs else this
1653
+
1654
+ private fun onVideoLayerPropertyChanged(progress: Int, session: VideoEditSession) {
1655
+ val id = selectedVideoLayerId ?: return
1656
+ val min = when (activeVideoLayerPropertyKey) {
1657
+ "rotation" -> -180f
1658
+ "opacity", "start", "end" -> 0f
1659
+ else -> 20f
1660
+ }
1661
+ val value = min + progress
1662
+ session.layerStack.updateLive { list ->
1663
+ list.map { layer ->
1664
+ if (layer.id != id) return@map layer
1665
+ when (activeVideoLayerPropertyKey) {
1666
+ "rotation" -> layer.copy(rotationDegrees = value)
1667
+ "opacity" -> layer.copy(opacity = value / 100f)
1668
+ "start" -> layer.copy(startMs = value.toLong().coerceAtMost(layer.endMs.takeIf { it > 0 } ?: session.durationMs))
1669
+ "end" -> layer.copy(endMs = value.toLong().coerceAtLeast(layer.startMs))
1670
+ else -> layer.copy(scale = value / 100f)
1671
+ }
1672
+ }
1673
+ }
1674
+ refreshVideoOverlayPreview(session)
1675
+ }
1676
+
1677
+ private fun createVideoLayerToolBar(session: VideoEditSession): LinearLayout {
1678
+ val toolbarColor = themeColor("toolbarColor", DesignTokens.surfaceContainerLow)
1679
+ val textColor = themeColor("textColor", DesignTokens.onSurface)
1680
+ val primaryColor = themeColor("primaryColor", DesignTokens.primaryContainer)
1681
+ val properties = listOf(
1682
+ "scale" to "Scale", "rotation" to "Rotate", "opacity" to "Opacity", "start" to "Start", "end" to "End"
1683
+ )
1684
+ return LinearLayout(this).apply {
1685
+ gravity = Gravity.CENTER_VERTICAL
1686
+ setPadding(dp(12), 0, dp(12), 0)
1687
+ setBackgroundColor(toolbarColor)
1688
+ addView(HorizontalScrollView(this@PhotoVideoEditorActivity).apply {
1689
+ isHorizontalScrollBarEnabled = false
1690
+ addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
1691
+ orientation = LinearLayout.HORIZONTAL
1692
+ properties.forEach { (key, label) ->
1693
+ val chip = actionButton(label) {
1694
+ activeVideoLayerPropertyKey = key
1695
+ syncVideoLayerPropertySeekBar(session)
1696
+ refreshSelection(videoLayerPropertyButtons, activeVideoLayerPropertyKey, textColor, primaryColor)
1697
+ }.apply { setTextColor(textColor) }
1698
+ videoLayerPropertyButtons[key] = chip
1699
+ addView(chip, LinearLayout.LayoutParams(dp(72), MATCH))
1700
+ }
1701
+ addView(
1702
+ actionButton("Delete") {
1703
+ val id = selectedVideoLayerId ?: return@actionButton
1704
+ session.layerStack.commit { list -> list.filterNot { it.id == id } }
1705
+ selectVideoLayer(null, session)
1706
+ onVideoLayerStackChanged()
1707
+ }.apply { setTextColor(textColor) },
1708
+ LinearLayout.LayoutParams(dp(80), MATCH)
1709
+ )
1710
+ })
1711
+ }, LinearLayout.LayoutParams(0, MATCH, 1f))
1712
+ addView(
1713
+ actionButton("Done") { selectVideoLayer(null, session) }.apply { setTextColor(primaryColor) },
1714
+ LinearLayout.LayoutParams(dp(72), MATCH)
1715
+ )
1716
+ }
1717
+ }
1718
+
1719
+ private fun showVideoTextInputDialog(session: VideoEditSession) {
1720
+ val input = EditText(this).apply { inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE }
1721
+ AlertDialog.Builder(this)
1722
+ .setTitle("Add text")
1723
+ .setView(input)
1724
+ .setPositiveButton("Add") { _, _ ->
1725
+ val text = input.text?.toString().orEmpty()
1726
+ if (text.isNotBlank()) {
1727
+ val layer = PhotoLayer(type = LayerType.TEXT, text = text)
1728
+ session.layerStack.commit { it + layer }
1729
+ onVideoLayerStackChanged()
1730
+ selectVideoLayer(layer.id, session)
1731
+ }
1732
+ }
1733
+ .setNegativeButton("Cancel", null)
1734
+ .show()
1735
+ }
1736
+
1737
+ private fun createVideoToolBar(session: VideoEditSession, playerView: PlayerView): LinearLayout {
1738
+ val backgroundColor = themeColor("toolbarColor", DesignTokens.withAlphaPercent(DesignTokens.surfaceContainerLowest, 95))
1739
+ return LinearLayout(this).apply {
1740
+ orientation = LinearLayout.VERTICAL
1741
+ setBackgroundColor(backgroundColor)
1742
+ setPadding(dp(DesignTokens.spaceXs), dp(DesignTokens.spaceSm), dp(DesignTokens.spaceXs), dp(DesignTokens.spaceLg))
1743
+
1744
+ addView(
1745
+ View(this@PhotoVideoEditorActivity).apply { background = roundedDrawable(DesignTokens.surfaceVariant, DesignTokens.radiusSm) },
1746
+ LinearLayout.LayoutParams(dp(36), dp(4)).apply { gravity = Gravity.CENTER_HORIZONTAL; bottomMargin = dp(DesignTokens.spaceXs) }
1747
+ )
1748
+
1749
+ val features = request.optJSONObject("features")
1750
+ val tools = listOf(
1751
+ Triple("cover", "Cover", R.drawable.ic_movie),
1752
+ Triple("speed", "Speed 1x", R.drawable.ic_speed),
1753
+ Triple("crop", "Crop", R.drawable.ic_crop),
1754
+ Triple("filters", "Filters", R.drawable.ic_photo_filter),
1755
+ Triple("text", "Text", R.drawable.ic_title),
1756
+ Triple("stickers", "Stickers", R.drawable.ic_sentiment_satisfied),
1757
+ Triple("overlay", "Overlay", R.drawable.ic_wallpaper)
1758
+ )
1759
+ addView(HorizontalScrollView(this@PhotoVideoEditorActivity).apply {
1760
+ isHorizontalScrollBarEnabled = false
1761
+ addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
1762
+ orientation = LinearLayout.HORIZONTAL
1763
+ gravity = Gravity.CENTER_VERTICAL
1764
+ setPadding(dp(DesignTokens.spaceSm), dp(DesignTokens.spaceXs), dp(DesignTokens.spaceSm), dp(DesignTokens.spaceXs))
1765
+ tools.filter { features?.optBoolean(it.first, true) != false }.forEach { (key, label, iconRes) ->
1766
+ val node = createToolNode(iconRes, label) { onVideoToolTapped(key, session, playerView) }
1767
+ videoToolButtons[key] = node
1768
+ addView(node.root, LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), WRAP).apply { marginEnd = dp(DesignTokens.spaceSm) })
1769
+ }
1770
+ })
1771
+ }, LinearLayout.LayoutParams(MATCH, WRAP))
1772
+ }
1773
+ }
1774
+
1775
+ private fun refreshVideoToolSelection(session: VideoEditSession) {
1776
+ val textColor = themeColor("textColor", DesignTokens.onSurface)
1777
+ videoToolButtons.forEach { (key, node) ->
1778
+ val selected = when (key) {
1779
+ "crop" -> videoAspectMode
1780
+ "filters" -> videoFiltersMode
1781
+ else -> false
1782
+ }
1783
+ setToolNodeSelected(node, selected, textColor)
1784
+ }
1785
+ }
1786
+
1787
+ private fun onVideoToolTapped(key: String, session: VideoEditSession, playerView: PlayerView) {
1788
+ when (key) {
1789
+ "cover" -> {
1790
+ val globalMs = session.globalPositionMs()
1791
+ session.update { it.withCoverFrame(globalMs) }
1792
+ Toast.makeText(this, "Cover frame set at ${formatMs(globalMs)}", Toast.LENGTH_SHORT).show()
1793
+ }
1794
+ "rotate" -> {
1795
+ session.update { it.rotatedRight() }
1796
+ playerView.rotation = session.state.rotationDegrees.toFloat()
1797
+ }
1798
+ "crop" -> setVideoAspectMode(true, session)
1799
+ "speed" -> {
1800
+ session.update { it.cycledSpeed() }
1801
+ session.player.setPlaybackSpeed(session.state.speed)
1802
+ videoToolButtons["speed"]?.label?.text = formatSpeedLabel(session.state.speed)
1803
+ }
1804
+ "text" -> showVideoTextInputDialog(session)
1805
+ "stickers" -> showVideoStickerPicker(session)
1806
+ "overlay" -> launchOverlayPicker(REQUEST_CODE_VIDEO_OVERLAY_UPLOAD)
1807
+ "filters" -> setVideoFiltersMode(true, session)
1808
+ else -> Toast.makeText(this, "$key is coming in a later milestone.", Toast.LENGTH_SHORT).show()
1809
+ }
1810
+ }
1811
+
1812
+ private fun showVideoStickerPicker(session: VideoEditSession) {
1813
+ val consumerAssets = request.optJSONArray("stickerAssets")
1814
+ val builtin = PhotoLayerRenderer.builtinStickerIds()
1815
+ val consumerCount = consumerAssets?.length() ?: 0
1816
+ val features = request.optJSONObject("features")
1817
+ val onlineStickersEnabled = features?.optBoolean("onlineStickers", true) != false
1818
+ val labels = (
1819
+ builtin.map { PhotoLayerRenderer.glyphFor(it) } +
1820
+ (0 until consumerCount).map { i -> consumerAssets?.optJSONObject(i)?.optString("id", "asset$i") ?: "asset$i" } +
1821
+ listOf("Upload from device") +
1822
+ (if (onlineStickersEnabled) listOf("Browse online") else emptyList())
1823
+ ).toTypedArray()
1824
+ AlertDialog.Builder(this)
1825
+ .setTitle("Add sticker")
1826
+ .setItems(labels) { _, index ->
1827
+ when {
1828
+ index < builtin.size -> {
1829
+ val layer = PhotoLayer(type = LayerType.STICKER, stickerId = builtin[index])
1830
+ session.layerStack.commit { it + layer }
1831
+ onVideoLayerStackChanged()
1832
+ selectVideoLayer(layer.id, session)
1833
+ }
1834
+ index < builtin.size + consumerCount -> {
1835
+ val asset = consumerAssets?.optJSONObject(index - builtin.size)
1836
+ val uri = asset?.optString("uri").takeUnless { it.isNullOrBlank() } ?: return@setItems
1837
+ val layer = PhotoLayer(type = LayerType.STICKER, stickerUri = uri)
1838
+ session.layerStack.commit { it + layer }
1839
+ onVideoLayerStackChanged()
1840
+ selectVideoLayer(layer.id, session)
1841
+ }
1842
+ index == builtin.size + consumerCount -> launchOverlayPicker(REQUEST_CODE_VIDEO_STICKER_UPLOAD)
1843
+ else -> OnlineStickerSheet(this) { path ->
1844
+ val layer = PhotoLayer(type = LayerType.STICKER, stickerUri = Uri.fromFile(File(path)).toString())
1845
+ session.layerStack.commit { it + layer }
1846
+ onVideoLayerStackChanged()
1847
+ selectVideoLayer(layer.id, session)
1848
+ }.show()
1849
+ }
1850
+ }
1851
+ .show()
1852
+ }
1853
+
1854
+ private fun formatSpeedLabel(speed: Float): String {
1855
+ val trimmed = if (speed == speed.toInt().toFloat()) speed.toInt().toString() else speed.toString()
1856
+ return "Speed ${trimmed}x"
1857
+ }
1858
+
1859
+ private fun setVideoAspectMode(enabled: Boolean, session: VideoEditSession) {
1860
+ videoAspectMode = enabled
1861
+ videoAspectSubBar.visibility = if (enabled) View.VISIBLE else View.GONE
1862
+ videoToolBar.visibility = if (enabled) View.GONE else View.VISIBLE
1863
+ refreshVideoToolSelection(session)
1864
+ refreshSelection(videoAspectButtons, session.state.aspectRatio, themeColor("textColor", DesignTokens.onSurface), themeColor("primaryColor", DesignTokens.primaryContainer))
1865
+ }
1866
+
1867
+ private fun createVideoAspectSubBar(session: VideoEditSession, playerView: PlayerView): LinearLayout {
1868
+ val toolbarColor = themeColor("toolbarColor", DesignTokens.surfaceContainerLow)
1869
+ val textColor = themeColor("textColor", DesignTokens.onSurface)
1870
+ val primaryColor = themeColor("primaryColor", DesignTokens.primaryContainer)
1871
+ val presets = listOf<Pair<String, Float?>>(
1872
+ "Original" to null, "1:1" to 1f, "4:5" to 4f / 5f, "9:16" to 9f / 16f, "16:9" to 16f / 9f
1873
+ )
1874
+ return LinearLayout(this).apply {
1875
+ orientation = LinearLayout.VERTICAL
1876
+ setBackgroundColor(toolbarColor)
1877
+
1878
+ // Rotate row — mirrors the photo Crop sub-bar's treatment.
1879
+ addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
1880
+ orientation = LinearLayout.HORIZONTAL
1881
+ gravity = Gravity.CENTER
1882
+ addView(
1883
+ createIconButton(R.drawable.ic_rotate_right, "Rotate right", textColor) {
1884
+ session.update { it.rotatedRight() }
1885
+ playerView.rotation = session.state.rotationDegrees.toFloat()
1886
+ },
1887
+ LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin))
1888
+ )
1889
+ }, LinearLayout.LayoutParams(MATCH, dp(DesignTokens.touchTargetMin)))
1890
+
1891
+ addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
1892
+ gravity = Gravity.CENTER_VERTICAL
1893
+ setPadding(dp(12), 0, dp(12), 0)
1894
+ addView(HorizontalScrollView(this@PhotoVideoEditorActivity).apply {
1895
+ isHorizontalScrollBarEnabled = false
1896
+ addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
1897
+ orientation = LinearLayout.HORIZONTAL
1898
+ presets.forEach { (label, ratio) ->
1899
+ val chip = actionButton(label) {
1900
+ session.update { it.withAspectRatio(ratio) }
1901
+ Toast.makeText(this@PhotoVideoEditorActivity, "Export aspect: $label", Toast.LENGTH_SHORT).show()
1902
+ refreshSelection(videoAspectButtons, ratio, textColor, primaryColor)
1903
+ }.apply { setTextColor(textColor) }
1904
+ videoAspectButtons[ratio] = chip
1905
+ addView(chip, LinearLayout.LayoutParams(dp(84), MATCH))
1906
+ }
1907
+ })
1908
+ }, LinearLayout.LayoutParams(0, MATCH, 1f))
1909
+ addView(
1910
+ actionButton("Done") { setVideoAspectMode(false, session) }.apply { setTextColor(primaryColor) },
1911
+ LinearLayout.LayoutParams(dp(72), MATCH)
1912
+ )
1913
+ }, LinearLayout.LayoutParams(MATCH, dp(56)))
1914
+ }
1915
+ }
1916
+
1917
+ private fun setVideoFiltersMode(enabled: Boolean, session: VideoEditSession) {
1918
+ videoFiltersMode = enabled
1919
+ videoFiltersSubBar.visibility = if (enabled) View.VISIBLE else View.GONE
1920
+ videoFilterSeekBar.visibility = if (enabled) View.VISIBLE else View.GONE
1921
+ videoToolBar.visibility = if (enabled) View.GONE else View.VISIBLE
1922
+ refreshVideoToolSelection(session)
1923
+ if (enabled) syncVideoFilterSeekBar(session)
1924
+ }
1925
+
1926
+ private fun syncVideoFilterSeekBar(session: VideoEditSession) {
1927
+ videoFilterSeekBar.max = 200
1928
+ videoFilterSeekBar.progress = (session.state.filterValue(activeVideoFilterKey) + 100f).roundToInt()
1929
+ }
1930
+
1931
+ private fun createVideoFiltersSubBar(session: VideoEditSession): LinearLayout {
1932
+ val toolbarColor = themeColor("toolbarColor", DesignTokens.surfaceContainerLow)
1933
+ val textColor = themeColor("textColor", DesignTokens.onSurface)
1934
+ val primaryColor = themeColor("primaryColor", DesignTokens.primaryContainer)
1935
+ val filters = listOf("brightness" to "Brightness", "contrast" to "Contrast", "saturation" to "Saturation")
1936
+ return LinearLayout(this).apply {
1937
+ gravity = Gravity.CENTER_VERTICAL
1938
+ setPadding(dp(12), 0, dp(12), 0)
1939
+ setBackgroundColor(toolbarColor)
1940
+ addView(HorizontalScrollView(this@PhotoVideoEditorActivity).apply {
1941
+ isHorizontalScrollBarEnabled = false
1942
+ addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
1943
+ orientation = LinearLayout.HORIZONTAL
1944
+ filters.forEach { (key, label) ->
1945
+ val chip = actionButton(label) {
1946
+ activeVideoFilterKey = key
1947
+ syncVideoFilterSeekBar(session)
1948
+ refreshSelection(videoFilterButtons, activeVideoFilterKey, textColor, primaryColor)
1949
+ }.apply { setTextColor(textColor) }
1950
+ videoFilterButtons[key] = chip
1951
+ addView(chip, LinearLayout.LayoutParams(dp(84), MATCH))
1952
+ }
1953
+ addView(
1954
+ actionButton("Reset") {
1955
+ session.update { it.withFiltersReset() }
1956
+ syncVideoFilterSeekBar(session)
1957
+ }.apply { setTextColor(textColor) },
1958
+ LinearLayout.LayoutParams(dp(72), MATCH)
1959
+ )
1960
+ })
1961
+ }, LinearLayout.LayoutParams(0, MATCH, 1f))
1962
+ addView(
1963
+ actionButton("Done") { setVideoFiltersMode(false, session) }.apply { setTextColor(primaryColor) },
1964
+ LinearLayout.LayoutParams(dp(72), MATCH)
1965
+ )
1966
+ }
1967
+ }
1968
+
1969
+ private fun exportVideo() {
1970
+ val session = videoSession
1971
+ if (session == null) {
1972
+ finishWithError("Nothing to export.", "E_INTERNAL")
1973
+ return
1974
+ }
1975
+ session.player.pause()
1976
+ videoExportProgressView.visibility = View.VISIBLE
1977
+ val exporter = VideoExporter(applicationContext)
1978
+ videoExporter = exporter
1979
+ try {
1980
+ exporter.export(
1981
+ clips = session.clips,
1982
+ state = session.state,
1983
+ layers = session.layerStack.layers,
1984
+ exportOptions = request.optJSONObject("export"),
1985
+ onProgress = { /* Progress UI is a spinner for now; see docs/video-editor.md. */ },
1986
+ onComplete = { result ->
1987
+ runOnUiThread {
1988
+ videoExporter = null
1989
+ videoExportProgressView.visibility = View.GONE
1990
+ val intent = Intent()
1991
+ .putExtra(EXTRA_URI, result.uri)
1992
+ .putExtra(EXTRA_TYPE, mediaType)
1993
+ .putExtra(EXTRA_MIME_TYPE, result.mimeType)
1994
+ .putExtra(EXTRA_WIDTH, result.width)
1995
+ .putExtra(EXTRA_HEIGHT, result.height)
1996
+ .putExtra(EXTRA_FILE_SIZE, result.fileSize)
1997
+ .putExtra(EXTRA_DURATION, result.durationMs)
1998
+ setResult(RESULT_OK, intent)
1999
+ finish()
2000
+ }
2001
+ },
2002
+ onError = { error ->
2003
+ runOnUiThread {
2004
+ videoExporter = null
2005
+ videoExportProgressView.visibility = View.GONE
2006
+ Toast.makeText(this, error.message ?: "Unable to export the video.", Toast.LENGTH_LONG).show()
2007
+ }
2008
+ }
2009
+ )
2010
+ } catch (error: Exception) {
2011
+ videoExporter = null
2012
+ videoExportProgressView.visibility = View.GONE
2013
+ Toast.makeText(this, error.message ?: "Unable to export the video.", Toast.LENGTH_LONG).show()
2014
+ }
2015
+ }
2016
+
2017
+ private fun cancelVideoExport() {
2018
+ videoExporter?.cancel()
2019
+ videoExporter = null
2020
+ videoExportProgressView.visibility = View.GONE
2021
+ }
2022
+
2023
+ // ---------------------------------------------------------------------
2024
+ // Multi-clip timeline (Milestone 7): clip strip UI plus split/duplicate/
2025
+ // delete/reorder, all operating on `session.clips` via `session.updateClips`.
2026
+ // ---------------------------------------------------------------------
2027
+
2028
+ /** Rebinds the trim range view to whichever clip is currently selected. */
2029
+ private fun bindTrimViewToSelectedClip(session: VideoEditSession) {
2030
+ val clip = session.clips.getOrNull(selectedClipIndex) ?: return
2031
+ val duration = clip.originalDurationMs.coerceAtLeast(1)
2032
+ trimRangeView.setRange(
2033
+ clip.trimStartMs.toFloat() / duration,
2034
+ clip.effectiveTrimEndMs().toFloat() / duration
2035
+ )
2036
+ timelineThumbnailRequest?.cancel()
2037
+ trimRangeView.thumbnails = emptyList()
2038
+ val frames = arrayOfNulls<Bitmap>(TIMELINE_THUMBNAIL_COUNT)
2039
+ val thumbnailRepository = timelineThumbnailRepository
2040
+ ?: TimelineThumbnailRepository(applicationContext).also { timelineThumbnailRepository = it }
2041
+ timelineThumbnailRequest = thumbnailRepository.load(
2042
+ mediaId = clip.sourceUri,
2043
+ uri = clip.sourceUri.toMediaUri(),
2044
+ durationMs = clip.originalDurationMs,
2045
+ count = TIMELINE_THUMBNAIL_COUNT,
2046
+ width = dp(72),
2047
+ height = dp(48),
2048
+ onThumbnail = { index, bitmap ->
2049
+ runOnUiThread {
2050
+ if (!isFinishing && session.clips.getOrNull(selectedClipIndex)?.id == clip.id) {
2051
+ frames[index] = bitmap
2052
+ trimRangeView.thumbnails = frames.filterNotNull()
2053
+ }
2054
+ }
2055
+ },
2056
+ onComplete = {
2057
+ runOnUiThread {
2058
+ if (session.clips.getOrNull(selectedClipIndex)?.id == clip.id) {
2059
+ timelineThumbnailRequest = null
2060
+ }
2061
+ }
2062
+ }
2063
+ )
2064
+ }
2065
+
2066
+ private fun String.toMediaUri(): Uri = Uri.parse(this).let { parsed ->
2067
+ if (parsed.scheme == null) Uri.fromFile(File(this)) else parsed
2068
+ }
2069
+
2070
+ /** Rebuilds the clip strip: one chip per clip plus Split/Duplicate/Delete/Move-Left/Move-Right actions. */
2071
+ private fun refreshClipStrip(session: VideoEditSession) {
2072
+ val textColor = themeColor("textColor", DesignTokens.onSurface)
2073
+ val primaryColor = themeColor("primaryColor", DesignTokens.primaryContainer)
2074
+ clipStripBar.removeAllViews()
2075
+ session.clips.forEachIndexed { index, clip ->
2076
+ val chip = Button(this).apply {
2077
+ text = "Clip ${index + 1}\n${formatMs(clip.trimmedDurationMs())}"
2078
+ textSize = 11f
2079
+ isAllCaps = false
2080
+ setButtonSelected(this, index == selectedClipIndex, textColor, primaryColor)
2081
+ setOnClickListener {
2082
+ selectedClipIndex = index
2083
+ session.seekToGlobalMs(session.clips.take(index).sumOf { it.trimmedDurationMs() })
2084
+ bindTrimViewToSelectedClip(session)
2085
+ refreshClipStrip(session)
2086
+ }
2087
+ }
2088
+ clipStripBar.addView(chip, LinearLayout.LayoutParams(dp(88), WRAP))
2089
+ }
2090
+ clipStripBar.addView(
2091
+ actionButton("Split") { splitSelectedClip(session) }.apply { setTextColor(textColor); textSize = 12f },
2092
+ LinearLayout.LayoutParams(dp(64), WRAP)
2093
+ )
2094
+ clipStripBar.addView(
2095
+ actionButton("Copy") { duplicateSelectedClip(session) }.apply { setTextColor(textColor); textSize = 12f },
2096
+ LinearLayout.LayoutParams(dp(64), WRAP)
2097
+ )
2098
+ clipStripBar.addView(
2099
+ actionButton("Delete") { deleteSelectedClip(session) }.apply { setTextColor(textColor); textSize = 12f },
2100
+ LinearLayout.LayoutParams(dp(64), WRAP)
2101
+ )
2102
+ clipStripBar.addView(
2103
+ actionButton("<") { moveSelectedClip(session, -1) }.apply { setTextColor(textColor); textSize = 12f },
2104
+ LinearLayout.LayoutParams(dp(40), WRAP)
2105
+ )
2106
+ clipStripBar.addView(
2107
+ actionButton(">") { moveSelectedClip(session, 1) }.apply { setTextColor(textColor); textSize = 12f },
2108
+ LinearLayout.LayoutParams(dp(40), WRAP)
2109
+ )
2110
+ }
2111
+
2112
+ /** Splits the selected clip at the current playhead into two clips sharing the same source. */
2113
+ private fun splitSelectedClip(session: VideoEditSession) {
2114
+ val index = selectedClipIndex
2115
+ val clip = session.clips.getOrNull(index) ?: return
2116
+ val precedingMs = session.clips.take(index).sumOf { it.trimmedDurationMs() }
2117
+ val splitAtWithinClipMs = (session.globalPositionMs() - precedingMs).coerceIn(0, clip.trimmedDurationMs())
2118
+ val splitSourceMs = clip.trimStartMs + splitAtWithinClipMs
2119
+ // Refuse to create a zero-length clip on either side of the cut.
2120
+ if (splitSourceMs <= clip.trimStartMs + 50 || splitSourceMs >= clip.effectiveTrimEndMs() - 50) {
2121
+ Toast.makeText(this, "Move the playhead into the clip first to split it there.", Toast.LENGTH_SHORT).show()
2122
+ return
2123
+ }
2124
+ session.updateClips { clips ->
2125
+ clips.flatMapIndexed { i, c ->
2126
+ if (i != index) listOf(c)
2127
+ else listOf(
2128
+ c.copy(trimEndMs = splitSourceMs),
2129
+ VideoClip(sourceUri = c.sourceUri, originalDurationMs = c.originalDurationMs, trimStartMs = splitSourceMs, trimEndMs = c.effectiveTrimEndMs())
2130
+ )
2131
+ }
2132
+ }
2133
+ refreshClipStrip(session)
2134
+ }
2135
+
2136
+ /** Duplicates the selected clip immediately after itself. */
2137
+ private fun duplicateSelectedClip(session: VideoEditSession) {
2138
+ val index = selectedClipIndex
2139
+ val clip = session.clips.getOrNull(index) ?: return
2140
+ session.updateClips { clips ->
2141
+ clips.flatMapIndexed { i, c -> if (i == index) listOf(c, c.copy(id = java.util.UUID.randomUUID().toString())) else listOf(c) }
2142
+ }
2143
+ selectedClipIndex = index + 1
2144
+ bindTrimViewToSelectedClip(session)
2145
+ refreshClipStrip(session)
2146
+ }
2147
+
2148
+ /** Deletes the selected clip, refusing to leave the timeline empty. */
2149
+ private fun deleteSelectedClip(session: VideoEditSession) {
2150
+ if (session.clips.size <= 1) {
2151
+ Toast.makeText(this, "A video needs at least one clip.", Toast.LENGTH_SHORT).show()
2152
+ return
2153
+ }
2154
+ val index = selectedClipIndex
2155
+ session.updateClips { clips -> clips.filterIndexed { i, _ -> i != index } }
2156
+ selectedClipIndex = selectedClipIndex.coerceIn(0, session.clips.size - 1)
2157
+ bindTrimViewToSelectedClip(session)
2158
+ refreshClipStrip(session)
2159
+ }
2160
+
2161
+ /** Moves the selected clip one position left (-1) or right (+1) in the timeline. */
2162
+ private fun moveSelectedClip(session: VideoEditSession, direction: Int) {
2163
+ val index = selectedClipIndex
2164
+ val target = index + direction
2165
+ if (target < 0 || target >= session.clips.size) return
2166
+ session.updateClips { clips ->
2167
+ clips.toMutableList().apply { add(target, removeAt(index)) }
2168
+ }
2169
+ selectedClipIndex = target
2170
+ refreshClipStrip(session)
2171
+ }
2172
+
2173
+ // ---------------------------------------------------------------------
2174
+ // Shared shell: header, insets, lifecycle result delivery.
2175
+ // ---------------------------------------------------------------------
2176
+
2177
+ /**
2178
+ * targetSdk 35+ enforces edge-to-edge, so the window draws behind the status
2179
+ * and gesture/nav bars. Grow the header/toolbar height by the inset and pad
2180
+ * that extra space, so the fixed-size content inside is never obscured or
2181
+ * squeezed by the system bars.
2182
+ *
2183
+ * [bottomBars] must list *every* bottom-row bar that can be the last visible
2184
+ * child of the root column — the main toolbar plus every tool's sub-bar
2185
+ * (crop/filters/stickers/draw/layers/video-aspect). Only one is visible at a
2186
+ * time, but each needs its own inset listener applied up front: Android
2187
+ * dispatches insets to every view in the hierarchy regardless of visibility,
2188
+ * so whichever bar becomes visible when a tool is selected already has the
2189
+ * correct bottom padding/height baked in, instead of sitting under the
2190
+ * gesture nav bar until the *next* inset change happens to reach it.
2191
+ */
2192
+ private fun applySystemBarInsets(header: View, headerHeight: Int, bottomBars: List<Pair<View, Int>>) {
2193
+ val headerBase = intArrayOf(header.paddingLeft, header.paddingRight)
2194
+ ViewCompat.setOnApplyWindowInsetsListener(header) { view, insets ->
2195
+ val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
2196
+ view.layoutParams = (view.layoutParams as LinearLayout.LayoutParams).apply { height = headerHeight + bars.top }
2197
+ view.setPadding(headerBase[0] + bars.left, bars.top, headerBase[1] + bars.right, 0)
2198
+ insets
2199
+ }
2200
+ bottomBars.forEach { (bar, baseHeight) ->
2201
+ val base = intArrayOf(bar.paddingLeft, bar.paddingRight, bar.paddingTop)
2202
+ ViewCompat.setOnApplyWindowInsetsListener(bar) { view, insets ->
2203
+ val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
2204
+ view.layoutParams = (view.layoutParams as LinearLayout.LayoutParams).apply { height = baseHeight + bars.bottom }
2205
+ view.setPadding(base[0] + bars.left, base[2], base[1] + bars.right, bars.bottom)
2206
+ insets
2207
+ }
2208
+ }
2209
+ }
2210
+
2211
+ private fun createTopBar(): LinearLayout {
2212
+ val textColor = themeColor("textColor", DesignTokens.onSurface)
2213
+ val primaryColor = themeColor("primaryColor", DesignTokens.primaryContainer)
2214
+ val onPrimaryColor = themeColor("onPrimaryColor", DesignTokens.onPrimaryContainer)
2215
+ val toolbarColor = themeColor("toolbarColor", DesignTokens.withAlphaPercent(DesignTokens.surfaceDim, 95))
2216
+ return LinearLayout(this).apply {
2217
+ gravity = Gravity.CENTER_VERTICAL
2218
+ setPadding(dp(DesignTokens.spaceLg), 0, dp(DesignTokens.spaceLg), 0)
2219
+ setBackgroundColor(toolbarColor)
2220
+
2221
+ addView(
2222
+ createIconButton(R.drawable.ic_close, "Close editor", textColor) { confirmDiscardChanges() }.apply {
2223
+ background = circleDrawable(DesignTokens.surfaceContainerLow)
2224
+ },
2225
+ LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin))
2226
+ )
2227
+
2228
+ addView(TextView(this@PhotoVideoEditorActivity).apply {
2229
+ text = if (mediaType == "photo") "Photo Editor" else "Video Editor"
2230
+ textSize = 16f
2231
+ gravity = Gravity.CENTER
2232
+ setTextColor(textColor)
2233
+ setTypeface(typeface, android.graphics.Typeface.BOLD)
2234
+ }, LinearLayout.LayoutParams(0, MATCH, 1f))
2235
+
2236
+ val undo = createIconButton(R.drawable.ic_undo, "Undo", textColor) {
2237
+ if (mediaType == "photo") { photoSession?.layerStack?.undo(); onLayerStackChanged() }
2238
+ else { videoSession?.layerStack?.undo(); onVideoLayerStackChanged() }
2239
+ }
2240
+ val redo = createIconButton(R.drawable.ic_redo, "Redo", textColor) {
2241
+ if (mediaType == "photo") { photoSession?.layerStack?.redo(); onLayerStackChanged() }
2242
+ else { videoSession?.layerStack?.redo(); onVideoLayerStackChanged() }
2243
+ }
2244
+ undoButton = undo
2245
+ redoButton = redo
2246
+ addView(undo, LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin)))
2247
+ addView(redo, LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin)))
2248
+
2249
+ if (mediaType == "photo") {
2250
+ addView(createIconButton(R.drawable.ic_compare, "Hold to compare with original", textColor) {}.apply {
2251
+ setOnTouchListener { _, event ->
2252
+ when (event.actionMasked) {
2253
+ MotionEvent.ACTION_DOWN -> showOriginalPreview(true)
2254
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> showOriginalPreview(false)
2255
+ }
2256
+ true
2257
+ }
2258
+ }, LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin)))
2259
+ }
2260
+
2261
+ addView(
2262
+ editorPrimaryButton(this@PhotoVideoEditorActivity, "Export", R.drawable.ic_share, primaryColor, onPrimaryColor) {
2263
+ if (mediaType == "photo") showPhotoExportConfiguration() else finishDone()
2264
+ },
2265
+ LinearLayout.LayoutParams(WRAP, dp(DesignTokens.touchTargetMin)).apply { marginStart = dp(DesignTokens.spaceXs) }
2266
+ )
2267
+ }
2268
+ }
2269
+
2270
+ private fun showOriginalPreview(show: Boolean) {
2271
+ if (!::photoImageView.isInitialized || comparingOriginal == show) return
2272
+ comparingOriginal = show
2273
+ val session = photoSession ?: return
2274
+ if (show) photoImageView.setImageBitmap(session.baseBitmap) else schedulePhotoPreviewRender()
2275
+ }
2276
+
2277
+ private fun showPhotoExportConfiguration() {
2278
+ val configured = request.optJSONObject("export")
2279
+ val formats = arrayOf("JPEG", "PNG", "WebP")
2280
+ val current = when (configured?.optString("imageFormat", "jpeg")) { "png" -> 1; "webp" -> 2; else -> 0 }
2281
+ var selected = current
2282
+ AlertDialog.Builder(this)
2283
+ .setTitle("Export & Share")
2284
+ .setMessage("Choose the output format. Quality and size limits continue to use your export settings.")
2285
+ .setSingleChoiceItems(formats, current) { _, which -> selected = which }
2286
+ .setNegativeButton("Cancel", null)
2287
+ .setPositiveButton("Export") { _, _ ->
2288
+ val output = configured ?: JSONObject().also { request.put("export", it) }
2289
+ output.put("imageFormat", arrayOf("jpeg", "png", "webp")[selected])
2290
+ finishDone()
2291
+ }
2292
+ .show()
2293
+ }
2294
+
2295
+ /** 44x44dp icon-only button, matching the Studio Violet "Tool Icon Button" spec. */
2296
+ private fun createIconButton(iconRes: Int, contentDescriptionText: String, tint: Int, onClick: () -> Unit): ImageButton {
2297
+ return ImageButton(this).apply {
2298
+ setImageResource(iconRes)
2299
+ contentDescription = contentDescriptionText
2300
+ background = null
2301
+ scaleType = ImageView.ScaleType.CENTER_INSIDE
2302
+ setColorFilter(tint)
2303
+ setPadding(dp(11), dp(11), dp(11), dp(11))
2304
+ setOnClickListener { onClick() }
2305
+ applyPressScale()
2306
+ }
2307
+ }
2308
+
2309
+ private fun roundedDrawable(color: Int, radiusDp: Int): android.graphics.drawable.GradientDrawable =
2310
+ android.graphics.drawable.GradientDrawable().apply {
2311
+ cornerRadius = dp(radiusDp).toFloat()
2312
+ setColor(color)
2313
+ }
2314
+
2315
+ private fun circleDrawable(color: Int): android.graphics.drawable.GradientDrawable =
2316
+ android.graphics.drawable.GradientDrawable().apply {
2317
+ shape = android.graphics.drawable.GradientDrawable.OVAL
2318
+ setColor(color)
2319
+ }
2320
+
2321
+ /** Refreshes the preview + selection + undo/redo enabled state after any layer-stack change. */
2322
+ private fun onLayerStackChanged() {
2323
+ val session = photoSession ?: return
2324
+ if (selectedLayerId != null && session.layerStack.layers.none { it.id == selectedLayerId }) {
2325
+ selectedLayerId = null
2326
+ }
2327
+ layerOverlay.layers = session.layerStack.layers
2328
+ layerOverlay.selectedLayerId = selectedLayerId
2329
+ schedulePhotoPreviewRender()
2330
+ undoButton.isEnabled = session.layerStack.canUndo
2331
+ redoButton.isEnabled = session.layerStack.canRedo
2332
+ undoButton.alpha = if (session.layerStack.canUndo) 1f else 0.4f
2333
+ redoButton.alpha = if (session.layerStack.canRedo) 1f else 0.4f
2334
+ if (selectedLayerId == null && layerToolBar.visibility == View.VISIBLE) setLayerToolBarVisible(false)
2335
+ }
2336
+
2337
+ /** Delegates expensive rendering to a coalescing background feature component. */
2338
+ private fun schedulePhotoPreviewRender() {
2339
+ if (!::photoImageView.isInitialized) return
2340
+ photoPreviewRenderer?.request()
2341
+ }
2342
+
2343
+ private fun actionButton(label: String, action: () -> Unit) = Button(this).apply {
2344
+ text = label
2345
+ textSize = 13f
2346
+ isAllCaps = false
2347
+ minHeight = dp(DesignTokens.touchTargetMin)
2348
+ minimumHeight = dp(DesignTokens.touchTargetMin)
2349
+ setPadding(dp(DesignTokens.spaceMd), 0, dp(DesignTokens.spaceMd), 0)
2350
+ setTextColor(DesignTokens.onSurface)
2351
+ background = roundedDrawable(DesignTokens.surfaceContainerHigh, DesignTokens.radiusLg)
2352
+ setOnClickListener { action() }
2353
+ }
2354
+
2355
+ /** Highlights a tool/chip button so the user can tell at a glance which one is active. */
2356
+ private fun setButtonSelected(button: Button, selected: Boolean, textColor: Int, primaryColor: Int) {
2357
+ button.setTextColor(if (selected) DesignTokens.onPrimaryContainer else textColor)
2358
+ button.background = roundedDrawable(
2359
+ if (selected) primaryColor else DesignTokens.surfaceContainerHigh,
2360
+ DesignTokens.radiusLg
2361
+ )
2362
+ }
2363
+
2364
+ private fun <K> refreshSelection(buttons: Map<K, Button>, activeKey: K?, textColor: Int, primaryColor: Int) {
2365
+ buttons.forEach { (key, button) -> setButtonSelected(button, key == activeKey, textColor, primaryColor) }
2366
+ }
2367
+
2368
+ /** One icon-tile + label entry in the main tool rail (Studio Violet "Tool Node"). */
2369
+ private data class ToolNode(val root: View, val tile: View, val icon: ImageView, val label: TextView)
2370
+
2371
+ /** Builds a 44dp rounded icon tile with a label below, matching the mockups' tool-rail nodes. */
2372
+ private fun createToolNode(iconRes: Int, labelText: String, onClick: () -> Unit): ToolNode {
2373
+ val icon = ImageView(this).apply {
2374
+ setImageResource(iconRes)
2375
+ setColorFilter(DesignTokens.outline)
2376
+ }
2377
+ val tile = FrameLayout(this).apply {
2378
+ background = roundedDrawable(DesignTokens.surfaceContainerLow, DesignTokens.radiusLg)
2379
+ addView(icon, FrameLayout.LayoutParams(dp(22), dp(22), Gravity.CENTER))
2380
+ }
2381
+ val label = TextView(this).apply {
2382
+ text = labelText
2383
+ textSize = 11f
2384
+ gravity = Gravity.CENTER
2385
+ setTextColor(DesignTokens.outline)
2386
+ setPadding(0, dp(DesignTokens.spaceXs), 0, 0)
2387
+ }
2388
+ val root = LinearLayout(this).apply {
2389
+ orientation = LinearLayout.VERTICAL
2390
+ gravity = Gravity.CENTER_HORIZONTAL
2391
+ isClickable = true
2392
+ isFocusable = true
2393
+ background = roundedDrawable(Color.TRANSPARENT, DesignTokens.radiusLg)
2394
+ setPadding(0, dp(4), 0, dp(4))
2395
+ addView(tile, LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin)))
2396
+ addView(label, LinearLayout.LayoutParams(WRAP, WRAP))
2397
+ setOnClickListener { onClick() }
2398
+ applyPressScale()
2399
+ }
2400
+ return ToolNode(root, tile, icon, label)
2401
+ }
2402
+
2403
+ /** Highlights a tool-rail node the same way the mockups do: filled tile + text/icon tint switching to the theme's text color. */
2404
+ private fun setToolNodeSelected(node: ToolNode, selected: Boolean, textColor: Int) {
2405
+ node.tile.background = roundedDrawable(
2406
+ if (selected) themeColor("primaryColor", DesignTokens.primaryContainer) else DesignTokens.surfaceContainerLow,
2407
+ DesignTokens.radiusLg
2408
+ )
2409
+ node.icon.setColorFilter(if (selected) DesignTokens.onPrimaryContainer else DesignTokens.outline)
2410
+ node.label.setTextColor(if (selected) DesignTokens.primary else DesignTokens.outline)
2411
+ }
2412
+
2413
+ private fun <K> refreshToolNodeSelection(nodes: Map<K, ToolNode>, activeKey: K?, textColor: Int) {
2414
+ nodes.forEach { (key, node) -> setToolNodeSelected(node, key == activeKey, textColor) }
2415
+ }
2416
+
2417
+ private fun showPreviewError(container: FrameLayout) {
2418
+ container.addView(TextView(this).apply {
2419
+ text = "Unable to preview this video"
2420
+ textSize = 16f
2421
+ gravity = Gravity.CENTER
2422
+ setTextColor(Color.WHITE)
2423
+ }, FrameLayout.LayoutParams(MATCH, MATCH))
2424
+ }
2425
+
2426
+ private fun finishDone() {
2427
+ if (mediaType == "photo") {
2428
+ exportPhoto()
2429
+ } else {
2430
+ exportVideo()
2431
+ }
2432
+ }
2433
+
2434
+ /** Studio Violet close flow. Draft persistence is intentionally omitted until it is real. */
2435
+ private fun confirmDiscardChanges() {
2436
+ AlertDialog.Builder(this)
2437
+ .setTitle("Discard Unsaved Changes?")
2438
+ .setMessage("If you exit now, your edits will be lost.")
2439
+ .setPositiveButton("Keep Editing", null)
2440
+ .setNegativeButton("Discard Changes") { _, _ -> finishCancelled() }
2441
+ .create()
2442
+ .apply {
2443
+ setOnShowListener {
2444
+ getButton(AlertDialog.BUTTON_POSITIVE)?.setTextColor(themeColor("primaryColor", DesignTokens.primary))
2445
+ getButton(AlertDialog.BUTTON_NEGATIVE)?.setTextColor(DesignTokens.error)
2446
+ }
2447
+ }
2448
+ .show()
2449
+ }
2450
+
2451
+ private fun finishCancelled() {
2452
+ videoExporter?.cancel()
2453
+ setResult(RESULT_CANCELED)
2454
+ finish()
2455
+ }
2456
+
2457
+ private fun finishWithError(message: String, code: String) {
2458
+ setResult(RESULT_ERROR, Intent().putExtra(EXTRA_ERROR, message).putExtra(EXTRA_ERROR_CODE, code))
2459
+ finish()
2460
+ }
2461
+
2462
+ @Deprecated("Deprecated in Java")
2463
+ override fun onBackPressed() = confirmDiscardChanges()
2464
+
2465
+ override fun onTrimMemory(level: Int) {
2466
+ super.onTrimMemory(level)
2467
+ if (level >= TRIM_MEMORY_RUNNING_LOW) timelineThumbnailRepository?.clearMemory()
2468
+ }
2469
+
2470
+ override fun onDestroy() {
2471
+ positionPollRunnable?.let { positionPollHandler.removeCallbacks(it) }
2472
+ if (::photoImageView.isInitialized) photoImageView.setImageDrawable(null)
2473
+ filterThumbnailLoader.close()
2474
+ timelineThumbnailRequest?.cancel()
2475
+ timelineThumbnailRequest = null
2476
+ if (::trimRangeView.isInitialized) trimRangeView.thumbnails = emptyList()
2477
+ timelineThumbnailRepository?.close()
2478
+ timelineThumbnailRepository = null
2479
+ val sessionToRelease = photoSession
2480
+ photoSession = null
2481
+ photoPreviewRenderer?.close { sessionToRelease?.release() }
2482
+ photoPreviewRenderer = null
2483
+ videoOverlayBitmap?.takeIf { !it.isRecycled }?.recycle()
2484
+ videoOverlayBitmap = null
2485
+ videoImageLayerCache.values.filterNotNull().distinct().forEach { if (!it.isRecycled) it.recycle() }
2486
+ videoImageLayerCache.clear()
2487
+ videoExporter?.cancel()
2488
+ videoSession?.release()
2489
+ super.onDestroy()
2490
+ }
2491
+
2492
+ private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt()
2493
+
2494
+ companion object {
2495
+ /** Rotate icon row (44dp) + aspect-preset chip row (56dp). */
2496
+ const val CROP_SUB_BAR_HEIGHT_DP = 148
2497
+ const val ADJUST_SUB_BAR_HEIGHT_DP = 112
2498
+ /** 64dp preset thumbnail + label + vertical padding, matching the "Preset Thumbnail Cards" spec. */
2499
+ const val FILTERS_SUB_BAR_HEIGHT_DP = 104
2500
+ /** 64dp asset card + optional caption + vertical padding, for the Stickers/Shapes grid. */
2501
+ const val ASSET_GRID_BAR_HEIGHT_DP = 104
2502
+ private const val TIMELINE_THUMBNAIL_COUNT = 12
2503
+ const val EXTRA_URI = "photoVideoEditor.uri"
2504
+ const val EXTRA_TYPE = "photoVideoEditor.type"
2505
+ const val EXTRA_MIME_TYPE = "photoVideoEditor.mimeType"
2506
+ const val EXTRA_WIDTH = "photoVideoEditor.width"
2507
+ const val EXTRA_HEIGHT = "photoVideoEditor.height"
2508
+ const val EXTRA_FILE_SIZE = "photoVideoEditor.fileSize"
2509
+ const val EXTRA_DURATION = "photoVideoEditor.duration"
2510
+ const val EXTRA_ERROR = "photoVideoEditor.error"
2511
+ const val EXTRA_ERROR_CODE = "photoVideoEditor.errorCode"
2512
+ const val EXTRA_REQUEST = "photoVideoEditor.request"
2513
+ const val RESULT_ERROR = Activity.RESULT_FIRST_USER
2514
+ private const val MATCH = ViewGroup.LayoutParams.MATCH_PARENT
2515
+ private const val WRAP = ViewGroup.LayoutParams.WRAP_CONTENT
2516
+ private const val REQUEST_CODE_STICKER_UPLOAD = 9001
2517
+ private const val REQUEST_CODE_OVERLAY_UPLOAD = 9002
2518
+ private const val REQUEST_CODE_VIDEO_STICKER_UPLOAD = 9003
2519
+ private const val REQUEST_CODE_VIDEO_OVERLAY_UPLOAD = 9004
2520
+ }
2521
+ }