react-native-photo-video-editor 0.1.1 → 0.2.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 (41) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/android/src/main/java/com/photovideoeditor/editor/PhotoVideoEditorActivity.kt +760 -590
  3. package/android/src/main/java/com/photovideoeditor/photo/export/PhotoExporter.kt +8 -23
  4. package/android/src/main/java/com/photovideoeditor/photo/render/CropGeometry.kt +71 -0
  5. package/android/src/main/java/com/photovideoeditor/photo/render/OverlayGeometry.kt +36 -0
  6. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoEditSession.kt +5 -7
  7. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoFilterPresets.kt +71 -41
  8. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerRenderer.kt +3 -1
  9. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoTransformRenderer.kt +33 -0
  10. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoTransformState.kt +6 -1
  11. package/android/src/main/java/com/photovideoeditor/photo/ui/CropOverlayView.kt +109 -151
  12. package/android/src/main/java/com/photovideoeditor/photo/ui/LayerOverlayView.kt +42 -13
  13. package/android/src/main/java/com/photovideoeditor/photo/ui/ZoomableImageView.kt +10 -0
  14. package/android/src/main/java/com/photovideoeditor/ui/components/CropPanel.kt +122 -0
  15. package/android/src/main/java/com/photovideoeditor/ui/components/EditorPanel.kt +250 -0
  16. package/android/src/main/java/com/photovideoeditor/ui/components/EditorToolSlider.kt +103 -0
  17. package/android/src/main/java/com/photovideoeditor/video/export/VideoExporter.kt +38 -11
  18. package/android/src/main/java/com/photovideoeditor/video/render/VideoCropEffects.kt +35 -0
  19. package/android/src/main/java/com/photovideoeditor/video/render/VideoEditSession.kt +10 -0
  20. package/android/src/main/java/com/photovideoeditor/video/render/VideoTransformState.kt +1 -0
  21. package/docs/editor-ui-validation.md +22 -0
  22. package/ios/Editor/PhotoVideoEditorViewController.swift +778 -443
  23. package/ios/Editor/UI/CropPanel.swift +97 -0
  24. package/ios/Editor/UI/EditorPanel.swift +334 -0
  25. package/ios/Editor/UI/EditorToolSlider.swift +22 -0
  26. package/ios/Editor/UI/TextEditorSheet.swift +84 -0
  27. package/ios/Photo/Export/PhotoExporter.swift +3 -3
  28. package/ios/Photo/Rendering/CropGeometry.swift +48 -0
  29. package/ios/Photo/Rendering/PhotoEditSession.swift +21 -10
  30. package/ios/Photo/Rendering/PhotoFilterPresets.swift +53 -23
  31. package/ios/Photo/Rendering/PhotoLayer.swift +9 -1
  32. package/ios/Photo/Rendering/PhotoLayerRenderer.swift +2 -1
  33. package/ios/Photo/Rendering/PhotoTransformState.swift +5 -0
  34. package/ios/Photo/UI/CropOverlayView.swift +96 -169
  35. package/ios/Photo/UI/FilterThumbnailLoader.swift +62 -0
  36. package/ios/Photo/UI/LayerOverlayView.swift +27 -10
  37. package/ios/Photo/UI/ZoomableImageView.swift +19 -1
  38. package/ios/Video/Export/VideoExporter.swift +10 -1
  39. package/ios/Video/Render/VideoCropGeometry.swift +48 -0
  40. package/ios/Video/Render/VideoTransformState.swift +1 -0
  41. package/package.json +1 -1
@@ -7,6 +7,7 @@ import android.content.Intent
7
7
  import android.graphics.Bitmap
8
8
  import android.graphics.BitmapFactory
9
9
  import android.graphics.Color
10
+ import android.graphics.Typeface
10
11
  import android.net.Uri
11
12
  import android.os.Bundle
12
13
  import android.text.InputType
@@ -33,6 +34,7 @@ import androidx.media3.ui.PlayerView
33
34
  import com.photovideoeditor.files.SourceResolver
34
35
  import com.photovideoeditor.photo.export.PhotoExportException
35
36
  import com.photovideoeditor.photo.export.PhotoExporter
37
+ import com.photovideoeditor.photo.render.OverlayGeometry
36
38
  import com.photovideoeditor.photo.render.LayerType
37
39
  import com.photovideoeditor.photo.render.PhotoAdjustments
38
40
  import com.photovideoeditor.photo.render.PhotoEditSession
@@ -55,9 +57,18 @@ import com.photovideoeditor.photo.ui.ZoomableImageView
55
57
  import com.photovideoeditor.stickers.OnlineStickerSheet
56
58
  import com.photovideoeditor.stickers.RuntimeSticker
57
59
  import com.photovideoeditor.ui.DesignTokens
60
+ import com.photovideoeditor.ui.components.CropPanel
61
+ import com.photovideoeditor.ui.components.EditorFilterThumbnail
62
+ import com.photovideoeditor.ui.components.EditorPanelMetrics
63
+ import com.photovideoeditor.ui.components.EditorStripItem
64
+ import com.photovideoeditor.ui.components.EditorToolSlider
58
65
  import com.photovideoeditor.ui.components.applyPressScale
59
66
  import com.photovideoeditor.ui.components.editorChip
67
+ import com.photovideoeditor.ui.components.editorFilterThumbnail
68
+ import com.photovideoeditor.ui.components.editorPanelHeader
60
69
  import com.photovideoeditor.ui.components.editorPrimaryButton
70
+ import com.photovideoeditor.ui.components.editorStripItem
71
+ import com.photovideoeditor.ui.components.editorToolStrip
61
72
  import org.json.JSONObject
62
73
  import java.io.File
63
74
  import kotlin.math.roundToInt
@@ -71,6 +82,7 @@ class PhotoVideoEditorActivity : Activity() {
71
82
  private var photoSession: PhotoEditSession? = null
72
83
  private var cropMode = false
73
84
  private var cropEntryState: PhotoTransformState? = null
85
+ private var photoCropPanel: CropPanel? = null
74
86
  private var adjustMode = false
75
87
  private var filtersMode = false
76
88
  private var drawMode = false
@@ -78,7 +90,7 @@ class PhotoVideoEditorActivity : Activity() {
78
90
  private lateinit var stickersSubBar: View
79
91
  private var activeAdjustmentKey = "brightness"
80
92
  private var selectedLayerId: String? = null
81
- private var activeLayerPropertyKey = "scale"
93
+ private var activeLayerPropertyKey = ""
82
94
  private var dragStartSnapshot: List<PhotoLayer>? = null
83
95
  private lateinit var photoImageView: ZoomableImageView
84
96
  private lateinit var cropOverlay: CropOverlayView
@@ -86,7 +98,10 @@ class PhotoVideoEditorActivity : Activity() {
86
98
  private lateinit var drawOverlay: DrawOverlayView
87
99
  private lateinit var straightenSeekBar: SeekBar
88
100
  private lateinit var cropSubBar: View
89
- private lateinit var adjustmentSeekBar: SeekBar
101
+ /** Drives whichever adjustment is selected in the Adjust panel. */
102
+ private lateinit var adjustmentSeekBar: EditorToolSlider
103
+ /** Filter strength, owned by the Filters panel (a View can only have one parent, so not shared). */
104
+ private lateinit var filterIntensitySeekBar: EditorToolSlider
90
105
  private lateinit var filtersSubBar: View
91
106
  private lateinit var adjustSubBar: View
92
107
  private lateinit var drawSubBar: View
@@ -104,9 +119,14 @@ class PhotoVideoEditorActivity : Activity() {
104
119
  private var videoSession: VideoEditSession? = null
105
120
  private var videoExporter: VideoExporter? = null
106
121
  private var videoAspectMode = false
122
+ private var videoCropMode = false
123
+ private var videoCropEntryState: PhotoTransformState? = null
124
+ private var videoCropPanel: CropPanel? = null
125
+ private lateinit var videoCropOverlay: CropOverlayView
126
+ private lateinit var cropPlayerView: PlayerView
107
127
  private lateinit var trimRangeView: TrimRangeView
108
128
  private lateinit var positionSeekBar: SeekBar
109
- private lateinit var playPauseButton: Button
129
+ private lateinit var playPauseButton: ImageButton
110
130
  private lateinit var timeLabel: TextView
111
131
  private lateinit var videoAspectSubBar: View
112
132
  private lateinit var videoToolBar: View
@@ -118,20 +138,22 @@ class PhotoVideoEditorActivity : Activity() {
118
138
  private val positionPollHandler = android.os.Handler(android.os.Looper.getMainLooper())
119
139
  private var positionPollRunnable: Runnable? = null
120
140
  private var seeking = false
141
+ private lateinit var videoControlsOverlay: View
142
+ private var videoControlsVisible = true
143
+ private val hideVideoControlsRunnable = Runnable { setVideoControlsVisible(false) }
121
144
 
122
145
  private val photoToolButtons = mutableMapOf<String, ToolNode>()
123
146
  private var photoToolScroll: HorizontalScrollView? = null
124
147
  private val cropAspectButtons = mutableMapOf<Float?, Button>()
125
- private val filterAdjustmentButtons = mutableMapOf<String, Button>()
126
- /** Preset id ("" = Original) -> (thumbnail frame, label) for the Filters preset-card scroller. */
127
- private val filterPresetCards = mutableMapOf<String, Pair<FrameLayout, TextView>>()
148
+ /** Adjustment key (plus "mirror") -> its entry in the Adjust panel's strip. */
149
+ private val adjustmentStripItems = mutableMapOf<String, EditorStripItem>()
150
+ /** Preset id ("" = Original) -> its thumbnail in the Filters carousel. */
151
+ private val filterPresetCards = mutableMapOf<String, EditorFilterThumbnail>()
128
152
  private var resizeMode = false
129
153
  private lateinit var resizeSubBar: View
130
154
  private var resizePresetSize: Pair<Int, Int>? = null
131
155
  private val resizePresetButtons = mutableMapOf<Pair<Int, Int>?, Button>()
132
- private val layerPropertyButtons = mutableMapOf<String, Button>()
133
- /** Non-Delete/Duplicate layer action icons, hidden for TEXT layers (only Opacity/Colors/Duplicate/Delete apply there). */
134
- private val layerIconButtons = mutableMapOf<String, View>()
156
+ private val layerPropertyButtons = mutableMapOf<String, ToolNode>()
135
157
  private val videoToolButtons = mutableMapOf<String, ToolNode>()
136
158
  private val videoAspectButtons = mutableMapOf<Float?, Button>()
137
159
  private val videoFilterButtons = mutableMapOf<String, Button>()
@@ -143,9 +165,12 @@ class PhotoVideoEditorActivity : Activity() {
143
165
  private lateinit var videoLayerToolBar: View
144
166
  private lateinit var videoLayerPropertySeekBar: SeekBar
145
167
  private var selectedVideoLayerId: String? = null
146
- private var activeVideoLayerPropertyKey = "scale"
168
+ private var activeVideoLayerPropertyKey = ""
147
169
  private var videoDragStartSnapshot: List<PhotoLayer>? = null
148
- private val videoLayerPropertyButtons = mutableMapOf<String, Button>()
170
+ private val videoLayerPropertyButtons = mutableMapOf<String, ToolNode>()
171
+ private lateinit var videoColorRow: View
172
+ private val textSwatches = mutableMapOf<Int, View>()
173
+ private val videoTextSwatches = mutableMapOf<Int, View>()
149
174
  private var selectedClipIndex = 0
150
175
  private lateinit var clipStripBar: LinearLayout
151
176
  private var timelineThumbnailRepository: TimelineThumbnailRepository? = null
@@ -234,9 +259,13 @@ class PhotoVideoEditorActivity : Activity() {
234
259
  }
235
260
  photoImageView = imageView
236
261
  photoPreviewRenderer = PhotoPreviewRenderCoordinator(
237
- render = { session.renderPreview() },
262
+ render = { session.renderPreview(cropMode || resizeMode) },
238
263
  display = { bitmap ->
239
- if (!comparingOriginal && !isFinishing && !isDestroyed) imageView.setImageBitmap(bitmap)
264
+ if (!comparingOriginal && !isFinishing && !isDestroyed) {
265
+ imageView.setImageBitmap(bitmap)
266
+ imageView.resetToFit()
267
+ if (cropMode && ::cropOverlay.isInitialized) cropOverlay.restore(imageView.currentImageBounds(), session.state)
268
+ }
240
269
  },
241
270
  )
242
271
  previewContainer.addView(imageView, FrameLayout.LayoutParams(MATCH, MATCH))
@@ -254,11 +283,17 @@ class PhotoVideoEditorActivity : Activity() {
254
283
  previewContainer.addView(drawing, FrameLayout.LayoutParams(MATCH, MATCH))
255
284
 
256
285
  imageView.onBoundsChanged = { bounds ->
257
- if (cropMode) overlay.setImageBounds(bounds, resetCrop = false)
286
+ if (cropMode) overlay.restore(bounds, session.state)
258
287
  layers.setImageBounds(bounds)
259
288
  drawing.setImageBounds(bounds)
260
289
  }
261
290
  overlay.onCropChanged = { left, top, right, bottom -> session.update { it.withCrop(left, top, right, bottom) } }
291
+ overlay.onMediaBoundsChanged = { bounds -> imageView.setRenderedBounds(bounds) }
292
+ overlay.onViewportChanged = { zoom, panX, panY -> session.update { it.copy(zoom = zoom, panX = panX, panY = panY) } }
293
+ layers.onLayerDelete = { id ->
294
+ session.layerStack.commit { list -> list.filterNot { it.id == id } }
295
+ selectLayer(null); onLayerStackChanged()
296
+ }
262
297
  layers.onLayerTapped = { id -> selectLayer(id) }
263
298
  layers.onLayerDoubleTapped = { id ->
264
299
  session.layerStack.layers.firstOrNull { it.id == id && it.type == LayerType.TEXT }?.let { showTextInputDialog(session, it) }
@@ -298,10 +333,7 @@ class PhotoVideoEditorActivity : Activity() {
298
333
  })
299
334
  }
300
335
  straightenSeekBar = straighten
301
- root.addView(
302
- straighten,
303
- LinearLayout.LayoutParams(MATCH, WRAP).apply { setMargins(dp(16), dp(4), dp(16), 0) }
304
- )
336
+
305
337
 
306
338
  val cropBar = createCropSubBar(session, imageView, overlay)
307
339
  cropSubBar = cropBar
@@ -313,22 +345,35 @@ class PhotoVideoEditorActivity : Activity() {
313
345
  resizeBar.visibility = View.GONE
314
346
  root.addView(resizeBar, LinearLayout.LayoutParams(MATCH, dp(56)))
315
347
 
316
- val adjustmentSeek = SeekBar(this).apply {
317
- visibility = View.GONE
348
+ val accentColor = themeColor("primaryColor", DesignTokens.primaryContainer)
349
+ adjustmentSeekBar = EditorToolSlider(this).apply {
350
+ applyCompactTrack(accentColor)
318
351
  setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
319
352
  override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
320
353
  if (!fromUser) return
321
354
  val (min, _) = PhotoAdjustments.rangeFor(activeAdjustmentKey)
322
- val value = min + progress
323
- session.updateAdjustments { it.with(activeAdjustmentKey, value) }
355
+ session.updateAdjustments { it.with(activeAdjustmentKey, min + progress) }
356
+ // Coalesced by the preview renderer, so dragging does not queue a render per pixel.
357
+ schedulePhotoPreviewRender()
358
+ }
359
+ override fun onStartTrackingTouch(seekBar: SeekBar?) {}
360
+ override fun onStopTrackingTouch(seekBar: SeekBar?) { refreshAdjustSelection(session) }
361
+ })
362
+ }
363
+ filterIntensitySeekBar = EditorToolSlider(this).apply {
364
+ applyCompactTrack(accentColor)
365
+ labelText = "Intensity"
366
+ valueFormatter = { progress -> "$progress" }
367
+ setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
368
+ override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
369
+ if (!fromUser) return
370
+ session.updateAdjustments { it.copy(filterStrength = progress.toFloat()) }
324
371
  schedulePhotoPreviewRender()
325
372
  }
326
373
  override fun onStartTrackingTouch(seekBar: SeekBar?) {}
327
374
  override fun onStopTrackingTouch(seekBar: SeekBar?) {}
328
375
  })
329
376
  }
330
- adjustmentSeekBar = adjustmentSeek
331
- root.addView(adjustmentSeek, LinearLayout.LayoutParams(MATCH, WRAP).apply { setMargins(dp(16), dp(4), dp(16), 0) })
332
377
 
333
378
  val adjustBar = createAdjustSubBar(session, imageView)
334
379
  adjustSubBar = adjustBar
@@ -350,15 +395,17 @@ class PhotoVideoEditorActivity : Activity() {
350
395
  drawBar.visibility = View.GONE
351
396
  root.addView(drawBar, LinearLayout.LayoutParams(MATCH, dp(56)))
352
397
 
353
- val layerPropertySeek = SeekBar(this).apply {
398
+ val layerPropertySeek = EditorToolSlider(this).apply {
399
+ progressTintList = android.content.res.ColorStateList.valueOf(themeColor("primaryColor", DesignTokens.primaryContainer))
400
+ thumbTintList = progressTintList
354
401
  visibility = View.GONE
355
402
  setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
356
403
  override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
357
404
  if (!fromUser) return
358
405
  onLayerPropertyChanged(progress)
359
406
  }
360
- override fun onStartTrackingTouch(seekBar: SeekBar?) {}
361
- override fun onStopTrackingTouch(seekBar: SeekBar?) {}
407
+ override fun onStartTrackingTouch(seekBar: SeekBar?) { dragStartSnapshot = session.layerStack.layers.map { it.copy() } }
408
+ override fun onStopTrackingTouch(seekBar: SeekBar?) { dragStartSnapshot?.let { session.layerStack.commitSnapshot(it) }; dragStartSnapshot = null; onLayerStackChanged() }
362
409
  })
363
410
  }
364
411
  layerPropertySeekBar = layerPropertySeek
@@ -372,7 +419,7 @@ class PhotoVideoEditorActivity : Activity() {
372
419
  val layerBar = createLayerToolBar(session)
373
420
  layerToolBar = layerBar
374
421
  layerBar.visibility = View.GONE
375
- root.addView(layerBar, LinearLayout.LayoutParams(MATCH, dp(56)))
422
+ root.addView(layerBar, LinearLayout.LayoutParams(MATCH, dp(76)))
376
423
 
377
424
  val toolbarHeight = dp(76)
378
425
  val toolbar = createPhotoToolBar(session, imageView, overlay)
@@ -390,7 +437,7 @@ class PhotoVideoEditorActivity : Activity() {
390
437
  filtersBar to dp(FILTERS_SUB_BAR_HEIGHT_DP),
391
438
  stickersBar to dp(ASSET_GRID_BAR_HEIGHT_DP),
392
439
  drawBar to dp(56),
393
- layerBar to dp(56)
440
+ layerBar to dp(76)
394
441
  )
395
442
  )
396
443
  return outer
@@ -404,39 +451,57 @@ class PhotoVideoEditorActivity : Activity() {
404
451
  Triple("text", "Text", R.drawable.ic_title),
405
452
  Triple("stickers", "Stickers", R.drawable.ic_sentiment_satisfied),
406
453
  Triple("draw", "Draw", R.drawable.ic_draw),
407
- Triple("resize", "Resize", R.drawable.ic_aspect_ratio)
454
+ Triple("resize", "Resize", R.drawable.ic_aspect_ratio),
455
+ Triple("compare", "Original", R.drawable.ic_compare)
408
456
  )
409
457
 
458
+ /**
459
+ * Main tool dock. All eight tools stay available; rather than compressing them onto one screen
460
+ * (tiny icons, wrapped labels), items are sized so roughly five fill the width and the rest are a
461
+ * scroll away.
462
+ */
410
463
  private fun createPhotoToolBar(session: PhotoEditSession, imageView: ZoomableImageView, overlay: CropOverlayView): LinearLayout {
411
464
  val backgroundColor = themeColor("toolbarColor", DesignTokens.withAlphaPercent(DesignTokens.surfaceContainerLowest, 95))
465
+ val itemWidth = photoToolItemWidth()
412
466
  return LinearLayout(this).apply {
413
467
  orientation = LinearLayout.VERTICAL
414
468
  setBackgroundColor(backgroundColor)
415
- setPadding(dp(DesignTokens.spaceXs), dp(DesignTokens.spaceSm), dp(DesignTokens.spaceXs), dp(DesignTokens.spaceLg))
416
-
417
- addView(
418
- View(this@PhotoVideoEditorActivity).apply { background = roundedDrawable(DesignTokens.surfaceVariant, DesignTokens.radiusSm) },
419
- LinearLayout.LayoutParams(dp(36), dp(4)).apply { gravity = Gravity.CENTER_HORIZONTAL; bottomMargin = dp(DesignTokens.spaceXs) }
420
- )
421
469
 
422
470
  val features = request.optJSONObject("features")
423
471
  addView(HorizontalScrollView(this@PhotoVideoEditorActivity).apply {
424
472
  isHorizontalScrollBarEnabled = false
473
+ clipToPadding = false
425
474
  photoToolScroll = this
426
475
  addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
427
476
  orientation = LinearLayout.HORIZONTAL
428
477
  gravity = Gravity.CENTER_VERTICAL
429
- setPadding(dp(DesignTokens.spaceSm), dp(DesignTokens.spaceXs), dp(DesignTokens.spaceSm), dp(DesignTokens.spaceXs))
430
478
  photoToolDefinitions.filter { features?.optBoolean(it.first, true) != false }.forEach { (key, label, iconRes) ->
431
479
  val node = createToolNode(iconRes, label) { onPhotoToolTapped(key, session, imageView, overlay) }
480
+ if (key == "compare") {
481
+ node.root.contentDescription = "Hold to compare with original"
482
+ node.root.setOnTouchListener { _, event ->
483
+ when (event.actionMasked) {
484
+ MotionEvent.ACTION_DOWN -> showOriginalPreview(true)
485
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> showOriginalPreview(false)
486
+ }
487
+ true
488
+ }
489
+ }
432
490
  photoToolButtons[key] = node
433
- addView(node.root, LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), WRAP).apply { marginEnd = dp(DesignTokens.spaceSm) })
491
+ addView(node.root, LinearLayout.LayoutParams(itemWidth, WRAP))
434
492
  }
435
493
  })
436
494
  }, LinearLayout.LayoutParams(MATCH, WRAP))
437
495
  }
438
496
  }
439
497
 
498
+ /**
499
+ * Width that shows about five tools at once, so the dock fills the screen evenly on a small phone
500
+ * and does not leave 40dp gaps on a large one. Clamped so labels stay on one line either way.
501
+ */
502
+ private fun photoToolItemWidth(): Int =
503
+ (resources.displayMetrics.widthPixels / 5).coerceIn(dp(64), dp(88))
504
+
440
505
  private fun refreshPhotoToolSelection() {
441
506
  val textColor = themeColor("textColor", DesignTokens.onSurface)
442
507
  photoToolButtons.forEach { (key, node) ->
@@ -468,7 +533,9 @@ class PhotoVideoEditorActivity : Activity() {
468
533
  }
469
534
 
470
535
  private fun onPhotoToolTapped(key: String, session: PhotoEditSession, imageView: ZoomableImageView, overlay: CropOverlayView) {
536
+ if (key != "compare" && comparingOriginal) showOriginalPreview(false)
471
537
  when (key) {
538
+ "compare" -> showOriginalPreview(!comparingOriginal)
472
539
  "crop" -> setCropMode(true, session, imageView, overlay)
473
540
  "adjust" -> setAdjustMode(true, session)
474
541
  "filters" -> setFiltersMode(true, session)
@@ -486,60 +553,92 @@ class PhotoVideoEditorActivity : Activity() {
486
553
  // ---------------------------------------------------------------------
487
554
 
488
555
  private fun showTextInputDialog(session: PhotoEditSession, editingLayer: PhotoLayer? = null) {
489
- var selectedColor = editingLayer?.textColor ?: Color.WHITE
556
+ showTextEditor(editingLayer) { text ->
557
+ val layer = editingLayer?.copy(text = text) ?: PhotoLayer(type = LayerType.TEXT, text = text)
558
+ session.layerStack.commit { list -> if (editingLayer == null) list + layer else list.map { if (it.id == layer.id) layer else it } }
559
+ selectLayer(layer.id)
560
+ onLayerStackChanged()
561
+ }
562
+ }
563
+
564
+ private fun showTextEditor(editingLayer: PhotoLayer?, onSave: (String) -> Unit) {
565
+ val dialog = android.app.Dialog(this)
566
+ dialog.requestWindowFeature(android.view.Window.FEATURE_NO_TITLE)
567
+ val panel = LinearLayout(this).apply {
568
+ orientation = LinearLayout.VERTICAL
569
+ setPadding(dp(20), dp(16), dp(20), dp(20))
570
+ background = roundedDrawable(DesignTokens.surfaceContainerHigh, 16)
571
+ }
572
+ panel.addView(TextView(this).apply {
573
+ text = if (editingLayer == null) "Add Text" else "Edit Text"
574
+ textSize = 20f
575
+ setTextColor(Color.WHITE)
576
+ setTypeface(typeface, android.graphics.Typeface.BOLD)
577
+ })
490
578
  val input = EditText(this).apply {
491
- inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE
492
- setTextColor(selectedColor)
493
- setHintTextColor(DesignTokens.outline)
579
+ inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
494
580
  hint = "Type something..."
581
+ setHintTextColor(DesignTokens.outline)
582
+ setTextColor(Color.WHITE)
583
+ textSize = 22f
584
+ gravity = Gravity.TOP or Gravity.START
585
+ minLines = 2; maxLines = 6
586
+ setPadding(dp(12), dp(12), dp(12), dp(12))
587
+ background = roundedDrawable(DesignTokens.surfaceContainerLow, 12)
495
588
  setText(editingLayer?.text.orEmpty())
496
- gravity = Gravity.CENTER
497
- setPadding(dp(DesignTokens.spaceLg), dp(DesignTokens.spaceLg), dp(DesignTokens.spaceLg), dp(DesignTokens.spaceLg))
498
- }
499
- val colors = listOf(Color.WHITE, Color.BLACK, Color.RED, Color.YELLOW, Color.GREEN, Color.CYAN, Color.BLUE, Color.MAGENTA)
500
- val colorRow = LinearLayout(this).apply {
501
- orientation = LinearLayout.HORIZONTAL
502
- gravity = Gravity.CENTER
503
- colors.forEach { color ->
504
- addView(createColorSwatch(color) { selectedColor = color; input.setTextColor(color) }, LinearLayout.LayoutParams(dp(40), dp(40)))
505
- }
589
+ setSelection(text.length)
590
+ }
591
+ panel.addView(input, LinearLayout.LayoutParams(MATCH, WRAP).apply { topMargin = dp(16); bottomMargin = dp(12) })
592
+ val actions = LinearLayout(this).apply { gravity = Gravity.END or Gravity.CENTER_VERTICAL }
593
+ actions.addView(actionButton("Cancel") { dialog.dismiss() }, LinearLayout.LayoutParams(dp(88), dp(48)))
594
+ actions.addView(editorPrimaryButton(this, if (editingLayer == null) "Add" else "Done", fillColor = themeColor("primaryColor", DesignTokens.primaryContainer)) {
595
+ val text = input.text.toString()
596
+ if (text.isNotBlank()) { onSave(text); dialog.dismiss() }
597
+ }, LinearLayout.LayoutParams(dp(88), dp(48)))
598
+ panel.addView(actions, LinearLayout.LayoutParams(MATCH, WRAP))
599
+ val scroll = android.widget.ScrollView(this).apply { isFillViewport = true; addView(panel) }
600
+ dialog.setContentView(scroll)
601
+ dialog.window?.apply {
602
+ setBackgroundDrawableResource(android.R.color.transparent)
603
+ addFlags(android.view.WindowManager.LayoutParams.FLAG_DIM_BEHIND)
604
+ setDimAmount(0.7f)
605
+ setSoftInputMode(android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE or android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
606
+ }
607
+ dialog.setOnShowListener {
608
+ dialog.window?.setLayout(MATCH, WRAP)
609
+ dialog.window?.setGravity(Gravity.BOTTOM)
610
+ input.requestFocus()
611
+ input.post { (getSystemService(INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager).showSoftInput(input, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT) }
506
612
  }
507
- val container = LinearLayout(this).apply {
508
- orientation = LinearLayout.VERTICAL
509
- background = roundedDrawable(DesignTokens.surfaceContainerHigh, DesignTokens.radiusLg)
510
- addView(input, LinearLayout.LayoutParams(MATCH, dp(220)))
511
- addView(HorizontalScrollView(this@PhotoVideoEditorActivity).apply { addView(colorRow) }, LinearLayout.LayoutParams(MATCH, dp(56)))
512
- }
513
- val dialog = AlertDialog.Builder(this, android.R.style.Theme_Material_Dialog_Alert)
514
- .setTitle(if (editingLayer == null) "Add text" else "Edit text")
515
- .setView(container)
516
- .setPositiveButton("Done") { _, _ ->
517
- val text = input.text?.toString().orEmpty()
518
- if (text.isNotBlank()) {
519
- val layer = editingLayer?.copy(text = text, textColor = selectedColor)
520
- ?: PhotoLayer(type = LayerType.TEXT, text = text, textColor = selectedColor)
521
- session.layerStack.commit { list -> if (editingLayer == null) list + layer else list.map { if (it.id == layer.id) layer else it } }
522
- selectLayer(layer.id)
523
- }
524
- }
525
- .setNegativeButton("Cancel", null)
526
- .create()
527
- dialog.setOnShowListener { dialog.window?.setLayout(MATCH, MATCH); input.requestFocus() }
528
613
  dialog.show()
529
614
  }
530
615
 
531
616
  private fun setStickersMode(enabled: Boolean, session: PhotoEditSession) {
532
617
  stickersMode = enabled
533
618
  stickersSubBar.visibility = if (enabled) View.VISIBLE else View.GONE
534
- mainToolBar.visibility = if (enabled) View.GONE else View.VISIBLE
619
+ mainToolBar.visibility = if (enabled || selectedLayerId != null) View.GONE else View.VISIBLE
535
620
  refreshPhotoToolSelection()
536
621
  }
537
622
 
623
+ private fun newSticker(stickerId: String? = null, stickerUri: String? = null): PhotoLayer {
624
+ val bounds = if (mediaType == "photo") photoImageView.currentImageBounds()
625
+ else videoSession?.let { computeVideoLetterboxBounds(it, videoOverlayImageView) }
626
+ val scale = bounds?.takeIf { it.width() > 0 && it.height() > 0 }?.let {
627
+ OverlayGeometry.initialStickerScale(it.width(), it.height())
628
+ } ?: (0.3f / 0.18f)
629
+ val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
630
+ stickerUri?.let { uri ->
631
+ SourceResolver.resolvePath(this, uri, "pve_sticker_bounds")?.let { BitmapFactory.decodeFile(it, options) }
632
+ }
633
+ val aspect = if (options.outWidth > 0 && options.outHeight > 0) options.outWidth.toFloat() / options.outHeight else 1f
634
+ return PhotoLayer(type = LayerType.STICKER, stickerId = stickerId, stickerUri = stickerUri, scale = scale, overlayAspectRatio = aspect)
635
+ }
636
+
538
637
  private fun showStickerBottomSheet(session: PhotoEditSession) {
539
638
  stickersMode = true
540
639
  refreshPhotoToolSelection()
541
640
  OnlineStickerSheet(this, runtimeStickerAssets()) { path ->
542
- val layer = PhotoLayer(type = LayerType.STICKER, stickerUri = Uri.fromFile(File(path)).toString())
641
+ val layer = newSticker(stickerUri = Uri.fromFile(File(path)).toString())
543
642
  session.layerStack.commit { it + layer }
544
643
  selectLayer(layer.id)
545
644
  }.apply {
@@ -598,7 +697,7 @@ class PhotoVideoEditorActivity : Activity() {
598
697
  val emoji = TextView(this@PhotoVideoEditorActivity).apply { text = PhotoLayerRenderer.glyphFor(id); textSize = 26f }
599
698
  addView(
600
699
  createAssetCard(emoji, null) {
601
- val layer = PhotoLayer(type = LayerType.STICKER, stickerId = id)
700
+ val layer = newSticker(stickerId = id)
602
701
  session.layerStack.commit { it + layer }
603
702
  selectLayer(layer.id)
604
703
  setStickersMode(false, session)
@@ -617,7 +716,7 @@ class PhotoVideoEditorActivity : Activity() {
617
716
  }
618
717
  addView(
619
718
  createAssetCard(icon, id) {
620
- val layer = PhotoLayer(type = LayerType.STICKER, stickerUri = uri)
719
+ val layer = newSticker(stickerUri = uri)
621
720
  session.layerStack.commit { it + layer }
622
721
  selectLayer(layer.id)
623
722
  setStickersMode(false, session)
@@ -735,7 +834,7 @@ class PhotoVideoEditorActivity : Activity() {
735
834
  when (requestCode) {
736
835
  REQUEST_CODE_STICKER_UPLOAD -> {
737
836
  val session = photoSession ?: return
738
- val layer = PhotoLayer(type = LayerType.STICKER, stickerUri = resolvedUri)
837
+ val layer = newSticker(stickerUri = resolvedUri)
739
838
  session.layerStack.commit { it + layer }
740
839
  selectLayer(layer.id)
741
840
  setStickersMode(false, session)
@@ -755,7 +854,7 @@ class PhotoVideoEditorActivity : Activity() {
755
854
  }
756
855
  REQUEST_CODE_VIDEO_STICKER_UPLOAD -> {
757
856
  val session = videoSession ?: return
758
- val layer = PhotoLayer(type = LayerType.STICKER, stickerUri = resolvedUri)
857
+ val layer = newSticker(stickerUri = resolvedUri)
759
858
  session.layerStack.commit { it + layer }
760
859
  onVideoLayerStackChanged()
761
860
  selectVideoLayer(layer.id, session)
@@ -779,52 +878,37 @@ class PhotoVideoEditorActivity : Activity() {
779
878
 
780
879
  private fun selectLayer(id: String?) {
781
880
  val session = photoSession
881
+ if (selectedLayerId != id) activeLayerPropertyKey = ""
782
882
  selectedLayerId = id
783
883
  layerOverlay.selectedLayerId = id
784
884
  if (session != null) {
785
885
  layerOverlay.layers = session.layerStack.layers
786
886
  schedulePhotoPreviewRender()
787
887
  }
888
+ if (id != null) {
889
+ if (drawMode && session != null) setDrawMode(false, session)
890
+ if (cropMode && session != null) cancelCropMode(session, photoImageView, cropOverlay)
891
+ adjustMode = false; filtersMode = false; stickersMode = false; resizeMode = false
892
+ listOf(adjustSubBar, filtersSubBar, stickersSubBar, resizeSubBar, adjustmentSeekBar).forEach { it.visibility = View.GONE }
893
+ }
788
894
  setLayerToolBarVisible(id != null)
789
895
  }
790
896
 
791
897
  private fun setLayerToolBarVisible(visible: Boolean) {
792
898
  layerToolBar.visibility = if (visible) View.VISIBLE else View.GONE
793
899
  mainToolBar.visibility = if (visible) View.GONE else View.VISIBLE
794
- if (visible) {
795
- // TEXT layers show only Opacity/Colors/Duplicate/Delete; STICKER layers show only
796
- // Opacity/Duplicate/Delete (no Colors either) Scale/Rotate/Size and the
797
- // lock/visibility/reorder icons don't apply to either and are hidden per each reduced list.
798
- val selectedType = currentSelectedLayer()?.type
799
- val isText = selectedType == LayerType.TEXT
800
- val isSticker = selectedType == LayerType.STICKER
801
- val reducedControls = isText || isSticker
802
- layerPropertyButtons["scale"]?.visibility = if (reducedControls) View.GONE else View.VISIBLE
803
- layerPropertyButtons["rotation"]?.visibility = if (reducedControls) View.GONE else View.VISIBLE
804
- layerPropertyButtons["fontSize"]?.visibility = View.GONE
805
- layerPropertyButtons["color"]?.visibility = if (isText) View.VISIBLE else View.GONE
806
- listOf("lock", "visibility", "front", "back").forEach { key ->
807
- layerIconButtons[key]?.visibility = if (reducedControls) View.GONE else View.VISIBLE
808
- }
809
- val allowedKeys = when {
810
- isText -> setOf("opacity", "color")
811
- isSticker -> setOf("opacity")
812
- else -> setOf("scale", "rotation", "opacity")
813
- }
814
- if (activeLayerPropertyKey !in allowedKeys) {
815
- activeLayerPropertyKey = "opacity"
816
- }
817
- layerPropertySeekBar.visibility = if (activeLayerPropertyKey == "color") View.GONE else View.VISIBLE
818
- layerColorSwatchRow.visibility = if (activeLayerPropertyKey == "color") View.VISIBLE else View.GONE
819
- syncLayerPropertySeekBar()
820
- refreshSelection(layerPropertyButtons, activeLayerPropertyKey, themeColor("textColor", DesignTokens.onSurface), themeColor("primaryColor", DesignTokens.primaryContainer))
821
- } else {
822
- layerPropertySeekBar.visibility = View.GONE
823
- layerColorSwatchRow.visibility = View.GONE
824
- }
900
+ val isText = currentSelectedLayer()?.type == LayerType.TEXT
901
+ val simple = isText || currentSelectedLayer()?.type == LayerType.STICKER
902
+ listOf("scale", "lock", "visibility", "front", "back").forEach { layerPropertyButtons[it]?.root?.visibility = if (simple) View.GONE else View.VISIBLE }
903
+ listOf("edit", "color", "fontSize").forEach { layerPropertyButtons[it]?.root?.visibility = if (isText) View.VISIBLE else View.GONE }
904
+ if (!isText && activeLayerPropertyKey in setOf("color", "fontSize")) activeLayerPropertyKey = ""
905
+ layerPropertySeekBar.visibility = if (visible && activeLayerPropertyKey in setOf("scale", "rotation", "opacity", "fontSize")) View.VISIBLE else View.GONE
906
+ layerColorSwatchRow.visibility = if (visible && activeLayerPropertyKey == "color") View.VISIBLE else View.GONE
907
+ if (visible) syncLayerPropertySeekBar()
908
+ textSwatches.forEach { (color, view) -> view.background = roundedDrawable(DesignTokens.surfaceContainerHigh, 24).apply { if (color == currentSelectedLayer()?.textColor) setStroke(dp(2), themeColor("primaryColor", DesignTokens.primaryContainer)) } }
909
+ refreshToolNodeSelection(layerPropertyButtons, activeLayerPropertyKey, Color.WHITE)
825
910
  }
826
911
 
827
- /** Circular color swatch, matching the drawing_brush_studio mockup's palette dots. */
828
912
  private fun createColorSwatch(color: Int, onClick: () -> Unit): FrameLayout {
829
913
  val outer = FrameLayout(this).apply {
830
914
  background = circleDrawable(DesignTokens.surfaceContainerHigh)
@@ -837,38 +921,29 @@ class PhotoVideoEditorActivity : Activity() {
837
921
  }
838
922
 
839
923
  /** Text color swatch row, shown when a TEXT layer's "Color" property is active (text_editor_typography_studio mockup). */
840
- private fun createTextColorSwatchRow(session: PhotoEditSession): LinearLayout {
841
- val toolbarColor = themeColor("toolbarColor", DesignTokens.surfaceContainerLow)
842
- val colors = listOf(
843
- Color.WHITE, Color.BLACK, Color.RED, Color.rgb(255, 214, 51),
844
- Color.rgb(76, 217, 100), Color.rgb(0, 145, 255), DesignTokens.primary, DesignTokens.tertiary
845
- )
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
- colors.forEach { color ->
856
- addView(
857
- createColorSwatch(color) {
858
- val id = selectedLayerId ?: return@createColorSwatch
859
- session.layerStack.commit { list -> list.map { if (it.id == id) it.copy(textColor = color) else it } }
860
- onLayerStackChanged()
861
- },
862
- LinearLayout.LayoutParams(dp(32), dp(32)).apply { marginEnd = dp(DesignTokens.spaceSm) }
863
- )
864
- }
865
- })
866
- }, LinearLayout.LayoutParams(0, MATCH, 1f))
867
- addView(
868
- actionButton("Done") { selectLayer(null) }.apply { setTextColor(themeColor("primaryColor", DesignTokens.primaryContainer)) },
869
- LinearLayout.LayoutParams(dp(72), MATCH)
870
- )
871
- }
924
+ private fun createTextColorSwatchRow(session: PhotoEditSession): LinearLayout = createTextPalette(false) { color ->
925
+ val id = selectedLayerId ?: return@createTextPalette
926
+ session.layerStack.commit { list -> list.map { if (it.id == id) it.copy(textColor = color) else it } }
927
+ onLayerStackChanged()
928
+ setLayerToolBarVisible(true)
929
+ }
930
+
931
+ private fun createTextPalette(video: Boolean, onColor: (Int) -> Unit): LinearLayout = LinearLayout(this).apply {
932
+ setBackgroundColor(themeColor("toolbarColor", DesignTokens.surfaceContainerLow))
933
+ val swatches = if (video) videoTextSwatches else textSwatches
934
+ addView(HorizontalScrollView(this@PhotoVideoEditorActivity).apply {
935
+ isHorizontalScrollBarEnabled = false
936
+ addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
937
+ gravity = Gravity.CENTER_VERTICAL
938
+ setPadding(dp(12), 0, dp(12), 0)
939
+ listOf(Color.WHITE, Color.BLACK, Color.rgb(255, 77, 94), Color.rgb(255, 155, 61), Color.rgb(255, 214, 51), Color.rgb(76, 217, 100), Color.rgb(0, 145, 255), DesignTokens.primary, Color.rgb(255, 105, 180)).forEach { color ->
940
+ val swatch = createColorSwatch(color) { onColor(color) }
941
+ swatch.contentDescription = "Text color #" + Integer.toHexString(color).takeLast(6)
942
+ swatches[color] = swatch
943
+ addView(swatch, LinearLayout.LayoutParams(dp(48), dp(48)))
944
+ }
945
+ })
946
+ }, LinearLayout.LayoutParams(MATCH, dp(48)))
872
947
  }
873
948
 
874
949
  private fun currentSelectedLayer(): PhotoLayer? {
@@ -879,13 +954,14 @@ class PhotoVideoEditorActivity : Activity() {
879
954
  private fun syncLayerPropertySeekBar() {
880
955
  val layer = currentSelectedLayer() ?: return
881
956
  val (min, max, value) = when (activeLayerPropertyKey) {
882
- "rotation" -> Triple(-180f, 180f, layer.rotationDegrees)
957
+ "rotation" -> Triple(-180f, 180f, ((layer.rotationDegrees % 360f + 540f) % 360f) - 180f)
883
958
  "opacity" -> Triple(0f, 100f, layer.opacity * 100f)
884
959
  "fontSize" -> Triple(12f, 160f, layer.fontSize)
885
960
  else -> Triple(20f, 800f, layer.scale * 100f)
886
961
  }
887
962
  layerPropertySeekBar.max = (max - min).roundToInt()
888
963
  layerPropertySeekBar.progress = (value - min).roundToInt()
964
+ (layerPropertySeekBar as? EditorToolSlider)?.propertyKey = activeLayerPropertyKey
889
965
  }
890
966
 
891
967
  private fun onLayerPropertyChanged(progress: Int) {
@@ -913,269 +989,250 @@ class PhotoVideoEditorActivity : Activity() {
913
989
  schedulePhotoPreviewRender()
914
990
  }
915
991
 
916
- private fun createLayerToolBar(session: PhotoEditSession): LinearLayout {
917
- val toolbarColor = themeColor("toolbarColor", DesignTokens.surfaceContainerLow)
918
- val textColor = themeColor("textColor", DesignTokens.onSurface)
919
- val primaryColor = themeColor("primaryColor", DesignTokens.primaryContainer)
920
- // "fontSize"/"color" only make sense for TEXT layers; their chips are hidden/shown per-selection in `setLayerToolBarVisible`.
921
- val properties = listOf("scale" to "Scale", "rotation" to "Rotate", "opacity" to "Opacity", "fontSize" to "Size", "color" to "Color")
992
+ private fun createLayerToolBar(session: PhotoEditSession): LinearLayout = createContextToolbar(false) { key ->
993
+ val layer = currentSelectedLayer() ?: return@createContextToolbar
994
+ when (key) {
995
+ "edit" -> showTextInputDialog(session, layer)
996
+ "done" -> selectLayer(null)
997
+ "delete" -> { session.layerStack.commit { it.filterNot { item -> item.id == layer.id } }; selectLayer(null); onLayerStackChanged() }
998
+ "duplicate" -> {
999
+ val copy = layer.copy(id = java.util.UUID.randomUUID().toString(), x = (layer.x + 0.04f).coerceIn(0f, 1f), y = (layer.y + 0.04f).coerceIn(0f, 1f))
1000
+ session.layerStack.commit { it + copy }; selectLayer(copy.id); onLayerStackChanged()
1001
+ }
1002
+ "lock", "visibility", "front", "back" -> {
1003
+ session.layerStack.commit { list -> when (key) {
1004
+ "front" -> list.filterNot { it.id == layer.id } + layer
1005
+ "back" -> listOf(layer) + list.filterNot { it.id == layer.id }
1006
+ else -> list.map { if (it.id != layer.id) it else if (key == "lock") it.copy(locked = !it.locked) else it.copy(visible = !it.visible) }
1007
+ } }; onLayerStackChanged()
1008
+ }
1009
+ else -> { activeLayerPropertyKey = if (activeLayerPropertyKey == key) "" else key; setLayerToolBarVisible(true) }
1010
+ }
1011
+ }
1012
+
1013
+ private fun createContextToolbar(video: Boolean, onAction: (String) -> Unit): LinearLayout {
1014
+ val nodes = if (video) videoLayerPropertyButtons else layerPropertyButtons
1015
+ val actions = mutableListOf(
1016
+ Triple("edit", "Edit", R.drawable.ic_draw), Triple("color", "Color", R.drawable.ic_palette),
1017
+ Triple("fontSize", "Size", R.drawable.ic_format_size), Triple("rotation", "Rotate", R.drawable.ic_rotate_right),
1018
+ Triple("opacity", "Opacity", R.drawable.ic_opacity), Triple("scale", "Scale", R.drawable.ic_fullscreen),
1019
+ Triple("duplicate", "Duplicate", R.drawable.ic_content_copy), Triple("delete", "Delete", R.drawable.ic_delete)
1020
+ )
1021
+ if (!video) actions.addAll(listOf(Triple("lock", "Lock", R.drawable.ic_lock), Triple("visibility", "Hide", R.drawable.ic_visibility_off), Triple("front", "Front", R.drawable.ic_chevron_right), Triple("back", "Back", R.drawable.ic_chevron_left)))
922
1022
  return LinearLayout(this).apply {
923
1023
  gravity = Gravity.CENTER_VERTICAL
924
- setPadding(dp(12), 0, dp(12), 0)
925
- setBackgroundColor(toolbarColor)
1024
+ setBackgroundColor(themeColor("toolbarColor", DesignTokens.surfaceContainerLow))
926
1025
  addView(HorizontalScrollView(this@PhotoVideoEditorActivity).apply {
927
1026
  isHorizontalScrollBarEnabled = false
928
1027
  addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
929
- orientation = LinearLayout.HORIZONTAL
930
1028
  gravity = Gravity.CENTER_VERTICAL
931
- properties.forEach { (key, label) ->
932
- val chip = actionButton(label) {
933
- activeLayerPropertyKey = key
934
- layerPropertySeekBar.visibility = if (key == "color") View.GONE else View.VISIBLE
935
- layerColorSwatchRow.visibility = if (key == "color") View.VISIBLE else View.GONE
936
- if (key != "color") syncLayerPropertySeekBar()
937
- refreshSelection(layerPropertyButtons, activeLayerPropertyKey, textColor, primaryColor)
938
- }.apply { setTextColor(textColor) }
939
- layerPropertyButtons[key] = chip
940
- addView(chip, LinearLayout.LayoutParams(dp(80), MATCH))
1029
+ actions.forEach { (key, label, icon) ->
1030
+ val node = createToolNode(icon, label) { onAction(key) }
1031
+ nodes[key] = node
1032
+ if (key == "delete") { node.icon.setColorFilter(Color.rgb(255, 100, 115)); node.label.setTextColor(Color.rgb(255, 100, 115)) }
1033
+ addView(node.root, LinearLayout.LayoutParams(dp(72), WRAP))
941
1034
  }
942
- addView(
943
- createIconButton(R.drawable.ic_content_copy, "Duplicate layer", textColor) {
944
- val original = currentSelectedLayer() ?: return@createIconButton
945
- 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))
946
- session.layerStack.commit { it + copy }
947
- selectLayer(copy.id)
948
- },
949
- LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin))
950
- )
951
- addView(
952
- createIconButton(R.drawable.ic_lock, "Toggle lock", textColor) {
953
- val id = selectedLayerId ?: return@createIconButton
954
- session.layerStack.commit { list -> list.map { if (it.id == id) it.copy(locked = !it.locked) else it } }
955
- onLayerStackChanged()
956
- }.also { layerIconButtons["lock"] = it },
957
- LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin))
958
- )
959
- addView(
960
- createIconButton(R.drawable.ic_visibility_off, "Toggle visibility", textColor) {
961
- val id = selectedLayerId ?: return@createIconButton
962
- session.layerStack.commit { list -> list.map { if (it.id == id) it.copy(visible = !it.visible) else it } }
963
- onLayerStackChanged()
964
- }.also { layerIconButtons["visibility"] = it },
965
- LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin))
966
- )
967
- addView(
968
- createIconButton(R.drawable.ic_chevron_right, "Bring to front", textColor) {
969
- val id = selectedLayerId ?: return@createIconButton
970
- session.layerStack.commit { list -> (list.filter { it.id != id } + list.first { it.id == id }) }
971
- onLayerStackChanged()
972
- }.also { layerIconButtons["front"] = it },
973
- LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin))
974
- )
975
- addView(
976
- createIconButton(R.drawable.ic_chevron_left, "Send to back", textColor) {
977
- val id = selectedLayerId ?: return@createIconButton
978
- session.layerStack.commit { list -> (listOf(list.first { it.id == id }) + list.filter { it.id != id }) }
979
- onLayerStackChanged()
980
- }.also { layerIconButtons["back"] = it },
981
- LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin))
982
- )
983
- addView(
984
- createIconButton(R.drawable.ic_delete, "Delete layer", textColor) {
985
- val id = selectedLayerId ?: return@createIconButton
986
- session.layerStack.commit { list -> list.filterNot { it.id == id } }
987
- selectLayer(null)
988
- onLayerStackChanged()
989
- },
990
- LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin))
991
- )
992
1035
  })
993
1036
  }, LinearLayout.LayoutParams(0, MATCH, 1f))
994
- addView(
995
- actionButton("Done") { selectLayer(null) }.apply { setTextColor(primaryColor) },
996
- LinearLayout.LayoutParams(dp(72), MATCH)
997
- )
1037
+ val done = createToolNode(R.drawable.ic_check, "Done") { onAction("done") }
1038
+ setToolNodeSelected(done, true, Color.WHITE)
1039
+ addView(done.root, LinearLayout.LayoutParams(dp(64), WRAP))
998
1040
  }
999
1041
  }
1000
1042
 
1001
- /** Adjust (image_adjustments_workspace mockup) — Brightness/Contrast/Saturation/etc sliders + Mirror. */
1002
1043
  private fun setAdjustMode(enabled: Boolean, session: PhotoEditSession) {
1003
1044
  adjustMode = enabled
1045
+ // The slider now lives inside the panel, so showing the panel shows it — one less thing to
1046
+ // keep in sync, and no stray full-width slider row left behind above the main dock.
1004
1047
  adjustSubBar.visibility = if (enabled) View.VISIBLE else View.GONE
1005
- adjustmentSeekBar.visibility = if (enabled) View.VISIBLE else View.GONE
1006
1048
  mainToolBar.visibility = if (enabled) View.GONE else View.VISIBLE
1007
1049
  layerOverlay.visibility = if (enabled) View.GONE else View.VISIBLE
1008
- if (enabled) { activeAdjustmentKey = "brightness"; syncAdjustmentSeekBar(session) }
1050
+ // Entering Adjust selects a control to scrub but never resets the values themselves, so
1051
+ // Adjust -> Filters -> Adjust round-trips keep whatever the user already dialled in.
1052
+ if (enabled) { activeAdjustmentKey = "brightness"; syncAdjustmentSeekBar(session); fadeInPanel(adjustSubBar) }
1009
1053
  refreshPhotoToolSelection()
1010
- refreshAdjustSelection()
1054
+ refreshAdjustSelection(session)
1011
1055
  }
1012
1056
 
1013
- /** Filters (filter_browser_library mockup) — preset thumbnail chips + strength slider. */
1057
+ /** Filters — preset thumbnail carousel with an intensity slider above it. */
1014
1058
  private fun setFiltersMode(enabled: Boolean, session: PhotoEditSession) {
1015
1059
  filtersMode = enabled
1016
1060
  filtersSubBar.visibility = if (enabled) View.VISIBLE else View.GONE
1017
- adjustmentSeekBar.visibility = if (enabled) View.VISIBLE else View.GONE
1018
1061
  mainToolBar.visibility = if (enabled) View.GONE else View.VISIBLE
1019
1062
  layerOverlay.visibility = if (enabled) View.GONE else View.VISIBLE
1020
- if (enabled) { activeAdjustmentKey = "filterStrength"; syncAdjustmentSeekBar(session) }
1063
+ if (enabled) { syncAdjustmentSeekBar(session); fadeInPanel(filtersSubBar) }
1021
1064
  refreshPhotoToolSelection()
1022
1065
  refreshFiltersSelection(session)
1023
1066
  }
1024
1067
 
1025
- private fun refreshAdjustSelection() {
1026
- val textColor = themeColor("textColor", DesignTokens.onSurface)
1027
- val primaryColor = themeColor("primaryColor", DesignTokens.primaryContainer)
1028
- refreshSelection(filterAdjustmentButtons, activeAdjustmentKey, textColor, primaryColor)
1068
+ /** Short cross-fade when a panel opens, per the "subtle transition" spec (no slide/scale drama). */
1069
+ private fun fadeInPanel(panel: View) {
1070
+ panel.animate().cancel()
1071
+ panel.alpha = 0f
1072
+ panel.animate().alpha(1f).setDuration(180).start()
1029
1073
  }
1030
1074
 
1031
- private fun refreshFiltersSelection(session: PhotoEditSession) {
1032
- val textColor = themeColor("textColor", DesignTokens.onSurface)
1075
+ private fun refreshAdjustSelection(session: PhotoEditSession) {
1033
1076
  val primaryColor = themeColor("primaryColor", DesignTokens.primaryContainer)
1034
- val activePreset = session.adjustments.filterPreset ?: ""
1035
- filterPresetCards.forEach { (id, pair) ->
1036
- val (frame, label) = pair
1037
- val selected = id == activePreset
1038
- frame.background = roundedDrawable(DesignTokens.surfaceContainerHighest, DesignTokens.radiusLg)
1039
- frame.foreground = if (selected) {
1040
- android.graphics.drawable.GradientDrawable().apply {
1041
- cornerRadius = dp(DesignTokens.radiusLg).toFloat()
1042
- setStroke(dp(2), DesignTokens.primaryContainer)
1043
- }
1077
+ val defaults = PhotoAdjustments()
1078
+ adjustmentStripItems.forEach { (key, item) ->
1079
+ if (key == "mirror") {
1080
+ // A toggle, so "selected" means "on" rather than "being scrubbed".
1081
+ item.setSelected(session.adjustments.mirror, primaryColor)
1082
+ item.setModified(false, primaryColor)
1044
1083
  } else {
1045
- null
1084
+ item.setSelected(key == activeAdjustmentKey, primaryColor)
1085
+ item.setModified(session.adjustments.value(key) != defaults.value(key), primaryColor)
1046
1086
  }
1047
- label.setTextColor(if (selected) primaryColor else DesignTokens.outline)
1048
- label.setTypeface(label.typeface, if (selected) android.graphics.Typeface.BOLD else android.graphics.Typeface.NORMAL)
1049
1087
  }
1050
1088
  }
1051
1089
 
1090
+ private fun refreshFiltersSelection(session: PhotoEditSession) {
1091
+ val primaryColor = themeColor("primaryColor", DesignTokens.primaryContainer)
1092
+ val activePreset = session.adjustments.filterPreset ?: ""
1093
+ filterPresetCards.forEach { (id, thumbnail) -> thumbnail.setSelected(id == activePreset, primaryColor) }
1094
+ // Intensity only means something once a preset is applied.
1095
+ filterIntensitySeekBar.visibility = if (activePreset.isEmpty()) View.INVISIBLE else View.VISIBLE
1096
+ }
1097
+
1098
+ /** Points whichever slider is on screen at the currently selected value. */
1052
1099
  private fun syncAdjustmentSeekBar(session: PhotoEditSession) {
1053
1100
  val (min, max) = PhotoAdjustments.rangeFor(activeAdjustmentKey)
1054
1101
  adjustmentSeekBar.max = (max - min).roundToInt()
1055
1102
  adjustmentSeekBar.progress = (session.adjustments.value(activeAdjustmentKey) - min).roundToInt()
1103
+ adjustmentSeekBar.labelText = photoAdjustmentDefinitions.firstOrNull { it.first == activeAdjustmentKey }?.second
1104
+ adjustmentSeekBar.valueFormatter = { progress -> formatAdjustmentValue(activeAdjustmentKey, min + progress) }
1105
+ // Bipolar adjustments get a centre tick at their neutral value; unipolar ones (blur) do not.
1106
+ adjustmentSeekBar.neutralProgress = if (min < 0f) (-min).roundToInt() else null
1107
+
1108
+ val (strengthMin, strengthMax) = PhotoAdjustments.rangeFor("filterStrength")
1109
+ filterIntensitySeekBar.max = (strengthMax - strengthMin).roundToInt()
1110
+ filterIntensitySeekBar.progress = (session.adjustments.filterStrength - strengthMin).roundToInt()
1111
+ }
1112
+
1113
+ private fun formatAdjustmentValue(key: String, value: Float): String {
1114
+ val rounded = value.roundToInt()
1115
+ return if (key == "blurRadius") "$rounded" else if (rounded > 0) "+$rounded" else "$rounded"
1056
1116
  }
1057
1117
 
1118
+ /**
1119
+ * Adjustments the processing engine actually implements, in carousel order. Deliberately not a
1120
+ * wishlist: every entry maps to a real [PhotoAdjustments] field, so there are no controls that
1121
+ * move a slider without changing the photo. (Highlights/shadows/tint/sharpness were removed from
1122
+ * the engine in an earlier pass and would need that processing restored before they could return.)
1123
+ */
1124
+ private val photoAdjustmentDefinitions = listOf(
1125
+ Triple("exposure", "Exposure", R.drawable.ic_flare),
1126
+ Triple("brightness", "Brightness", R.drawable.ic_light_mode),
1127
+ Triple("contrast", "Contrast", R.drawable.ic_opacity),
1128
+ Triple("saturation", "Saturation", R.drawable.ic_palette),
1129
+ Triple("temperature", "Warmth", R.drawable.ic_compare),
1130
+ Triple("blurRadius", "Blur", R.drawable.ic_filter_center_focus)
1131
+ )
1132
+
1133
+ /**
1134
+ * Adjust panel: `Adjust / Reset / Done` header, one shared slider bound to whichever adjustment is
1135
+ * selected, then a compact scrollable strip. Replaces the previous row of full-height rectangular
1136
+ * buttons, which wrapped "Brightness" onto two lines and gave `Done` a quarter of the screen.
1137
+ */
1058
1138
  private fun createAdjustSubBar(session: PhotoEditSession, imageView: ZoomableImageView): LinearLayout {
1059
- val toolbarColor = themeColor("toolbarColor", DesignTokens.surfaceContainerLow)
1060
- val textColor = themeColor("textColor", DesignTokens.onSurface)
1061
1139
  val primaryColor = themeColor("primaryColor", DesignTokens.primaryContainer)
1062
- val adjustmentChips = listOf(
1063
- "brightness" to "Brightness", "contrast" to "Contrast", "saturation" to "Saturation",
1064
- "exposure" to "Exposure", "temperature" to "Temp", "blurRadius" to "Blur"
1065
- )
1066
1140
  return LinearLayout(this).apply {
1067
- gravity = Gravity.CENTER_VERTICAL
1068
- setPadding(dp(12), 0, dp(12), 0)
1069
- setBackgroundColor(toolbarColor)
1070
- addView(HorizontalScrollView(this@PhotoVideoEditorActivity).apply {
1071
- isHorizontalScrollBarEnabled = false
1072
- addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
1073
- orientation = LinearLayout.HORIZONTAL
1074
- adjustmentChips.forEach { (key, label) ->
1075
- val chip = actionButton(label) {
1141
+ orientation = LinearLayout.VERTICAL
1142
+ setBackgroundColor(DesignTokens.editorSurface)
1143
+ addView(
1144
+ editorPanelHeader(
1145
+ context = this@PhotoVideoEditorActivity,
1146
+ title = "Adjust",
1147
+ accentColor = primaryColor,
1148
+ secondaryLabel = "Reset",
1149
+ onSecondary = {
1150
+ session.updateAdjustments {
1151
+ it.copy(brightness = 0f, contrast = 0f, saturation = 0f, exposure = 0f, temperature = 0f, blurRadius = 0f, mirror = false)
1152
+ }
1153
+ syncAdjustmentSeekBar(session)
1154
+ schedulePhotoPreviewRender()
1155
+ refreshAdjustSelection(session)
1156
+ },
1157
+ onDone = { setAdjustMode(false, session) }
1158
+ ),
1159
+ LinearLayout.LayoutParams(MATCH, dp(EditorPanelMetrics.HEADER_HEIGHT_DP))
1160
+ )
1161
+ addView(adjustmentSeekBar, LinearLayout.LayoutParams(MATCH, dp(EditorPanelMetrics.SLIDER_HEIGHT_DP)))
1162
+ addView(
1163
+ editorToolStrip(this@PhotoVideoEditorActivity) {
1164
+ photoAdjustmentDefinitions.forEach { (key, label, iconRes) ->
1165
+ val item = editorStripItem(this@PhotoVideoEditorActivity, iconRes, label) {
1076
1166
  activeAdjustmentKey = key
1077
1167
  syncAdjustmentSeekBar(session)
1078
- refreshAdjustSelection()
1079
- }.apply { setTextColor(textColor) }
1080
- filterAdjustmentButtons[key] = chip
1081
- addView(chip, LinearLayout.LayoutParams(dp(84), MATCH))
1168
+ refreshAdjustSelection(session)
1169
+ }
1170
+ adjustmentStripItems[key] = item
1171
+ addView(item.root, LinearLayout.LayoutParams(dp(EditorPanelMetrics.STRIP_ITEM_WIDTH_DP), WRAP))
1082
1172
  }
1083
- addView(
1084
- actionButton("Mirror") {
1085
- session.updateAdjustments { it.copy(mirror = !it.mirror) }
1086
- schedulePhotoPreviewRender()
1087
- }.apply { setTextColor(textColor) },
1088
- LinearLayout.LayoutParams(dp(84), MATCH)
1089
- )
1090
- addView(
1091
- actionButton("Reset") {
1092
- session.updateAdjustments { it.copy(brightness = 0f, contrast = 0f, saturation = 0f, exposure = 0f, temperature = 0f, blurRadius = 0f, mirror = false) }
1093
- syncAdjustmentSeekBar(session)
1094
- schedulePhotoPreviewRender()
1095
- refreshAdjustSelection()
1096
- }.apply { setTextColor(textColor) },
1097
- LinearLayout.LayoutParams(dp(72), MATCH)
1098
- )
1099
- })
1100
- }, LinearLayout.LayoutParams(0, MATCH, 1f))
1101
- addView(
1102
- actionButton("Done") { setAdjustMode(false, session) }.apply { setTextColor(primaryColor) },
1103
- LinearLayout.LayoutParams(dp(72), MATCH)
1173
+ // Mirror is a toggle, not a slider target, but it lives in the same strip so the panel has
1174
+ // one visual language rather than a stray button.
1175
+ val mirror = editorStripItem(this@PhotoVideoEditorActivity, R.drawable.ic_aspect_ratio, "Mirror") {
1176
+ session.updateAdjustments { it.copy(mirror = !it.mirror) }
1177
+ schedulePhotoPreviewRender()
1178
+ refreshAdjustSelection(session)
1179
+ }
1180
+ adjustmentStripItems["mirror"] = mirror
1181
+ addView(mirror.root, LinearLayout.LayoutParams(dp(EditorPanelMetrics.STRIP_ITEM_WIDTH_DP), WRAP))
1182
+ },
1183
+ LinearLayout.LayoutParams(MATCH, dp(EditorPanelMetrics.STRIP_HEIGHT_DP))
1104
1184
  )
1105
1185
  }
1106
1186
  }
1107
1187
 
1108
- /** Preset thumbnail card (64x64 preview + label), matching the "Preset Thumbnail Cards" component. */
1109
- private fun createPresetCard(thumbnail: Bitmap?, labelText: String, textColor: Int, onClick: () -> Unit): Triple<LinearLayout, FrameLayout, TextView> {
1110
- val thumbSize = dp(64)
1111
- val image = ImageView(this).apply {
1112
- thumbnail?.let { setImageBitmap(it) }
1113
- scaleType = ImageView.ScaleType.CENTER_CROP
1114
- }
1115
- val frame = FrameLayout(this).apply {
1116
- background = roundedDrawable(DesignTokens.surfaceContainerHighest, DesignTokens.radiusLg)
1117
- clipToOutline = true
1118
- addView(image, FrameLayout.LayoutParams(MATCH, MATCH))
1119
- }
1120
- val label = TextView(this).apply {
1121
- text = labelText
1122
- textSize = 11f
1123
- gravity = Gravity.CENTER
1124
- setTextColor(DesignTokens.outline)
1125
- setPadding(0, dp(DesignTokens.spaceXs), 0, 0)
1126
- maxLines = 1
1127
- }
1128
- val root = LinearLayout(this).apply {
1129
- orientation = LinearLayout.VERTICAL
1130
- gravity = Gravity.CENTER_HORIZONTAL
1131
- isClickable = true
1132
- setPadding(dp(4), 0, dp(4), 0)
1133
- addView(frame, LinearLayout.LayoutParams(thumbSize, thumbSize))
1134
- addView(label, LinearLayout.LayoutParams(dp(72), WRAP))
1135
- setOnClickListener { onClick() }
1136
- }
1137
- return Triple(root, frame, label)
1138
- }
1139
-
1188
+ /**
1189
+ * Filters panel: `Filters / Done` header, an intensity slider that appears once a preset is
1190
+ * applied, then the preset carousel. Same header/slider/strip skeleton as Adjust so the two read
1191
+ * as one system.
1192
+ */
1140
1193
  private fun createFiltersSubBar(session: PhotoEditSession, imageView: ZoomableImageView): LinearLayout {
1141
- val toolbarColor = themeColor("toolbarColor", DesignTokens.surfaceContainerLow)
1142
- val textColor = themeColor("textColor", DesignTokens.onSurface)
1143
1194
  val primaryColor = themeColor("primaryColor", DesignTokens.primaryContainer)
1144
1195
  return LinearLayout(this).apply {
1145
- gravity = Gravity.CENTER_VERTICAL
1146
- setPadding(dp(12), dp(8), dp(12), dp(8))
1147
- setBackgroundColor(toolbarColor)
1148
- addView(HorizontalScrollView(this@PhotoVideoEditorActivity).apply {
1149
- isHorizontalScrollBarEnabled = false
1150
- addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
1151
- orientation = LinearLayout.HORIZONTAL
1152
- gravity = Gravity.CENTER_VERTICAL
1153
- val (originalCard, originalFrame, originalLabel) = createPresetCard(session.baseBitmap, "Original", textColor) {
1196
+ orientation = LinearLayout.VERTICAL
1197
+ setBackgroundColor(DesignTokens.editorSurface)
1198
+ addView(
1199
+ editorPanelHeader(
1200
+ context = this@PhotoVideoEditorActivity,
1201
+ title = "Filters",
1202
+ accentColor = primaryColor,
1203
+ onDone = { setFiltersMode(false, session) }
1204
+ ),
1205
+ LinearLayout.LayoutParams(MATCH, dp(EditorPanelMetrics.HEADER_HEIGHT_DP))
1206
+ )
1207
+ addView(filterIntensitySeekBar, LinearLayout.LayoutParams(MATCH, dp(EditorPanelMetrics.SLIDER_HEIGHT_DP)))
1208
+ addView(
1209
+ editorToolStrip(this@PhotoVideoEditorActivity) {
1210
+ val original = editorFilterThumbnail(this@PhotoVideoEditorActivity, "Original") {
1154
1211
  session.updateAdjustments { it.copy(filterPreset = null, filterStrength = 100f) }
1155
1212
  syncAdjustmentSeekBar(session)
1156
1213
  schedulePhotoPreviewRender()
1157
1214
  refreshFiltersSelection(session)
1158
1215
  }
1159
- addView(originalCard, LinearLayout.LayoutParams(WRAP, WRAP).apply { marginEnd = dp(DesignTokens.spaceSm) })
1216
+ original.image.setImageBitmap(session.baseBitmap)
1217
+ filterPresetCards[""] = original
1218
+ addView(original.root, LinearLayout.LayoutParams(WRAP, WRAP).apply { marginEnd = dp(DesignTokens.spaceSm) })
1160
1219
  PhotoFilterPresets.PRESET_IDS.forEach { id ->
1161
- val (card, frame, label) = createPresetCard(null, PhotoFilterPresets.labelFor(id), textColor) {
1220
+ val thumbnail = editorFilterThumbnail(this@PhotoVideoEditorActivity, PhotoFilterPresets.labelFor(id)) {
1162
1221
  session.updateAdjustments { it.copy(filterPreset = if (it.filterPreset == id) null else id) }
1163
- activeAdjustmentKey = "filterStrength"
1164
1222
  syncAdjustmentSeekBar(session)
1165
1223
  schedulePhotoPreviewRender()
1166
1224
  refreshFiltersSelection(session)
1167
1225
  }
1168
- val thumbnailView = frame.getChildAt(0) as ImageView
1169
- filterThumbnailLoader.load(session.baseBitmap, id, dp(64)) { thumbnailView.setImageBitmap(it) }
1170
- filterPresetCards[id] = Pair(frame, label)
1171
- addView(card, LinearLayout.LayoutParams(WRAP, WRAP).apply { marginEnd = dp(DesignTokens.spaceSm) })
1226
+ // Thumbnails render off a cached downscaled copy of the photo, never the full-resolution
1227
+ // bitmap 17 presets at full size would stall the panel on open.
1228
+ filterThumbnailLoader.load(session.baseBitmap, id, dp(EditorPanelMetrics.THUMBNAIL_WIDTH_DP)) {
1229
+ thumbnail.image.setImageBitmap(it)
1230
+ }
1231
+ filterPresetCards[id] = thumbnail
1232
+ addView(thumbnail.root, LinearLayout.LayoutParams(WRAP, WRAP).apply { marginEnd = dp(DesignTokens.spaceSm) })
1172
1233
  }
1173
- filterPresetCards[""] = Pair(originalFrame, originalLabel)
1174
- })
1175
- }, LinearLayout.LayoutParams(0, MATCH, 1f))
1176
- addView(
1177
- actionButton("Done") { setFiltersMode(false, session) }.apply { setTextColor(primaryColor) },
1178
- LinearLayout.LayoutParams(dp(72), MATCH)
1234
+ },
1235
+ LinearLayout.LayoutParams(MATCH, dp(EditorPanelMetrics.STRIP_HEIGHT_DP + 24))
1179
1236
  )
1180
1237
  }
1181
1238
  }
@@ -1185,118 +1242,39 @@ class PhotoVideoEditorActivity : Activity() {
1185
1242
  cropMode = enabled
1186
1243
  imageView.panZoomEnabled = !enabled
1187
1244
  overlay.visibility = if (enabled) View.VISIBLE else View.GONE
1188
- straightenSeekBar.visibility = if (enabled) View.VISIBLE else View.GONE
1189
1245
  cropSubBar.visibility = if (enabled) View.VISIBLE else View.GONE
1190
1246
  mainToolBar.visibility = if (enabled) View.GONE else View.VISIBLE
1191
1247
  layerOverlay.visibility = if (enabled) View.GONE else View.VISIBLE
1192
- if (enabled) {
1193
- imageView.resetToFit()
1194
- val state = session.state
1195
- val isIdentityCrop = state.cropLeft == 0f && state.cropTop == 0f && state.cropRight == 1f && state.cropBottom == 1f
1196
- overlay.setImageBounds(imageView.currentImageBounds(), resetCrop = isIdentityCrop)
1197
- overlay.setCrop(state.cropLeft, state.cropTop, state.cropRight, state.cropBottom)
1198
- overlay.setAspectRatio(state.aspectRatio)
1199
- } else {
1200
- cropEntryState = null
1201
- }
1248
+ if (!enabled) cropEntryState = null
1249
+ photoCropPanel?.sync(session.state)
1250
+ schedulePhotoPreviewRender()
1202
1251
  refreshPhotoToolSelection()
1203
- refreshSelection(cropAspectButtons, session.state.aspectRatio, themeColor("textColor", DesignTokens.onSurface), themeColor("primaryColor", DesignTokens.primaryContainer))
1204
1252
  }
1205
1253
 
1206
1254
  private fun cancelCropMode(session: PhotoEditSession, imageView: ZoomableImageView, overlay: CropOverlayView) {
1207
1255
  cropEntryState?.let { snapshot -> session.update { snapshot } }
1208
- straightenSeekBar.progress = ((cropEntryState?.straightenDegrees ?: 0f) + 45f).roundToInt()
1209
- cropEntryState = null
1210
- schedulePhotoPreviewRender()
1211
1256
  setCropMode(false, session, imageView, overlay)
1212
1257
  }
1213
1258
 
1214
1259
  private fun createCropSubBar(session: PhotoEditSession, imageView: ZoomableImageView, overlay: CropOverlayView): LinearLayout {
1215
- val toolbarColor = themeColor("toolbarColor", DesignTokens.surfaceContainerLow)
1216
- val textColor = themeColor("textColor", DesignTokens.onSurface)
1217
- val primaryColor = themeColor("primaryColor", DesignTokens.primaryContainer)
1218
- val aspectPresets = listOf<Pair<String, Float?>>(
1219
- "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
1220
- )
1221
- return LinearLayout(this).apply {
1222
- orientation = LinearLayout.VERTICAL
1223
- setPadding(0, dp(6), 0, dp(6))
1224
- setBackgroundColor(toolbarColor)
1225
-
1226
- addView(TextView(this@PhotoVideoEditorActivity).apply {
1227
- text = "Crop Mode • TRANSFORM & ASPECT RATIO"
1228
- textSize = 14f
1229
- setTypeface(typeface, android.graphics.Typeface.BOLD)
1230
- setTextColor(textColor)
1231
- gravity = Gravity.CENTER_VERTICAL
1232
- setPadding(dp(DesignTokens.spaceLg), 0, dp(DesignTokens.spaceLg), 0)
1233
- }, LinearLayout.LayoutParams(MATCH, dp(36)))
1234
-
1235
- // Rotate/Flip row — moved here from the main tool rail to match the crop_transform_editor mockup.
1236
- addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
1237
- orientation = LinearLayout.HORIZONTAL
1238
- gravity = Gravity.CENTER
1239
- addView(
1240
- createIconButton(R.drawable.ic_rotate_left, "Rotate left", textColor) {
1241
- session.update { it.rotatedRight().rotatedRight().rotatedRight() }
1242
- schedulePhotoPreviewRender()
1243
- imageView.post { imageView.resetToFit(); overlay.setImageBounds(imageView.currentImageBounds(), resetCrop = true) }
1244
- },
1245
- LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin))
1246
- )
1247
- addView(
1248
- createIconButton(R.drawable.ic_rotate_right, "Rotate right", textColor) {
1249
- session.update { it.rotatedRight() }
1250
- schedulePhotoPreviewRender()
1251
- imageView.post { imageView.resetToFit(); overlay.setImageBounds(imageView.currentImageBounds(), resetCrop = true) }
1252
- },
1253
- LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin))
1254
- )
1255
- }, LinearLayout.LayoutParams(MATCH, dp(DesignTokens.touchTargetMin)))
1256
-
1257
- addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
1258
- gravity = Gravity.CENTER_VERTICAL
1259
- setPadding(dp(12), 0, dp(12), 0)
1260
- addView(
1261
- actionButton("Cancel") { cancelCropMode(session, imageView, overlay) }.apply { setTextColor(textColor) },
1262
- LinearLayout.LayoutParams(dp(72), MATCH)
1263
- )
1264
- addView(HorizontalScrollView(this@PhotoVideoEditorActivity).apply {
1265
- isHorizontalScrollBarEnabled = false
1266
- addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
1267
- orientation = LinearLayout.HORIZONTAL
1268
- aspectPresets.forEach { (label, ratio) ->
1269
- val chip = actionButton(label) {
1270
- overlay.setAspectRatio(ratio)
1271
- session.update { it.withAspectRatio(ratio) }
1272
- refreshSelection(cropAspectButtons, ratio, textColor, primaryColor)
1273
- }.apply { setTextColor(textColor) }
1274
- cropAspectButtons[ratio] = chip
1275
- addView(chip, LinearLayout.LayoutParams(dp(64), MATCH))
1276
- }
1277
- addView(
1278
- actionButton("Reset") {
1279
- session.update { it.reset() }
1280
- overlay.setAspectRatio(null)
1281
- straightenSeekBar.progress = 45
1282
- schedulePhotoPreviewRender()
1283
- imageView.resetToFit()
1284
- overlay.setImageBounds(imageView.currentImageBounds(), resetCrop = true)
1285
- refreshSelection(cropAspectButtons, null, textColor, primaryColor)
1286
- }.apply { setTextColor(textColor) },
1287
- LinearLayout.LayoutParams(dp(72), MATCH)
1288
- )
1289
- })
1290
- }, LinearLayout.LayoutParams(0, MATCH, 1f))
1291
- addView(
1292
- actionButton("Done") { setCropMode(false, session, imageView, overlay) }.apply { setTextColor(primaryColor) },
1293
- LinearLayout.LayoutParams(dp(72), MATCH)
1294
- )
1295
- }, LinearLayout.LayoutParams(MATCH, dp(56)))
1296
- }
1260
+ fun refresh() { photoCropPanel?.sync(session.state); schedulePhotoPreviewRender() }
1261
+ return CropPanel(this, themeColor("primaryColor", DesignTokens.primaryContainer), themeColor("toolbarColor", DesignTokens.surfaceContainerLow), themeColor("textColor", DesignTokens.onSurface),
1262
+ originalRatio = { session.baseBitmap?.let { it.width.toFloat() / it.height } ?: 1f },
1263
+ onReset = { session.update { it.reset() }; refresh() },
1264
+ onDone = { setCropMode(false, session, imageView, overlay) },
1265
+ onCancel = { cancelCropMode(session, imageView, overlay) },
1266
+ onStraighten = { value -> session.update { it.withStraighten(value) }; refresh() },
1267
+ onRotate = {
1268
+ session.update { it.rotatedRight().copy(zoom = 1f, panX = 0f, panY = 0f, aspectRatio = null, aspectPreset = "Free") }; refresh()
1269
+ },
1270
+ onRatio = { label, ratio ->
1271
+ session.update { it.copy(aspectRatio = ratio, aspectPreset = label) }
1272
+ overlay.setAspectRatio(ratio)
1273
+ photoCropPanel?.sync(session.state)
1274
+ }
1275
+ ).also { photoCropPanel = it; it.sync(session.state) }
1297
1276
  }
1298
1277
 
1299
- /** 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. */
1300
1278
  private fun setResizeMode(enabled: Boolean, session: PhotoEditSession, imageView: ZoomableImageView, overlay: CropOverlayView) {
1301
1279
  resizeMode = enabled
1302
1280
  imageView.panZoomEnabled = !enabled
@@ -1410,7 +1388,12 @@ class PhotoVideoEditorActivity : Activity() {
1410
1388
  root.addView(header, LinearLayout.LayoutParams(MATCH, headerHeight))
1411
1389
 
1412
1390
  val preview = FrameLayout(this).apply { setBackgroundColor(Color.BLACK) }
1413
- root.addView(preview, LinearLayout.LayoutParams(MATCH, 0, 1f))
1391
+ root.addView(
1392
+ preview,
1393
+ LinearLayout.LayoutParams(MATCH, 0, 1f).apply {
1394
+ setMargins(0, dp(DesignTokens.spaceXs), 0, dp(DesignTokens.spaceXs))
1395
+ }
1396
+ )
1414
1397
 
1415
1398
  val session = VideoEditSession(this, sourceUri)
1416
1399
  videoSession = session
@@ -1443,6 +1426,7 @@ class PhotoVideoEditorActivity : Activity() {
1443
1426
  useController = false
1444
1427
  contentDescription = "Selected video preview"
1445
1428
  }
1429
+ cropPlayerView = playerView
1446
1430
  preview.addView(playerView, FrameLayout.LayoutParams(MATCH, MATCH))
1447
1431
 
1448
1432
  val overlayView = ImageView(this).apply {
@@ -1454,9 +1438,33 @@ class PhotoVideoEditorActivity : Activity() {
1454
1438
  videoOverlayImageView = overlayView
1455
1439
  preview.addView(overlayView, FrameLayout.LayoutParams(MATCH, MATCH))
1456
1440
 
1441
+ videoCropOverlay = CropOverlayView(this).apply {
1442
+ visibility = View.GONE
1443
+ onCropChanged = { l, t, r, b -> session.update { it.copy(crop = it.crop.withCrop(l, t, r, b)) } }
1444
+ onViewportChanged = { zoom, x, y -> session.update { it.copy(crop = it.crop.copy(zoom = zoom, panX = x, panY = y)) } }
1445
+ onMediaBoundsChanged = { bounds ->
1446
+ val fitted = videoMediaBounds(session, playerView, false)
1447
+ if (fitted.width() > 0) {
1448
+ playerView.pivotX = playerView.width / 2f; playerView.pivotY = playerView.height / 2f
1449
+ playerView.scaleX = bounds.width() / fitted.width(); playerView.scaleY = playerView.scaleX
1450
+ playerView.translationX = bounds.centerX() - fitted.centerX(); playerView.translationY = bounds.centerY() - fitted.centerY()
1451
+ }
1452
+ }
1453
+ }
1454
+ preview.addView(videoCropOverlay, FrameLayout.LayoutParams(MATCH, MATCH))
1455
+ preview.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
1456
+ if (videoCropMode) videoCropOverlay.restore(videoMediaBounds(session, playerView, false), session.state.crop)
1457
+ }
1457
1458
  val layerOverlay = LayerOverlayView(this)
1458
1459
  videoLayerOverlay = layerOverlay
1459
1460
  preview.addView(layerOverlay, FrameLayout.LayoutParams(MATCH, MATCH))
1461
+ layerOverlay.onLayerDelete = { id ->
1462
+ session.layerStack.commit { list -> list.filterNot { it.id == id } }
1463
+ selectVideoLayer(null, session); onVideoLayerStackChanged()
1464
+ }
1465
+ layerOverlay.onLayerDoubleTapped = { id ->
1466
+ session.layerStack.layers.firstOrNull { it.id == id && it.type == LayerType.TEXT }?.let { showVideoTextInputDialog(session, it) }
1467
+ }
1460
1468
  layerOverlay.onLayerTapped = { id -> selectVideoLayer(id, session) }
1461
1469
  layerOverlay.onLayerTransformChanged = { id, x, y, scale, rotation ->
1462
1470
  if (videoDragStartSnapshot == null) videoDragStartSnapshot = session.layerStack.layers
@@ -1472,12 +1480,15 @@ class PhotoVideoEditorActivity : Activity() {
1472
1480
  onVideoLayerStackChanged()
1473
1481
  }
1474
1482
 
1475
- val playPause = Button(this).apply {
1476
- text = ""
1483
+ val accentColor = themeColor("primaryColor", DesignTokens.primaryContainer)
1484
+
1485
+ val playPause = ImageButton(this).apply {
1477
1486
  contentDescription = "Play video"
1478
- setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_play, 0, 0, 0)
1479
- gravity = Gravity.CENTER
1480
- setBackgroundColor(Color.TRANSPARENT)
1487
+ setImageResource(R.drawable.ic_play)
1488
+ setColorFilter(Color.WHITE)
1489
+ scaleType = ImageView.ScaleType.CENTER_INSIDE
1490
+ background = android.graphics.drawable.InsetDrawable(circleDrawable(Color.argb(140, 0, 0, 0)), dp(2))
1491
+ setPadding(dp(9), dp(9), dp(9), dp(9))
1481
1492
  setOnClickListener {
1482
1493
  if (session.player.isPlaying) {
1483
1494
  session.player.pause()
@@ -1487,38 +1498,64 @@ class PhotoVideoEditorActivity : Activity() {
1487
1498
  }
1488
1499
  session.player.play()
1489
1500
  }
1501
+ setVideoControlsVisible(true)
1490
1502
  }
1503
+ applyPressScale()
1491
1504
  }
1492
1505
  playPauseButton = playPause
1493
1506
  val time = TextView(this).apply {
1494
1507
  text = "0:00 / 0:00"
1495
- setTextColor(textColor)
1496
- textSize = 12f
1508
+ setTextColor(Color.WHITE)
1509
+ textSize = 11f
1497
1510
  }
1498
1511
  timeLabel = time
1499
1512
  val transport = LinearLayout(this).apply {
1500
1513
  orientation = LinearLayout.HORIZONTAL
1501
1514
  gravity = Gravity.CENTER_VERTICAL
1502
- setBackgroundColor(toolbarColor)
1503
- setPadding(dp(8), 0, dp(8), 0)
1504
- addView(playPause, LinearLayout.LayoutParams(dp(72), WRAP))
1505
- addView(time, LinearLayout.LayoutParams(WRAP, WRAP))
1515
+ addView(playPause, LinearLayout.LayoutParams(dp(44), dp(44)))
1516
+ addView(time, LinearLayout.LayoutParams(WRAP, WRAP).apply { marginStart = dp(DesignTokens.spaceSm) })
1506
1517
  }
1507
- root.addView(transport, LinearLayout.LayoutParams(MATCH, dp(40)))
1508
1518
 
1519
+ val seekTrack = android.graphics.drawable.LayerDrawable(
1520
+ arrayOf(
1521
+ roundedDrawable(Color.argb(90, 255, 255, 255), 999),
1522
+ android.graphics.drawable.ClipDrawable(roundedDrawable(accentColor, 999), Gravity.START, android.graphics.drawable.ClipDrawable.HORIZONTAL)
1523
+ )
1524
+ ).apply {
1525
+ setId(0, android.R.id.background)
1526
+ setId(1, android.R.id.progress)
1527
+ setLayerInset(0, 0, dp(9), 0, dp(9))
1528
+ setLayerInset(1, 0, dp(9), 0, dp(9))
1529
+ }
1509
1530
  val seek = SeekBar(this).apply {
1531
+ contentDescription = "Playback position"
1532
+ progressDrawable = seekTrack
1533
+ thumb = circleDrawable(Color.WHITE).apply { setSize(dp(12), dp(12)) }
1534
+ splitTrack = false
1510
1535
  max = 1000
1511
1536
  setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
1512
1537
  override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
1513
1538
  if (!fromUser || session.durationMs <= 0) return
1514
1539
  session.seekToGlobalMs(session.durationMs * progress / 1000)
1540
+ updateTimeLabel()
1515
1541
  }
1516
- override fun onStartTrackingTouch(seekBar: SeekBar?) { seeking = true }
1517
- override fun onStopTrackingTouch(seekBar: SeekBar?) { seeking = false }
1542
+ override fun onStartTrackingTouch(seekBar: SeekBar?) { seeking = true; setVideoControlsVisible(true) }
1543
+ override fun onStopTrackingTouch(seekBar: SeekBar?) { seeking = false; scheduleAutoHideVideoControls(session) }
1518
1544
  })
1519
1545
  }
1520
1546
  positionSeekBar = seek
1521
- root.addView(seek, LinearLayout.LayoutParams(MATCH, WRAP).apply { setMargins(dp(8), 0, dp(8), 0) })
1547
+
1548
+ val controlsOverlay = LinearLayout(this).apply {
1549
+ orientation = LinearLayout.VERTICAL
1550
+ setBackgroundColor(Color.argb(150, 0, 0, 0))
1551
+ setPadding(dp(DesignTokens.spaceSm), dp(DesignTokens.spaceXs), dp(DesignTokens.spaceSm), dp(DesignTokens.spaceXs))
1552
+ addView(transport, LinearLayout.LayoutParams(WRAP, WRAP))
1553
+ addView(seek, LinearLayout.LayoutParams(MATCH, dp(24)))
1554
+ }
1555
+ videoControlsOverlay = controlsOverlay
1556
+ preview.addView(controlsOverlay, FrameLayout.LayoutParams(MATCH, WRAP, Gravity.BOTTOM))
1557
+ preview.isClickable = true
1558
+ preview.setOnClickListener { toggleVideoControls(session) }
1522
1559
 
1523
1560
  val trim = TrimRangeView(this).apply {
1524
1561
  // Bound to the currently selected clip (Milestone 7): fractions are relative to that clip's
@@ -1565,6 +1602,17 @@ class PhotoVideoEditorActivity : Activity() {
1565
1602
  }
1566
1603
  }
1567
1604
 
1605
+ val cropPanel = CropPanel(this, themeColor("primaryColor", DesignTokens.primaryContainer), toolbarColor, textColor,
1606
+ originalRatio = { session.sourceWidth.toFloat() / session.sourceHeight.coerceAtLeast(1) },
1607
+ onReset = { session.update { it.copy(crop = PhotoTransformState()) }; refreshVideoCrop(session) },
1608
+ onDone = { setVideoCropMode(false, session) },
1609
+ onCancel = { videoCropEntryState?.let { saved -> session.update { it.copy(crop = saved) } }; setVideoCropMode(false, session) },
1610
+ onStraighten = { value -> session.update { it.copy(crop = it.crop.withStraighten(value)) }; refreshVideoCrop(session) },
1611
+ onRotate = { session.update { it.copy(crop = it.crop.rotatedRight().copy(zoom = 1f, panX = 0f, panY = 0f, aspectRatio = null, aspectPreset = "Free")) }; refreshVideoCrop(session) },
1612
+ onRatio = { label, ratio -> session.update { it.copy(crop = it.crop.copy(aspectRatio = ratio, aspectPreset = label)) }; videoCropOverlay.setAspectRatio(ratio); videoCropPanel?.sync(session.state.crop) }
1613
+ ).apply { visibility = View.GONE }
1614
+ videoCropPanel = cropPanel
1615
+ root.addView(cropPanel, LinearLayout.LayoutParams(MATCH, dp(CROP_SUB_BAR_HEIGHT_DP)))
1568
1616
  val aspectBar = createVideoAspectSubBar(session, playerView)
1569
1617
  aspectBar.visibility = View.GONE
1570
1618
  videoAspectSubBar = aspectBar
@@ -1589,32 +1637,43 @@ class PhotoVideoEditorActivity : Activity() {
1589
1637
  videoFiltersSubBar = filtersBar
1590
1638
  root.addView(filtersBar, LinearLayout.LayoutParams(MATCH, dp(56)))
1591
1639
 
1592
- val layerPropertySeek = SeekBar(this).apply {
1640
+ val layerPropertySeek = EditorToolSlider(this).apply {
1641
+ progressTintList = android.content.res.ColorStateList.valueOf(themeColor("primaryColor", DesignTokens.primaryContainer))
1642
+ thumbTintList = progressTintList
1593
1643
  visibility = View.GONE
1594
1644
  setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
1595
1645
  override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
1596
1646
  if (fromUser) onVideoLayerPropertyChanged(progress, session)
1597
1647
  }
1598
- override fun onStartTrackingTouch(seekBar: SeekBar?) {}
1599
- override fun onStopTrackingTouch(seekBar: SeekBar?) {}
1648
+ override fun onStartTrackingTouch(seekBar: SeekBar?) { videoDragStartSnapshot = session.layerStack.layers.map { it.copy() } }
1649
+ override fun onStopTrackingTouch(seekBar: SeekBar?) { videoDragStartSnapshot?.let { session.layerStack.commitSnapshot(it) }; videoDragStartSnapshot = null; onVideoLayerStackChanged() }
1600
1650
  })
1601
1651
  }
1602
1652
  videoLayerPropertySeekBar = layerPropertySeek
1603
1653
  root.addView(layerPropertySeek, LinearLayout.LayoutParams(MATCH, WRAP).apply { setMargins(dp(8), dp(4), dp(8), 0) })
1604
1654
 
1655
+ videoColorRow = createTextPalette(true) { color ->
1656
+ val id = selectedVideoLayerId ?: return@createTextPalette
1657
+ session.layerStack.commit { list -> list.map { if (it.id == id) it.copy(textColor = color) else it } }
1658
+ onVideoLayerStackChanged()
1659
+ setVideoLayerToolBarVisible(true, session)
1660
+ }.apply { visibility = View.GONE }
1661
+ root.addView(videoColorRow, LinearLayout.LayoutParams(MATCH, dp(48)))
1605
1662
  val layerBar = createVideoLayerToolBar(session)
1606
1663
  layerBar.visibility = View.GONE
1607
1664
  videoLayerToolBar = layerBar
1608
- root.addView(layerBar, LinearLayout.LayoutParams(MATCH, dp(56)))
1665
+ root.addView(layerBar, LinearLayout.LayoutParams(MATCH, dp(76)))
1609
1666
 
1610
- val toolbarHeight = dp(76)
1667
+ // The dock builds its own horizontal inset and pill; this holder only reserves the row height
1668
+ // (plus an 8dp gap) so the system-bar inset lands on transparent space, not on the pill.
1669
+ val toolbarHeight = dp(VIDEO_DOCK_BAR_HEIGHT_DP)
1611
1670
  val toolbar = createVideoToolBar(session, playerView)
1612
1671
  videoToolBar = toolbar
1613
1672
  root.addView(toolbar, LinearLayout.LayoutParams(MATCH, toolbarHeight))
1614
1673
  applySystemBarInsets(
1615
1674
  header,
1616
1675
  headerHeight,
1617
- listOf(toolbar to toolbarHeight, aspectBar to dp(CROP_SUB_BAR_HEIGHT_DP), filtersBar to dp(56), layerBar to dp(56))
1676
+ listOf(cropPanel to dp(CROP_SUB_BAR_HEIGHT_DP), toolbar to toolbarHeight, aspectBar to dp(CROP_SUB_BAR_HEIGHT_DP), filtersBar to dp(56), layerBar to dp(76))
1618
1677
  )
1619
1678
 
1620
1679
  startPositionPolling(session)
@@ -1622,6 +1681,7 @@ class PhotoVideoEditorActivity : Activity() {
1622
1681
  }
1623
1682
 
1624
1683
  private fun startPositionPolling(session: VideoEditSession) {
1684
+ var wasPlaying = false
1625
1685
  val runnable = object : Runnable {
1626
1686
  override fun run() {
1627
1687
  if (!seeking && session.durationMs > 0) {
@@ -1629,8 +1689,12 @@ class PhotoVideoEditorActivity : Activity() {
1629
1689
  updateTimeLabel()
1630
1690
  }
1631
1691
  val playing = session.player.isPlaying
1632
- playPauseButton.setCompoundDrawablesWithIntrinsicBounds(if (playing) R.drawable.ic_pause else R.drawable.ic_play, 0, 0, 0)
1692
+ playPauseButton.setImageResource(if (playing) R.drawable.ic_pause else R.drawable.ic_play)
1633
1693
  playPauseButton.contentDescription = if (playing) "Pause video" else "Play video"
1694
+ if (playing != wasPlaying) {
1695
+ wasPlaying = playing
1696
+ if (playing) scheduleAutoHideVideoControls(session) else setVideoControlsVisible(true)
1697
+ }
1634
1698
  refreshVideoOverlayPreview(session)
1635
1699
  positionPollHandler.postDelayed(this, 250)
1636
1700
  }
@@ -1639,6 +1703,35 @@ class PhotoVideoEditorActivity : Activity() {
1639
1703
  positionPollHandler.postDelayed(runnable, 250)
1640
1704
  }
1641
1705
 
1706
+ /** Shows/hides the in-preview playback controls with a subtle fade (spec: auto-hide while playing). */
1707
+ private fun setVideoControlsVisible(visible: Boolean) {
1708
+ if (!::videoControlsOverlay.isInitialized) return
1709
+ positionPollHandler.removeCallbacks(hideVideoControlsRunnable)
1710
+ videoControlsVisible = visible
1711
+ videoControlsOverlay.animate().cancel()
1712
+ if (visible) {
1713
+ videoControlsOverlay.visibility = View.VISIBLE
1714
+ videoControlsOverlay.animate().alpha(1f).setDuration(200).start()
1715
+ } else {
1716
+ videoControlsOverlay.animate().alpha(0f).setDuration(200)
1717
+ .withEndAction { if (!videoControlsVisible) videoControlsOverlay.visibility = View.INVISIBLE }.start()
1718
+ }
1719
+ }
1720
+
1721
+ private fun scheduleAutoHideVideoControls(session: VideoEditSession) {
1722
+ positionPollHandler.removeCallbacks(hideVideoControlsRunnable)
1723
+ if (session.player.isPlaying) positionPollHandler.postDelayed(hideVideoControlsRunnable, 2500)
1724
+ }
1725
+
1726
+ private fun toggleVideoControls(session: VideoEditSession) {
1727
+ if (videoControlsVisible) {
1728
+ setVideoControlsVisible(false)
1729
+ } else {
1730
+ setVideoControlsVisible(true)
1731
+ scheduleAutoHideVideoControls(session)
1732
+ }
1733
+ }
1734
+
1642
1735
  private fun updateTimeLabel() {
1643
1736
  val session = videoSession ?: return
1644
1737
  timeLabel.text = "${formatMs(session.globalPositionMs())} / ${formatMs(session.durationMs)}"
@@ -1668,9 +1761,16 @@ class PhotoVideoEditorActivity : Activity() {
1668
1761
  videoOverlayRenderKey = null
1669
1762
  return
1670
1763
  }
1671
- val videoSize = session.player.videoSize
1672
- val sourceWidth = videoSize.width.takeIf { it > 0 } ?: 1280
1673
- val sourceHeight = videoSize.height.takeIf { it > 0 } ?: 720
1764
+ // Deliberately NOT `session.player.videoSize`: ExoPlayer's live-reported size is an
1765
+ // independent source of truth from `videoOutputSize()` (which drives `computeVideoLetterboxBounds()`
1766
+ // above, i.e. the touch/gesture-normalization bounds for `videoLayerOverlay`). The two can disagree
1767
+ // (e.g. before the first decoded frame reports a size, or if the effects pipeline republishes a
1768
+ // different size under crop), which silently shifts a layer's normalized x/y/scale relative to what
1769
+ // the user saw on screen. Sourcing both from the same deterministic function keeps preview (this
1770
+ // bitmap) and gesture math in lockstep, and matches what `VideoExporter` computes for the real frame.
1771
+ val (sourceWidthF, sourceHeightF) = videoOutputSize(session, cropped = !videoCropMode)
1772
+ val sourceWidth = sourceWidthF.roundToInt().coerceAtLeast(1)
1773
+ val sourceHeight = sourceHeightF.roundToInt().coerceAtLeast(1)
1674
1774
  val previewScale = minOf(1f, 1280f / maxOf(sourceWidth, sourceHeight))
1675
1775
  val width = (sourceWidth * previewScale).roundToInt().coerceAtLeast(2)
1676
1776
  val height = (sourceHeight * previewScale).roundToInt().coerceAtLeast(2)
@@ -1696,23 +1796,62 @@ class PhotoVideoEditorActivity : Activity() {
1696
1796
  return bitmap
1697
1797
  }
1698
1798
 
1699
- /** Where the video actually renders within [view] under `FIT_CENTER`-style letterboxing (mirrors `ZoomableImageView.resetToFit`'s math). */
1700
- private fun computeVideoLetterboxBounds(session: VideoEditSession, view: View): android.graphics.RectF {
1701
- val viewWidth = view.width.toFloat()
1702
- val viewHeight = view.height.toFloat()
1703
- val videoSize = session.player.videoSize
1704
- if (viewWidth <= 0 || viewHeight <= 0 || videoSize.width <= 0 || videoSize.height <= 0) {
1705
- return android.graphics.RectF(0f, 0f, viewWidth, viewHeight)
1799
+ /**
1800
+ * Where the video actually renders within [view] under `FIT_CENTER`-style letterboxing (mirrors
1801
+ * `ZoomableImageView.resetToFit`'s math). This is the SINGLE source of truth for overlay-relative
1802
+ * coordinates: [computeVideoLetterboxBounds] (touch/gesture normalization) and
1803
+ * [refreshVideoOverlayPreview] (the bitmap layers actually draw into) both derive their size from
1804
+ * here, so a layer's normalized x/y/scale always means the same on-screen place in both.
1805
+ *
1806
+ * Known gap (not currently reachable from the UI — the video tool rail only exposes Crop/Text/
1807
+ * Stickers, not a whole-video "Rotate" tool): [VideoTransformState.rotationDegrees] (the top-level
1808
+ * rotate field, distinct from `crop.rotationDegrees` handled below) is applied to `playerView` as a
1809
+ * cosmetic `View.rotation` only and is never folded in here, while `VideoExporter` bakes it into the
1810
+ * real output frame (swapping width/height for 90/270°). If that tool is ever wired back up, this
1811
+ * function's width/height swap must also account for `state.rotationDegrees`, or preview and export
1812
+ * will disagree the same way this function's fix for `crop.rotationDegrees` was needed.
1813
+ */
1814
+ private fun videoOutputSize(session: VideoEditSession, cropped: Boolean): Pair<Float, Float> {
1815
+ var w = session.sourceWidth.coerceAtLeast(1).toFloat(); var h = session.sourceHeight.coerceAtLeast(1).toFloat()
1816
+ val crop = session.state.crop
1817
+ if (crop.rotationDegrees % 180 != 0) { val swap = w; w = h; h = swap }
1818
+ return if (cropped) w * (crop.cropRight - crop.cropLeft) to h * (crop.cropBottom - crop.cropTop) else w to h
1819
+ }
1820
+
1821
+ private fun videoMediaBounds(session: VideoEditSession, view: View, cropped: Boolean): android.graphics.RectF {
1822
+ val (w, h) = videoOutputSize(session, cropped)
1823
+ val scale = minOf(view.width / w, view.height / h)
1824
+ val x = (view.width - w * scale) / 2; val y = (view.height - h * scale) / 2
1825
+ return android.graphics.RectF(x, y, x + w * scale, y + h * scale)
1826
+ }
1827
+
1828
+ private fun computeVideoLetterboxBounds(session: VideoEditSession, view: View): android.graphics.RectF = videoMediaBounds(session, view, !videoCropMode)
1829
+
1830
+ private fun refreshVideoCrop(session: VideoEditSession) {
1831
+ session.player.setVideoEffects(com.photovideoeditor.video.render.VideoCropEffects.create(session.state.crop, !videoCropMode))
1832
+ videoCropPanel?.sync(session.state.crop)
1833
+ cropPlayerView.scaleX = 1f; cropPlayerView.scaleY = 1f; cropPlayerView.translationX = 0f; cropPlayerView.translationY = 0f
1834
+ if (videoCropMode) videoCropOverlay.restore(videoMediaBounds(session, cropPlayerView, false), session.state.crop)
1835
+ videoOverlayRenderKey = null
1836
+ refreshVideoOverlayPreview(session)
1837
+ }
1838
+
1839
+ private fun setVideoCropMode(enabled: Boolean, session: VideoEditSession) {
1840
+ if (enabled && !videoCropMode) {
1841
+ videoCropEntryState = session.state.crop
1842
+ session.player.pause()
1843
+ selectVideoLayer(null, session)
1706
1844
  }
1707
- val scale = minOf(viewWidth / videoSize.width, viewHeight / videoSize.height)
1708
- val fittedWidth = videoSize.width * scale
1709
- val fittedHeight = videoSize.height * scale
1710
- val left = (viewWidth - fittedWidth) / 2f
1711
- val top = (viewHeight - fittedHeight) / 2f
1712
- return android.graphics.RectF(left, top, left + fittedWidth, top + fittedHeight)
1845
+ videoCropMode = enabled
1846
+ videoCropPanel?.visibility = if (enabled) View.VISIBLE else View.GONE
1847
+ videoCropOverlay.visibility = if (enabled) View.VISIBLE else View.GONE
1848
+ videoLayerOverlay.visibility = if (enabled) View.GONE else View.VISIBLE
1849
+ videoOverlayImageView.visibility = if (enabled) View.GONE else View.VISIBLE
1850
+ videoToolBar.visibility = if (enabled) View.GONE else View.VISIBLE
1851
+ if (!enabled) videoCropEntryState = null
1852
+ refreshVideoCrop(session)
1713
1853
  }
1714
1854
 
1715
- /** Mirrors [onLayerStackChanged] for the video overlay stack (Milestone 6). */
1716
1855
  private fun onVideoLayerStackChanged() {
1717
1856
  val session = videoSession ?: return
1718
1857
  if (selectedVideoLayerId != null && session.layerStack.layers.none { it.id == selectedVideoLayerId }) {
@@ -1728,20 +1867,25 @@ class PhotoVideoEditorActivity : Activity() {
1728
1867
  }
1729
1868
 
1730
1869
  private fun selectVideoLayer(id: String?, session: VideoEditSession) {
1870
+ if (selectedVideoLayerId != id) activeVideoLayerPropertyKey = ""
1731
1871
  selectedVideoLayerId = id
1732
1872
  videoLayerOverlay.selectedLayerId = id
1733
1873
  setVideoLayerToolBarVisible(id != null, session)
1734
1874
  }
1735
1875
 
1736
1876
  private fun setVideoLayerToolBarVisible(visible: Boolean, session: VideoEditSession) {
1877
+ if (visible) setVideoControlsVisible(true)
1737
1878
  videoLayerToolBar.visibility = if (visible) View.VISIBLE else View.GONE
1738
- videoLayerPropertySeekBar.visibility = if (visible) View.VISIBLE else View.GONE
1739
1879
  videoToolBar.visibility = if (visible) View.GONE else View.VISIBLE
1740
- if (visible) {
1741
- if (activeVideoLayerPropertyKey !in setOf("rotation", "opacity")) activeVideoLayerPropertyKey = "rotation"
1742
- syncVideoLayerPropertySeekBar(session)
1743
- refreshSelection(videoLayerPropertyButtons, activeVideoLayerPropertyKey, themeColor("textColor", DesignTokens.onSurface), themeColor("primaryColor", DesignTokens.primaryContainer))
1744
- }
1880
+ val isText = currentSelectedVideoLayer(session)?.type == LayerType.TEXT
1881
+ videoLayerPropertyButtons["scale"]?.root?.visibility = if (isText || currentSelectedVideoLayer(session)?.type == LayerType.STICKER) View.GONE else View.VISIBLE
1882
+ listOf("edit", "color", "fontSize").forEach { videoLayerPropertyButtons[it]?.root?.visibility = if (isText) View.VISIBLE else View.GONE }
1883
+ if (!isText && activeVideoLayerPropertyKey in setOf("color", "fontSize")) activeVideoLayerPropertyKey = ""
1884
+ videoLayerPropertySeekBar.visibility = if (visible && activeVideoLayerPropertyKey in setOf("scale", "rotation", "opacity", "fontSize")) View.VISIBLE else View.GONE
1885
+ videoColorRow.visibility = if (visible && activeVideoLayerPropertyKey == "color") View.VISIBLE else View.GONE
1886
+ if (visible) syncVideoLayerPropertySeekBar(session)
1887
+ videoTextSwatches.forEach { (color, view) -> view.background = roundedDrawable(DesignTokens.surfaceContainerHigh, 24).apply { if (color == currentSelectedVideoLayer(session)?.textColor) setStroke(dp(2), themeColor("primaryColor", DesignTokens.primaryContainer)) } }
1888
+ refreshToolNodeSelection(videoLayerPropertyButtons, activeVideoLayerPropertyKey, Color.WHITE)
1745
1889
  }
1746
1890
 
1747
1891
  private fun currentSelectedVideoLayer(session: VideoEditSession): PhotoLayer? =
@@ -1750,12 +1894,14 @@ class PhotoVideoEditorActivity : Activity() {
1750
1894
  private fun syncVideoLayerPropertySeekBar(session: VideoEditSession) {
1751
1895
  val layer = currentSelectedVideoLayer(session) ?: return
1752
1896
  val (min, max, value) = when (activeVideoLayerPropertyKey) {
1753
- "rotation" -> Triple(-180f, 180f, layer.rotationDegrees)
1897
+ "rotation" -> Triple(-180f, 180f, ((layer.rotationDegrees % 360f + 540f) % 360f) - 180f)
1754
1898
  "opacity" -> Triple(0f, 100f, layer.opacity * 100f)
1899
+ "fontSize" -> Triple(12f, 160f, layer.fontSize)
1755
1900
  "start" -> Triple(0f, session.durationMs.toFloat(), layer.startMs.toFloat())
1756
1901
  "end" -> Triple(0f, session.durationMs.toFloat(), session.durationMs.effectiveEndOr(layer.endMs).toFloat())
1757
1902
  else -> Triple(20f, 800f, layer.scale * 100f)
1758
1903
  }
1904
+ (videoLayerPropertySeekBar as? EditorToolSlider)?.propertyKey = activeVideoLayerPropertyKey
1759
1905
  videoLayerPropertySeekBar.max = (max - min).roundToInt()
1760
1906
  videoLayerPropertySeekBar.progress = (value - min).roundToInt()
1761
1907
  }
@@ -1767,6 +1913,7 @@ class PhotoVideoEditorActivity : Activity() {
1767
1913
  val min = when (activeVideoLayerPropertyKey) {
1768
1914
  "rotation" -> -180f
1769
1915
  "opacity", "start", "end" -> 0f
1916
+ "fontSize" -> 12f
1770
1917
  else -> 20f
1771
1918
  }
1772
1919
  val value = min + progress
@@ -1776,6 +1923,7 @@ class PhotoVideoEditorActivity : Activity() {
1776
1923
  when (activeVideoLayerPropertyKey) {
1777
1924
  "rotation" -> layer.copy(rotationDegrees = value)
1778
1925
  "opacity" -> layer.copy(opacity = value / 100f)
1926
+ "fontSize" -> layer.copy(fontSize = value)
1779
1927
  "start" -> layer.copy(startMs = value.toLong().coerceAtMost(layer.endMs.takeIf { it > 0 } ?: session.durationMs))
1780
1928
  "end" -> layer.copy(endMs = value.toLong().coerceAtLeast(layer.startMs))
1781
1929
  else -> layer.copy(scale = value / 100f)
@@ -1785,94 +1933,102 @@ class PhotoVideoEditorActivity : Activity() {
1785
1933
  refreshVideoOverlayPreview(session)
1786
1934
  }
1787
1935
 
1788
- private fun createVideoLayerToolBar(session: VideoEditSession): LinearLayout {
1789
- val toolbarColor = themeColor("toolbarColor", DesignTokens.surfaceContainerLow)
1790
- val textColor = themeColor("textColor", DesignTokens.onSurface)
1791
- val primaryColor = themeColor("primaryColor", DesignTokens.primaryContainer)
1792
- val properties = listOf("rotation" to "Rotate", "opacity" to "Opacity")
1793
- return LinearLayout(this).apply {
1936
+ private fun createVideoLayerToolBar(session: VideoEditSession): LinearLayout = createContextToolbar(true) { key ->
1937
+ val layer = currentSelectedVideoLayer(session) ?: return@createContextToolbar
1938
+ when (key) {
1939
+ "edit" -> showVideoTextInputDialog(session, layer)
1940
+ "done" -> selectVideoLayer(null, session)
1941
+ "delete" -> { session.layerStack.commit { it.filterNot { item -> item.id == layer.id } }; selectVideoLayer(null, session); onVideoLayerStackChanged() }
1942
+ "duplicate" -> {
1943
+ val copy = layer.copy(id = java.util.UUID.randomUUID().toString(), x = (layer.x + 0.04f).coerceIn(0f, 1f), y = (layer.y + 0.04f).coerceIn(0f, 1f))
1944
+ session.layerStack.commit { it + copy }; selectVideoLayer(copy.id, session); onVideoLayerStackChanged()
1945
+ }
1946
+ else -> { activeVideoLayerPropertyKey = if (activeVideoLayerPropertyKey == key) "" else key; setVideoLayerToolBarVisible(true, session) }
1947
+ }
1948
+ }
1949
+
1950
+ private fun showVideoTextInputDialog(session: VideoEditSession, editingLayer: PhotoLayer? = null) {
1951
+ showTextEditor(editingLayer) { text ->
1952
+ val layer = editingLayer?.copy(text = text) ?: PhotoLayer(type = LayerType.TEXT, text = text)
1953
+ session.layerStack.commit { list -> if (editingLayer == null) list + layer else list.map { if (it.id == layer.id) layer else it } }
1954
+ onVideoLayerStackChanged()
1955
+ selectVideoLayer(layer.id, session)
1956
+ }
1957
+ }
1958
+
1959
+ /**
1960
+ * Compact video dock: two equal-width icon+label actions in one rounded pill. Deliberately not
1961
+ * [createToolNode] — that rail stacks a 44dp tile above a label (76dp of chrome), which is far more
1962
+ * than two actions warrant. Returns a transparent holder so [applySystemBarInsets] can pad the
1963
+ * gesture/nav inset without stretching the pill itself into the system bar.
1964
+ */
1965
+ private fun createVideoToolBar(session: VideoEditSession, playerView: PlayerView): View {
1966
+ val features = request.optJSONObject("features")
1967
+ val dock = LinearLayout(this).apply {
1968
+ orientation = LinearLayout.HORIZONTAL
1794
1969
  gravity = Gravity.CENTER_VERTICAL
1795
- setPadding(dp(12), 0, dp(12), 0)
1796
- setBackgroundColor(toolbarColor)
1797
- addView(HorizontalScrollView(this@PhotoVideoEditorActivity).apply {
1798
- isHorizontalScrollBarEnabled = false
1799
- addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
1800
- orientation = LinearLayout.HORIZONTAL
1801
- properties.forEach { (key, label) ->
1802
- val chip = actionButton(label) {
1803
- activeVideoLayerPropertyKey = key
1804
- syncVideoLayerPropertySeekBar(session)
1805
- refreshSelection(videoLayerPropertyButtons, activeVideoLayerPropertyKey, textColor, primaryColor)
1806
- }.apply { setTextColor(textColor) }
1807
- videoLayerPropertyButtons[key] = chip
1808
- addView(chip, LinearLayout.LayoutParams(dp(72), MATCH))
1809
- }
1970
+ background = roundedDrawable(DesignTokens.surfaceContainer, VIDEO_DOCK_RADIUS_DP)
1971
+ listOf("text" to "Text", "stickers" to "Stickers")
1972
+ .filter { features?.optBoolean(it.first, true) != false }
1973
+ .forEach { (key, label) ->
1810
1974
  addView(
1811
- actionButton("Delete") {
1812
- val id = selectedVideoLayerId ?: return@actionButton
1813
- session.layerStack.commit { list -> list.filterNot { it.id == id } }
1814
- selectVideoLayer(null, session)
1815
- onVideoLayerStackChanged()
1816
- }.apply { setTextColor(textColor) },
1817
- LinearLayout.LayoutParams(dp(80), MATCH)
1975
+ createVideoDockAction(key, label) { onVideoToolTapped(key, session, playerView) },
1976
+ LinearLayout.LayoutParams(0, MATCH, 1f)
1818
1977
  )
1819
- })
1820
- }, LinearLayout.LayoutParams(0, MATCH, 1f))
1978
+ }
1979
+ }
1980
+ return FrameLayout(this).apply {
1821
1981
  addView(
1822
- actionButton("Done") { selectVideoLayer(null, session) }.apply { setTextColor(primaryColor) },
1823
- LinearLayout.LayoutParams(dp(72), MATCH)
1982
+ dock,
1983
+ FrameLayout.LayoutParams(MATCH, dp(VIDEO_DOCK_HEIGHT_DP), Gravity.TOP).apply {
1984
+ setMargins(dp(DesignTokens.spaceMd), 0, dp(DesignTokens.spaceMd), 0)
1985
+ }
1824
1986
  )
1825
1987
  }
1826
1988
  }
1827
1989
 
1828
- private fun showVideoTextInputDialog(session: VideoEditSession) {
1829
- val input = EditText(this).apply { inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE }
1830
- AlertDialog.Builder(this)
1831
- .setTitle("Add text")
1832
- .setView(input)
1833
- .setPositiveButton("Add") { _, _ ->
1834
- val text = input.text?.toString().orEmpty()
1835
- if (text.isNotBlank()) {
1836
- val layer = PhotoLayer(type = LayerType.TEXT, text = text)
1837
- session.layerStack.commit { it + layer }
1838
- onVideoLayerStackChanged()
1839
- selectVideoLayer(layer.id, session)
1840
- }
1990
+ /** One dock action: icon + label on a single row, purple pill while pressed. */
1991
+ private fun createVideoDockAction(key: String, labelText: String, onClick: () -> Unit): LinearLayout {
1992
+ val icon: View = if (key == "text") {
1993
+ // A literal "Aa" reads as typography far better than R.drawable.ic_title, which is a lone
1994
+ // oversized "T".
1995
+ TextView(this).apply {
1996
+ text = "Aa"
1997
+ textSize = 15f
1998
+ includeFontPadding = false
1999
+ gravity = Gravity.CENTER
2000
+ setTypeface(typeface, Typeface.BOLD)
2001
+ setTextColor(DesignTokens.textPrimary)
1841
2002
  }
1842
- .setNegativeButton("Cancel", null)
1843
- .show()
1844
- }
1845
-
1846
- private fun createVideoToolBar(session: VideoEditSession, playerView: PlayerView): LinearLayout {
1847
- val backgroundColor = themeColor("toolbarColor", DesignTokens.withAlphaPercent(DesignTokens.surfaceContainerLowest, 95))
2003
+ } else {
2004
+ ImageView(this).apply {
2005
+ setImageResource(R.drawable.ic_sentiment_satisfied)
2006
+ setColorFilter(DesignTokens.textPrimary)
2007
+ }
2008
+ }
2009
+ val accent = themeColor("primaryColor", DesignTokens.primaryContainer)
1848
2010
  return LinearLayout(this).apply {
1849
- orientation = LinearLayout.VERTICAL
1850
- setBackgroundColor(backgroundColor)
1851
- setPadding(dp(DesignTokens.spaceXs), dp(DesignTokens.spaceSm), dp(DesignTokens.spaceXs), dp(DesignTokens.spaceLg))
1852
-
1853
- addView(
1854
- View(this@PhotoVideoEditorActivity).apply { background = roundedDrawable(DesignTokens.surfaceVariant, DesignTokens.radiusSm) },
1855
- LinearLayout.LayoutParams(dp(36), dp(4)).apply { gravity = Gravity.CENTER_HORIZONTAL; bottomMargin = dp(DesignTokens.spaceXs) }
1856
- )
1857
-
1858
- val features = request.optJSONObject("features")
1859
- val tools = listOf(
1860
- Triple("text", "Text", R.drawable.ic_title),
1861
- Triple("stickers", "Stickers", R.drawable.ic_sentiment_satisfied)
1862
- )
1863
- addView(HorizontalScrollView(this@PhotoVideoEditorActivity).apply {
1864
- isHorizontalScrollBarEnabled = false
1865
- addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
1866
- orientation = LinearLayout.HORIZONTAL
1867
- gravity = Gravity.CENTER_VERTICAL
1868
- setPadding(dp(DesignTokens.spaceSm), dp(DesignTokens.spaceXs), dp(DesignTokens.spaceSm), dp(DesignTokens.spaceXs))
1869
- tools.filter { features?.optBoolean(it.first, true) != false }.forEach { (key, label, iconRes) ->
1870
- val node = createToolNode(iconRes, label) { onVideoToolTapped(key, session, playerView) }
1871
- videoToolButtons[key] = node
1872
- addView(node.root, LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), WRAP).apply { marginEnd = dp(DesignTokens.spaceSm) })
1873
- }
1874
- })
1875
- }, LinearLayout.LayoutParams(MATCH, WRAP))
2011
+ orientation = LinearLayout.HORIZONTAL
2012
+ gravity = Gravity.CENTER
2013
+ isClickable = true
2014
+ isFocusable = true
2015
+ contentDescription = labelText
2016
+ // A StateListDrawable rather than a touch listener, so it composes with applyPressScale()
2017
+ // (which installs its own OnTouchListener).
2018
+ background = android.graphics.drawable.StateListDrawable().apply {
2019
+ addState(intArrayOf(android.R.attr.state_pressed), roundedDrawable(accent, VIDEO_DOCK_RADIUS_DP))
2020
+ addState(intArrayOf(), roundedDrawable(Color.TRANSPARENT, VIDEO_DOCK_RADIUS_DP))
2021
+ }
2022
+ addView(icon, LinearLayout.LayoutParams(dp(22), dp(22)).apply { marginEnd = dp(DesignTokens.spaceSm) })
2023
+ addView(TextView(this@PhotoVideoEditorActivity).apply {
2024
+ text = labelText
2025
+ textSize = 13f
2026
+ maxLines = 1
2027
+ includeFontPadding = false
2028
+ setTextColor(DesignTokens.textPrimary)
2029
+ })
2030
+ setOnClickListener { onClick() }
2031
+ applyPressScale()
1876
2032
  }
1877
2033
  }
1878
2034
 
@@ -1890,6 +2046,7 @@ class PhotoVideoEditorActivity : Activity() {
1890
2046
 
1891
2047
  private fun onVideoToolTapped(key: String, session: VideoEditSession, playerView: PlayerView) {
1892
2048
  when (key) {
2049
+ "crop" -> setVideoCropMode(true, session)
1893
2050
  "cover" -> {
1894
2051
  val globalMs = session.globalPositionMs()
1895
2052
  session.update { it.withCoverFrame(globalMs) }
@@ -1915,7 +2072,7 @@ class PhotoVideoEditorActivity : Activity() {
1915
2072
 
1916
2073
  private fun showVideoStickerPicker(session: VideoEditSession) {
1917
2074
  OnlineStickerSheet(this, runtimeStickerAssets()) { path ->
1918
- val layer = PhotoLayer(type = LayerType.STICKER, stickerUri = Uri.fromFile(File(path)).toString())
2075
+ val layer = newSticker(stickerUri = Uri.fromFile(File(path)).toString())
1919
2076
  session.layerStack.commit { it + layer }
1920
2077
  onVideoLayerStackChanged()
1921
2078
  selectVideoLayer(layer.id, session)
@@ -2307,7 +2464,9 @@ class PhotoVideoEditorActivity : Activity() {
2307
2464
 
2308
2465
  addView(TextView(this@PhotoVideoEditorActivity).apply {
2309
2466
  text = if (mediaType == "photo") "Photo Editor" else "Video Editor"
2310
- textSize = 16f
2467
+ textSize = 14f
2468
+ maxLines = 1
2469
+ ellipsize = android.text.TextUtils.TruncateAt.END
2311
2470
  gravity = Gravity.CENTER
2312
2471
  setTextColor(textColor)
2313
2472
  setTypeface(typeface, android.graphics.Typeface.BOLD)
@@ -2321,25 +2480,16 @@ class PhotoVideoEditorActivity : Activity() {
2321
2480
  if (mediaType == "photo") { photoSession?.layerStack?.redo(); onLayerStackChanged() }
2322
2481
  else { videoSession?.layerStack?.redo(); onVideoLayerStackChanged() }
2323
2482
  }
2483
+ undo.isEnabled = false; undo.alpha = 0.4f
2484
+ redo.isEnabled = false; redo.alpha = 0.4f
2324
2485
  undoButton = undo
2325
2486
  redoButton = redo
2326
2487
  addView(undo, LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin)))
2327
2488
  addView(redo, LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin)))
2328
2489
 
2329
- if (mediaType == "photo") {
2330
- addView(createIconButton(R.drawable.ic_compare, "Hold to compare with original", textColor) {}.apply {
2331
- setOnTouchListener { _, event ->
2332
- when (event.actionMasked) {
2333
- MotionEvent.ACTION_DOWN -> showOriginalPreview(true)
2334
- MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> showOriginalPreview(false)
2335
- }
2336
- true
2337
- }
2338
- }, LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin)))
2339
- }
2340
2490
 
2341
2491
  addView(
2342
- editorPrimaryButton(this@PhotoVideoEditorActivity, "Export", R.drawable.ic_share, primaryColor, onPrimaryColor) {
2492
+ editorPrimaryButton(this@PhotoVideoEditorActivity, "Export", null, primaryColor, onPrimaryColor) {
2343
2493
  if (mediaType == "photo") showPhotoExportConfiguration() else finishDone()
2344
2494
  },
2345
2495
  LinearLayout.LayoutParams(WRAP, dp(DesignTokens.touchTargetMin)).apply { marginStart = dp(DesignTokens.spaceXs) }
@@ -2461,17 +2611,20 @@ class PhotoVideoEditorActivity : Activity() {
2461
2611
  val label = TextView(this).apply {
2462
2612
  text = labelText
2463
2613
  textSize = 11f
2614
+ maxLines = 1
2615
+ includeFontPadding = false
2464
2616
  gravity = Gravity.CENTER
2465
2617
  setTextColor(DesignTokens.outline)
2466
- setPadding(0, dp(DesignTokens.spaceXs), 0, 0)
2618
+ setPadding(0, dp(2), 0, 0)
2467
2619
  }
2468
2620
  val root = LinearLayout(this).apply {
2469
2621
  orientation = LinearLayout.VERTICAL
2470
2622
  gravity = Gravity.CENTER_HORIZONTAL
2471
2623
  isClickable = true
2472
2624
  isFocusable = true
2625
+ contentDescription = labelText
2473
2626
  background = roundedDrawable(Color.TRANSPARENT, DesignTokens.radiusLg)
2474
- setPadding(0, dp(4), 0, dp(4))
2627
+ setPadding(0, dp(2), 0, dp(2))
2475
2628
  addView(tile, LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin)))
2476
2629
  addView(label, LinearLayout.LayoutParams(WRAP, WRAP))
2477
2630
  setOnClickListener { onClick() }
@@ -2481,17 +2634,23 @@ class PhotoVideoEditorActivity : Activity() {
2481
2634
  }
2482
2635
 
2483
2636
  /** Highlights a tool-rail node the same way the mockups do: filled tile + text/icon tint switching to the theme's text color. */
2637
+ /**
2638
+ * Selection is a purple icon + label over a faint purple disc — not a filled purple tile, which at
2639
+ * dock size reads as a large button and fights the photo for attention.
2640
+ */
2484
2641
  private fun setToolNodeSelected(node: ToolNode, selected: Boolean, textColor: Int) {
2485
- node.tile.background = roundedDrawable(
2486
- if (selected) themeColor("primaryColor", DesignTokens.primaryContainer) else DesignTokens.surfaceContainerLow,
2487
- DesignTokens.radiusLg
2488
- )
2489
- node.icon.setColorFilter(if (selected) DesignTokens.onPrimaryContainer else DesignTokens.outline)
2490
- node.label.setTextColor(if (selected) DesignTokens.primary else DesignTokens.outline)
2642
+ val accent = themeColor("primaryColor", DesignTokens.primaryContainer)
2643
+ node.tile.background = if (selected) circleDrawable(DesignTokens.withAlphaPercent(accent, 18)) else null
2644
+ node.root.isSelected = selected
2645
+ node.icon.setColorFilter(if (selected) accent else DesignTokens.textSecondary)
2646
+ node.label.setTextColor(if (selected) accent else DesignTokens.textSecondary)
2491
2647
  }
2492
2648
 
2493
2649
  private fun <K> refreshToolNodeSelection(nodes: Map<K, ToolNode>, activeKey: K?, textColor: Int) {
2494
- nodes.forEach { (key, node) -> setToolNodeSelected(node, key == activeKey, textColor) }
2650
+ nodes.forEach { (key, node) ->
2651
+ setToolNodeSelected(node, key == activeKey, textColor)
2652
+ if (key == "delete") { node.icon.setColorFilter(Color.rgb(255, 100, 115)); node.label.setTextColor(Color.rgb(255, 100, 115)) }
2653
+ }
2495
2654
  }
2496
2655
 
2497
2656
  private fun showPreviewError(container: FrameLayout) {
@@ -2540,7 +2699,13 @@ class PhotoVideoEditorActivity : Activity() {
2540
2699
  }
2541
2700
 
2542
2701
  @Deprecated("Deprecated in Java")
2543
- override fun onBackPressed() = confirmDiscardChanges()
2702
+ override fun onBackPressed() {
2703
+ if (cropMode) photoSession?.let { cancelCropMode(it, photoImageView, cropOverlay) }
2704
+ else if (videoCropMode) videoSession?.let { session ->
2705
+ videoCropEntryState?.let { saved -> session.update { it.copy(crop = saved) } }; setVideoCropMode(false, session)
2706
+ }
2707
+ else confirmDiscardChanges()
2708
+ }
2544
2709
 
2545
2710
  override fun onTrimMemory(level: Int) {
2546
2711
  super.onTrimMemory(level)
@@ -2573,12 +2738,17 @@ class PhotoVideoEditorActivity : Activity() {
2573
2738
 
2574
2739
  companion object {
2575
2740
  /** Rotate icon row (44dp) + aspect-preset chip row (56dp). */
2576
- const val CROP_SUB_BAR_HEIGHT_DP = 148
2577
- const val ADJUST_SUB_BAR_HEIGHT_DP = 112
2578
- /** 64dp preset thumbnail + label + vertical padding, matching the "Preset Thumbnail Cards" spec. */
2579
- const val FILTERS_SUB_BAR_HEIGHT_DP = 104
2741
+ const val CROP_SUB_BAR_HEIGHT_DP = 232
2742
+ /** Header + shared slider + adjustment strip; see EditorPanelMetrics for the breakdown. */
2743
+ const val ADJUST_SUB_BAR_HEIGHT_DP = EditorPanelMetrics.TOTAL_HEIGHT_DP
2744
+ /** Same skeleton as Adjust, with a taller strip for the thumbnail + label. */
2745
+ const val FILTERS_SUB_BAR_HEIGHT_DP = EditorPanelMetrics.TOTAL_HEIGHT_DP + 24
2580
2746
  /** 64dp asset card + optional caption + vertical padding, for the Stickers/Shapes grid. */
2581
2747
  const val ASSET_GRID_BAR_HEIGHT_DP = 104
2748
+ /** Compact video dock (Text/Stickers pill) and the gap between it and the safe-area edge. */
2749
+ const val VIDEO_DOCK_HEIGHT_DP = 52
2750
+ const val VIDEO_DOCK_RADIUS_DP = 18
2751
+ const val VIDEO_DOCK_BAR_HEIGHT_DP = VIDEO_DOCK_HEIGHT_DP + 8
2582
2752
  private const val TIMELINE_THUMBNAIL_COUNT = 12
2583
2753
  const val EXTRA_URI = "photoVideoEditor.uri"
2584
2754
  const val EXTRA_TYPE = "photoVideoEditor.type"