react-native-photo-video-editor 0.1.0 → 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 (233) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/PhotoVideoEditor.podspec +1 -0
  3. package/README.md +10 -6
  4. package/THIRD_PARTY_NOTICES.md +20 -0
  5. package/android/build.gradle +1 -1
  6. package/android/src/main/AndroidManifest.xml +1 -1
  7. package/android/src/main/assets/Stickers/AUTUMN_1.png +0 -0
  8. package/android/src/main/assets/Stickers/AUTUMN_10.png +0 -0
  9. package/android/src/main/assets/Stickers/AUTUMN_11.png +0 -0
  10. package/android/src/main/assets/Stickers/AUTUMN_12.png +0 -0
  11. package/android/src/main/assets/Stickers/AUTUMN_13.png +0 -0
  12. package/android/src/main/assets/Stickers/AUTUMN_14.png +0 -0
  13. package/android/src/main/assets/Stickers/AUTUMN_15.png +0 -0
  14. package/android/src/main/assets/Stickers/AUTUMN_16.png +0 -0
  15. package/android/src/main/assets/Stickers/AUTUMN_17.png +0 -0
  16. package/android/src/main/assets/Stickers/AUTUMN_18.png +0 -0
  17. package/android/src/main/assets/Stickers/AUTUMN_2.png +0 -0
  18. package/android/src/main/assets/Stickers/AUTUMN_3.png +0 -0
  19. package/android/src/main/assets/Stickers/AUTUMN_4.png +0 -0
  20. package/android/src/main/assets/Stickers/AUTUMN_5.png +0 -0
  21. package/android/src/main/assets/Stickers/AUTUMN_6.png +0 -0
  22. package/android/src/main/assets/Stickers/AUTUMN_7.png +0 -0
  23. package/android/src/main/assets/Stickers/AUTUMN_8.png +0 -0
  24. package/android/src/main/assets/Stickers/AUTUMN_9.png +0 -0
  25. package/android/src/main/assets/Stickers/LOL_1.png +0 -0
  26. package/android/src/main/assets/Stickers/LOL_10.png +0 -0
  27. package/android/src/main/assets/Stickers/LOL_11.png +0 -0
  28. package/android/src/main/assets/Stickers/LOL_12.png +0 -0
  29. package/android/src/main/assets/Stickers/LOL_13.png +0 -0
  30. package/android/src/main/assets/Stickers/LOL_14.png +0 -0
  31. package/android/src/main/assets/Stickers/LOL_15.png +0 -0
  32. package/android/src/main/assets/Stickers/LOL_16.png +0 -0
  33. package/android/src/main/assets/Stickers/LOL_17.png +0 -0
  34. package/android/src/main/assets/Stickers/LOL_18.png +0 -0
  35. package/android/src/main/assets/Stickers/LOL_19.png +0 -0
  36. package/android/src/main/assets/Stickers/LOL_2.png +0 -0
  37. package/android/src/main/assets/Stickers/LOL_3.png +0 -0
  38. package/android/src/main/assets/Stickers/LOL_4.png +0 -0
  39. package/android/src/main/assets/Stickers/LOL_5.png +0 -0
  40. package/android/src/main/assets/Stickers/LOL_6.png +0 -0
  41. package/android/src/main/assets/Stickers/LOL_7.png +0 -0
  42. package/android/src/main/assets/Stickers/LOL_8.png +0 -0
  43. package/android/src/main/assets/Stickers/LOL_9.png +0 -0
  44. package/android/src/main/assets/Stickers/SPRING_1.png +0 -0
  45. package/android/src/main/assets/Stickers/SPRING_2.png +0 -0
  46. package/android/src/main/assets/Stickers/SPRING_3.png +0 -0
  47. package/android/src/main/assets/Stickers/SPRING_4.png +0 -0
  48. package/android/src/main/assets/Stickers/SPRING_5.png +0 -0
  49. package/android/src/main/assets/Stickers/SPRING_6.png +0 -0
  50. package/android/src/main/assets/Stickers/SUMMER_1.png +0 -0
  51. package/android/src/main/assets/Stickers/SUMMER_10.png +0 -0
  52. package/android/src/main/assets/Stickers/SUMMER_11.png +0 -0
  53. package/android/src/main/assets/Stickers/SUMMER_12.png +0 -0
  54. package/android/src/main/assets/Stickers/SUMMER_13.png +0 -0
  55. package/android/src/main/assets/Stickers/SUMMER_14.png +0 -0
  56. package/android/src/main/assets/Stickers/SUMMER_15.png +0 -0
  57. package/android/src/main/assets/Stickers/SUMMER_16.png +0 -0
  58. package/android/src/main/assets/Stickers/SUMMER_17.png +0 -0
  59. package/android/src/main/assets/Stickers/SUMMER_18.png +0 -0
  60. package/android/src/main/assets/Stickers/SUMMER_19.png +0 -0
  61. package/android/src/main/assets/Stickers/SUMMER_2.png +0 -0
  62. package/android/src/main/assets/Stickers/SUMMER_20.png +0 -0
  63. package/android/src/main/assets/Stickers/SUMMER_21.png +0 -0
  64. package/android/src/main/assets/Stickers/SUMMER_3.png +0 -0
  65. package/android/src/main/assets/Stickers/SUMMER_4.png +0 -0
  66. package/android/src/main/assets/Stickers/SUMMER_5.png +0 -0
  67. package/android/src/main/assets/Stickers/SUMMER_6.png +0 -0
  68. package/android/src/main/assets/Stickers/SUMMER_7.png +0 -0
  69. package/android/src/main/assets/Stickers/SUMMER_8.png +0 -0
  70. package/android/src/main/assets/Stickers/SUMMER_9.png +0 -0
  71. package/android/src/main/assets/Stickers/TEXT_1.png +0 -0
  72. package/android/src/main/assets/Stickers/TEXT_12.png +0 -0
  73. package/android/src/main/assets/Stickers/TEXT_13.png +0 -0
  74. package/android/src/main/assets/Stickers/TEXT_14.png +0 -0
  75. package/android/src/main/assets/Stickers/TEXT_15.png +0 -0
  76. package/android/src/main/assets/Stickers/TEXT_16.png +0 -0
  77. package/android/src/main/assets/Stickers/TEXT_2.png +0 -0
  78. package/android/src/main/assets/Stickers/TEXT_25.png +0 -0
  79. package/android/src/main/assets/Stickers/TEXT_26.png +0 -0
  80. package/android/src/main/assets/Stickers/TEXT_27.png +0 -0
  81. package/android/src/main/assets/Stickers/TEXT_28.png +0 -0
  82. package/android/src/main/assets/Stickers/TEXT_29.png +0 -0
  83. package/android/src/main/assets/Stickers/TEXT_3.png +0 -0
  84. package/android/src/main/assets/Stickers/TEXT_30.png +0 -0
  85. package/android/src/main/assets/Stickers/TEXT_31.png +0 -0
  86. package/android/src/main/assets/Stickers/TEXT_32.png +0 -0
  87. package/android/src/main/assets/Stickers/TEXT_7.png +0 -0
  88. package/android/src/main/assets/Stickers/TEXT_8.png +0 -0
  89. package/android/src/main/assets/Stickers/TEXT_9.png +0 -0
  90. package/android/src/main/java/com/photovideoeditor/editor/PhotoVideoEditorActivity.kt +901 -651
  91. package/android/src/main/java/com/photovideoeditor/photo/export/PhotoExporter.kt +8 -23
  92. package/android/src/main/java/com/photovideoeditor/photo/render/CropGeometry.kt +71 -0
  93. package/android/src/main/java/com/photovideoeditor/photo/render/OverlayGeometry.kt +36 -0
  94. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoEditSession.kt +5 -7
  95. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoFilterPresets.kt +71 -41
  96. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayer.kt +5 -1
  97. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerRenderer.kt +24 -1
  98. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoTransformRenderer.kt +33 -0
  99. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoTransformState.kt +6 -1
  100. package/android/src/main/java/com/photovideoeditor/photo/ui/CropOverlayView.kt +112 -122
  101. package/android/src/main/java/com/photovideoeditor/photo/ui/DrawOverlayView.kt +66 -0
  102. package/android/src/main/java/com/photovideoeditor/photo/ui/LayerOverlayView.kt +63 -14
  103. package/android/src/main/java/com/photovideoeditor/photo/ui/ZoomableImageView.kt +10 -0
  104. package/android/src/main/java/com/photovideoeditor/stickers/OnlineStickerSheet.kt +135 -104
  105. package/android/src/main/java/com/photovideoeditor/ui/components/CropPanel.kt +122 -0
  106. package/android/src/main/java/com/photovideoeditor/ui/components/EditorPanel.kt +250 -0
  107. package/android/src/main/java/com/photovideoeditor/ui/components/EditorToolSlider.kt +103 -0
  108. package/android/src/main/java/com/photovideoeditor/video/export/VideoExporter.kt +38 -11
  109. package/android/src/main/java/com/photovideoeditor/video/render/VideoCropEffects.kt +35 -0
  110. package/android/src/main/java/com/photovideoeditor/video/render/VideoEditSession.kt +10 -0
  111. package/android/src/main/java/com/photovideoeditor/video/render/VideoTransformState.kt +1 -0
  112. package/android/src/main/res/drawable/ic_draw.xml +6 -0
  113. package/android/src/main/res/drawable/ic_pause.xml +7 -0
  114. package/android/src/main/res/drawable/ic_play.xml +7 -0
  115. package/docs/api.md +1 -1
  116. package/docs/editor-ui-validation.md +22 -0
  117. package/docs/photo-editor.md +5 -6
  118. package/ios/Editor/PhotoVideoEditorViewController.swift +918 -494
  119. package/ios/Editor/UI/CropPanel.swift +97 -0
  120. package/ios/Editor/UI/EditorPanel.swift +334 -0
  121. package/ios/Editor/UI/EditorToolSlider.swift +22 -0
  122. package/ios/Editor/UI/TextEditorSheet.swift +84 -0
  123. package/ios/Photo/Export/PhotoExporter.swift +3 -3
  124. package/ios/Photo/Rendering/CropGeometry.swift +48 -0
  125. package/ios/Photo/Rendering/PhotoEditSession.swift +21 -10
  126. package/ios/Photo/Rendering/PhotoFilterPresets.swift +53 -23
  127. package/ios/Photo/Rendering/PhotoLayer.swift +14 -2
  128. package/ios/Photo/Rendering/PhotoLayerRenderer.swift +16 -1
  129. package/ios/Photo/Rendering/PhotoTransformState.swift +5 -0
  130. package/ios/Photo/UI/CropOverlayView.swift +98 -134
  131. package/ios/Photo/UI/DrawOverlayView.swift +48 -0
  132. package/ios/Photo/UI/FilterThumbnailLoader.swift +62 -0
  133. package/ios/Photo/UI/LayerOverlayView.swift +36 -11
  134. package/ios/Photo/UI/ZoomableImageView.swift +19 -1
  135. package/ios/Resources/Stickers/AUTUMN_1.png +0 -0
  136. package/ios/Resources/Stickers/AUTUMN_10.png +0 -0
  137. package/ios/Resources/Stickers/AUTUMN_11.png +0 -0
  138. package/ios/Resources/Stickers/AUTUMN_12.png +0 -0
  139. package/ios/Resources/Stickers/AUTUMN_13.png +0 -0
  140. package/ios/Resources/Stickers/AUTUMN_14.png +0 -0
  141. package/ios/Resources/Stickers/AUTUMN_15.png +0 -0
  142. package/ios/Resources/Stickers/AUTUMN_16.png +0 -0
  143. package/ios/Resources/Stickers/AUTUMN_17.png +0 -0
  144. package/ios/Resources/Stickers/AUTUMN_18.png +0 -0
  145. package/ios/Resources/Stickers/AUTUMN_2.png +0 -0
  146. package/ios/Resources/Stickers/AUTUMN_3.png +0 -0
  147. package/ios/Resources/Stickers/AUTUMN_4.png +0 -0
  148. package/ios/Resources/Stickers/AUTUMN_5.png +0 -0
  149. package/ios/Resources/Stickers/AUTUMN_6.png +0 -0
  150. package/ios/Resources/Stickers/AUTUMN_7.png +0 -0
  151. package/ios/Resources/Stickers/AUTUMN_8.png +0 -0
  152. package/ios/Resources/Stickers/AUTUMN_9.png +0 -0
  153. package/ios/Resources/Stickers/LOL_1.png +0 -0
  154. package/ios/Resources/Stickers/LOL_10.png +0 -0
  155. package/ios/Resources/Stickers/LOL_11.png +0 -0
  156. package/ios/Resources/Stickers/LOL_12.png +0 -0
  157. package/ios/Resources/Stickers/LOL_13.png +0 -0
  158. package/ios/Resources/Stickers/LOL_14.png +0 -0
  159. package/ios/Resources/Stickers/LOL_15.png +0 -0
  160. package/ios/Resources/Stickers/LOL_16.png +0 -0
  161. package/ios/Resources/Stickers/LOL_17.png +0 -0
  162. package/ios/Resources/Stickers/LOL_18.png +0 -0
  163. package/ios/Resources/Stickers/LOL_19.png +0 -0
  164. package/ios/Resources/Stickers/LOL_2.png +0 -0
  165. package/ios/Resources/Stickers/LOL_3.png +0 -0
  166. package/ios/Resources/Stickers/LOL_4.png +0 -0
  167. package/ios/Resources/Stickers/LOL_5.png +0 -0
  168. package/ios/Resources/Stickers/LOL_6.png +0 -0
  169. package/ios/Resources/Stickers/LOL_7.png +0 -0
  170. package/ios/Resources/Stickers/LOL_8.png +0 -0
  171. package/ios/Resources/Stickers/LOL_9.png +0 -0
  172. package/ios/Resources/Stickers/SPRING_1.png +0 -0
  173. package/ios/Resources/Stickers/SPRING_2.png +0 -0
  174. package/ios/Resources/Stickers/SPRING_3.png +0 -0
  175. package/ios/Resources/Stickers/SPRING_4.png +0 -0
  176. package/ios/Resources/Stickers/SPRING_5.png +0 -0
  177. package/ios/Resources/Stickers/SPRING_6.png +0 -0
  178. package/ios/Resources/Stickers/SUMMER_1.png +0 -0
  179. package/ios/Resources/Stickers/SUMMER_10.png +0 -0
  180. package/ios/Resources/Stickers/SUMMER_11.png +0 -0
  181. package/ios/Resources/Stickers/SUMMER_12.png +0 -0
  182. package/ios/Resources/Stickers/SUMMER_13.png +0 -0
  183. package/ios/Resources/Stickers/SUMMER_14.png +0 -0
  184. package/ios/Resources/Stickers/SUMMER_15.png +0 -0
  185. package/ios/Resources/Stickers/SUMMER_16.png +0 -0
  186. package/ios/Resources/Stickers/SUMMER_17.png +0 -0
  187. package/ios/Resources/Stickers/SUMMER_18.png +0 -0
  188. package/ios/Resources/Stickers/SUMMER_19.png +0 -0
  189. package/ios/Resources/Stickers/SUMMER_2.png +0 -0
  190. package/ios/Resources/Stickers/SUMMER_20.png +0 -0
  191. package/ios/Resources/Stickers/SUMMER_21.png +0 -0
  192. package/ios/Resources/Stickers/SUMMER_3.png +0 -0
  193. package/ios/Resources/Stickers/SUMMER_4.png +0 -0
  194. package/ios/Resources/Stickers/SUMMER_5.png +0 -0
  195. package/ios/Resources/Stickers/SUMMER_6.png +0 -0
  196. package/ios/Resources/Stickers/SUMMER_7.png +0 -0
  197. package/ios/Resources/Stickers/SUMMER_8.png +0 -0
  198. package/ios/Resources/Stickers/SUMMER_9.png +0 -0
  199. package/ios/Resources/Stickers/TEXT_1.png +0 -0
  200. package/ios/Resources/Stickers/TEXT_12.png +0 -0
  201. package/ios/Resources/Stickers/TEXT_13.png +0 -0
  202. package/ios/Resources/Stickers/TEXT_14.png +0 -0
  203. package/ios/Resources/Stickers/TEXT_15.png +0 -0
  204. package/ios/Resources/Stickers/TEXT_16.png +0 -0
  205. package/ios/Resources/Stickers/TEXT_2.png +0 -0
  206. package/ios/Resources/Stickers/TEXT_25.png +0 -0
  207. package/ios/Resources/Stickers/TEXT_26.png +0 -0
  208. package/ios/Resources/Stickers/TEXT_27.png +0 -0
  209. package/ios/Resources/Stickers/TEXT_28.png +0 -0
  210. package/ios/Resources/Stickers/TEXT_29.png +0 -0
  211. package/ios/Resources/Stickers/TEXT_3.png +0 -0
  212. package/ios/Resources/Stickers/TEXT_30.png +0 -0
  213. package/ios/Resources/Stickers/TEXT_31.png +0 -0
  214. package/ios/Resources/Stickers/TEXT_32.png +0 -0
  215. package/ios/Resources/Stickers/TEXT_7.png +0 -0
  216. package/ios/Resources/Stickers/TEXT_8.png +0 -0
  217. package/ios/Resources/Stickers/TEXT_9.png +0 -0
  218. package/ios/Resources/Stickers/a_pety_icon.png +0 -0
  219. package/ios/Stickers/OnlineStickerSheetViewController.swift +102 -159
  220. package/ios/Video/Export/VideoExporter.swift +10 -1
  221. package/ios/Video/Render/VideoCropGeometry.swift +48 -0
  222. package/ios/Video/Render/VideoTransformState.swift +1 -0
  223. package/lib/module/index.js +2 -2
  224. package/lib/module/index.js.map +1 -1
  225. package/lib/typescript/src/types.d.ts +2 -2
  226. package/lib/typescript/src/types.d.ts.map +1 -1
  227. package/package.json +2 -1
  228. package/src/index.ts +1 -1
  229. package/src/types.ts +2 -2
  230. package/android/src/main/java/com/photovideoeditor/stickers/OpenMojiCatalog.kt +0 -172
  231. package/android/src/main/java/com/photovideoeditor/stickers/RemoteImageLoader.kt +0 -110
  232. package/ios/Stickers/OpenMojiCatalog.swift +0 -188
  233. package/ios/Stickers/RemoteImageLoader.swift +0 -127
@@ -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,13 +34,16 @@ 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
39
41
  import com.photovideoeditor.photo.render.PhotoFilterPresets
40
42
  import com.photovideoeditor.photo.render.PhotoLayer
41
43
  import com.photovideoeditor.photo.render.PhotoLayerRenderer
44
+ import com.photovideoeditor.photo.render.PhotoTransformState
42
45
  import com.photovideoeditor.photo.ui.CropOverlayView
46
+ import com.photovideoeditor.photo.ui.DrawOverlayView
43
47
  import com.photovideoeditor.photo.ui.FilterThumbnailLoader
44
48
  import com.photovideoeditor.video.export.VideoExportException
45
49
  import com.photovideoeditor.video.export.VideoExporter
@@ -51,10 +55,20 @@ import com.photovideoeditor.photo.ui.LayerOverlayView
51
55
  import com.photovideoeditor.photo.ui.PhotoPreviewRenderCoordinator
52
56
  import com.photovideoeditor.photo.ui.ZoomableImageView
53
57
  import com.photovideoeditor.stickers.OnlineStickerSheet
58
+ import com.photovideoeditor.stickers.RuntimeSticker
54
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
55
65
  import com.photovideoeditor.ui.components.applyPressScale
56
66
  import com.photovideoeditor.ui.components.editorChip
67
+ import com.photovideoeditor.ui.components.editorFilterThumbnail
68
+ import com.photovideoeditor.ui.components.editorPanelHeader
57
69
  import com.photovideoeditor.ui.components.editorPrimaryButton
70
+ import com.photovideoeditor.ui.components.editorStripItem
71
+ import com.photovideoeditor.ui.components.editorToolStrip
58
72
  import org.json.JSONObject
59
73
  import java.io.File
60
74
  import kotlin.math.roundToInt
@@ -67,22 +81,30 @@ class PhotoVideoEditorActivity : Activity() {
67
81
 
68
82
  private var photoSession: PhotoEditSession? = null
69
83
  private var cropMode = false
84
+ private var cropEntryState: PhotoTransformState? = null
85
+ private var photoCropPanel: CropPanel? = null
70
86
  private var adjustMode = false
71
87
  private var filtersMode = false
88
+ private var drawMode = false
72
89
  private var stickersMode = false
73
90
  private lateinit var stickersSubBar: View
74
91
  private var activeAdjustmentKey = "brightness"
75
92
  private var selectedLayerId: String? = null
76
- private var activeLayerPropertyKey = "scale"
93
+ private var activeLayerPropertyKey = ""
77
94
  private var dragStartSnapshot: List<PhotoLayer>? = null
78
95
  private lateinit var photoImageView: ZoomableImageView
79
96
  private lateinit var cropOverlay: CropOverlayView
80
97
  private lateinit var layerOverlay: LayerOverlayView
98
+ private lateinit var drawOverlay: DrawOverlayView
81
99
  private lateinit var straightenSeekBar: SeekBar
82
100
  private lateinit var cropSubBar: View
83
- 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
84
105
  private lateinit var filtersSubBar: View
85
106
  private lateinit var adjustSubBar: View
107
+ private lateinit var drawSubBar: View
86
108
  private lateinit var layerToolBar: View
87
109
  private lateinit var layerPropertySeekBar: SeekBar
88
110
  private lateinit var layerColorSwatchRow: View
@@ -97,9 +119,14 @@ class PhotoVideoEditorActivity : Activity() {
97
119
  private var videoSession: VideoEditSession? = null
98
120
  private var videoExporter: VideoExporter? = null
99
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
100
127
  private lateinit var trimRangeView: TrimRangeView
101
128
  private lateinit var positionSeekBar: SeekBar
102
- private lateinit var playPauseButton: Button
129
+ private lateinit var playPauseButton: ImageButton
103
130
  private lateinit var timeLabel: TextView
104
131
  private lateinit var videoAspectSubBar: View
105
132
  private lateinit var videoToolBar: View
@@ -111,20 +138,22 @@ class PhotoVideoEditorActivity : Activity() {
111
138
  private val positionPollHandler = android.os.Handler(android.os.Looper.getMainLooper())
112
139
  private var positionPollRunnable: Runnable? = null
113
140
  private var seeking = false
141
+ private lateinit var videoControlsOverlay: View
142
+ private var videoControlsVisible = true
143
+ private val hideVideoControlsRunnable = Runnable { setVideoControlsVisible(false) }
114
144
 
115
145
  private val photoToolButtons = mutableMapOf<String, ToolNode>()
116
146
  private var photoToolScroll: HorizontalScrollView? = null
117
147
  private val cropAspectButtons = mutableMapOf<Float?, Button>()
118
- private val filterAdjustmentButtons = mutableMapOf<String, Button>()
119
- /** Preset id ("" = Original) -> (thumbnail frame, label) for the Filters preset-card scroller. */
120
- private val filterPresetCards = mutableMapOf<String, Pair<FrameLayout, TextView>>()
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>()
121
152
  private var resizeMode = false
122
153
  private lateinit var resizeSubBar: View
123
154
  private var resizePresetSize: Pair<Int, Int>? = null
124
155
  private val resizePresetButtons = mutableMapOf<Pair<Int, Int>?, Button>()
125
- private val layerPropertyButtons = mutableMapOf<String, Button>()
126
- /** Non-Delete/Duplicate layer action icons, hidden for TEXT layers (only Opacity/Colors/Duplicate/Delete apply there). */
127
- private val layerIconButtons = mutableMapOf<String, View>()
156
+ private val layerPropertyButtons = mutableMapOf<String, ToolNode>()
128
157
  private val videoToolButtons = mutableMapOf<String, ToolNode>()
129
158
  private val videoAspectButtons = mutableMapOf<Float?, Button>()
130
159
  private val videoFilterButtons = mutableMapOf<String, Button>()
@@ -136,9 +165,12 @@ class PhotoVideoEditorActivity : Activity() {
136
165
  private lateinit var videoLayerToolBar: View
137
166
  private lateinit var videoLayerPropertySeekBar: SeekBar
138
167
  private var selectedVideoLayerId: String? = null
139
- private var activeVideoLayerPropertyKey = "scale"
168
+ private var activeVideoLayerPropertyKey = ""
140
169
  private var videoDragStartSnapshot: List<PhotoLayer>? = null
141
- 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>()
142
174
  private var selectedClipIndex = 0
143
175
  private lateinit var clipStripBar: LinearLayout
144
176
  private var timelineThumbnailRepository: TimelineThumbnailRepository? = null
@@ -227,25 +259,17 @@ class PhotoVideoEditorActivity : Activity() {
227
259
  }
228
260
  photoImageView = imageView
229
261
  photoPreviewRenderer = PhotoPreviewRenderCoordinator(
230
- render = { session.renderPreview() },
262
+ render = { session.renderPreview(cropMode || resizeMode) },
231
263
  display = { bitmap ->
232
- 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
+ }
233
269
  },
234
270
  )
235
271
  previewContainer.addView(imageView, FrameLayout.LayoutParams(MATCH, MATCH))
236
272
 
237
- previewContainer.addView(TextView(this).apply {
238
- text = "● ORIGINAL • 100%"
239
- textSize = 10f
240
- letterSpacing = 0.08f
241
- setTextColor(DesignTokens.onSurfaceVariant)
242
- gravity = Gravity.CENTER
243
- background = roundedDrawable(DesignTokens.withAlphaPercent(DesignTokens.surfaceContainerHigh, 92), DesignTokens.radiusLg)
244
- setPadding(dp(DesignTokens.spaceMd), dp(DesignTokens.spaceXs), dp(DesignTokens.spaceMd), dp(DesignTokens.spaceXs))
245
- }, FrameLayout.LayoutParams(WRAP, WRAP, Gravity.TOP or Gravity.CENTER_HORIZONTAL).apply {
246
- topMargin = dp(DesignTokens.spaceSm)
247
- })
248
-
249
273
  val overlay = CropOverlayView(this).apply { visibility = View.GONE }
250
274
  cropOverlay = overlay
251
275
  previewContainer.addView(overlay, FrameLayout.LayoutParams(MATCH, MATCH))
@@ -254,12 +278,26 @@ class PhotoVideoEditorActivity : Activity() {
254
278
  layerOverlay = layers
255
279
  previewContainer.addView(layers, FrameLayout.LayoutParams(MATCH, MATCH))
256
280
 
281
+ val drawing = DrawOverlayView(this).apply { visibility = View.GONE }
282
+ drawOverlay = drawing
283
+ previewContainer.addView(drawing, FrameLayout.LayoutParams(MATCH, MATCH))
284
+
257
285
  imageView.onBoundsChanged = { bounds ->
258
- if (cropMode) overlay.setImageBounds(bounds, resetCrop = false)
286
+ if (cropMode) overlay.restore(bounds, session.state)
259
287
  layers.setImageBounds(bounds)
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) }
298
+ layers.onLayerDoubleTapped = { id ->
299
+ session.layerStack.layers.firstOrNull { it.id == id && it.type == LayerType.TEXT }?.let { showTextInputDialog(session, it) }
300
+ }
263
301
  layers.onLayerTransformChanged = { id, x, y, scale, rotation ->
264
302
  if (dragStartSnapshot == null) dragStartSnapshot = session.layerStack.layers
265
303
  session.layerStack.updateLive { list ->
@@ -295,10 +333,7 @@ class PhotoVideoEditorActivity : Activity() {
295
333
  })
296
334
  }
297
335
  straightenSeekBar = straighten
298
- root.addView(
299
- straighten,
300
- LinearLayout.LayoutParams(MATCH, WRAP).apply { setMargins(dp(16), dp(4), dp(16), 0) }
301
- )
336
+
302
337
 
303
338
  val cropBar = createCropSubBar(session, imageView, overlay)
304
339
  cropSubBar = cropBar
@@ -310,22 +345,35 @@ class PhotoVideoEditorActivity : Activity() {
310
345
  resizeBar.visibility = View.GONE
311
346
  root.addView(resizeBar, LinearLayout.LayoutParams(MATCH, dp(56)))
312
347
 
313
- val adjustmentSeek = SeekBar(this).apply {
314
- visibility = View.GONE
348
+ val accentColor = themeColor("primaryColor", DesignTokens.primaryContainer)
349
+ adjustmentSeekBar = EditorToolSlider(this).apply {
350
+ applyCompactTrack(accentColor)
315
351
  setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
316
352
  override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
317
353
  if (!fromUser) return
318
354
  val (min, _) = PhotoAdjustments.rangeFor(activeAdjustmentKey)
319
- val value = min + progress
320
- 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()) }
321
371
  schedulePhotoPreviewRender()
322
372
  }
323
373
  override fun onStartTrackingTouch(seekBar: SeekBar?) {}
324
374
  override fun onStopTrackingTouch(seekBar: SeekBar?) {}
325
375
  })
326
376
  }
327
- adjustmentSeekBar = adjustmentSeek
328
- root.addView(adjustmentSeek, LinearLayout.LayoutParams(MATCH, WRAP).apply { setMargins(dp(16), dp(4), dp(16), 0) })
329
377
 
330
378
  val adjustBar = createAdjustSubBar(session, imageView)
331
379
  adjustSubBar = adjustBar
@@ -342,15 +390,22 @@ class PhotoVideoEditorActivity : Activity() {
342
390
  stickersBar.visibility = View.GONE
343
391
  root.addView(stickersBar, LinearLayout.LayoutParams(MATCH, dp(ASSET_GRID_BAR_HEIGHT_DP)))
344
392
 
345
- val layerPropertySeek = SeekBar(this).apply {
393
+ val drawBar = createDrawSubBar(session)
394
+ drawSubBar = drawBar
395
+ drawBar.visibility = View.GONE
396
+ root.addView(drawBar, LinearLayout.LayoutParams(MATCH, dp(56)))
397
+
398
+ val layerPropertySeek = EditorToolSlider(this).apply {
399
+ progressTintList = android.content.res.ColorStateList.valueOf(themeColor("primaryColor", DesignTokens.primaryContainer))
400
+ thumbTintList = progressTintList
346
401
  visibility = View.GONE
347
402
  setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
348
403
  override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
349
404
  if (!fromUser) return
350
405
  onLayerPropertyChanged(progress)
351
406
  }
352
- override fun onStartTrackingTouch(seekBar: SeekBar?) {}
353
- 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() }
354
409
  })
355
410
  }
356
411
  layerPropertySeekBar = layerPropertySeek
@@ -364,7 +419,7 @@ class PhotoVideoEditorActivity : Activity() {
364
419
  val layerBar = createLayerToolBar(session)
365
420
  layerToolBar = layerBar
366
421
  layerBar.visibility = View.GONE
367
- root.addView(layerBar, LinearLayout.LayoutParams(MATCH, dp(56)))
422
+ root.addView(layerBar, LinearLayout.LayoutParams(MATCH, dp(76)))
368
423
 
369
424
  val toolbarHeight = dp(76)
370
425
  val toolbar = createPhotoToolBar(session, imageView, overlay)
@@ -381,7 +436,8 @@ class PhotoVideoEditorActivity : Activity() {
381
436
  resizeBar to dp(56),
382
437
  filtersBar to dp(FILTERS_SUB_BAR_HEIGHT_DP),
383
438
  stickersBar to dp(ASSET_GRID_BAR_HEIGHT_DP),
384
- layerBar to dp(56)
439
+ drawBar to dp(56),
440
+ layerBar to dp(76)
385
441
  )
386
442
  )
387
443
  return outer
@@ -394,42 +450,58 @@ class PhotoVideoEditorActivity : Activity() {
394
450
  Triple("filters", "Filters", R.drawable.ic_photo_filter),
395
451
  Triple("text", "Text", R.drawable.ic_title),
396
452
  Triple("stickers", "Stickers", R.drawable.ic_sentiment_satisfied),
397
- Triple("overlay", "Overlay", R.drawable.ic_wallpaper),
398
- Triple("retouch", "Retouch", R.drawable.ic_face),
399
- Triple("layers", "Layers", R.drawable.ic_layers),
400
- Triple("resize", "Resize", R.drawable.ic_aspect_ratio)
453
+ Triple("draw", "Draw", R.drawable.ic_draw),
454
+ Triple("resize", "Resize", R.drawable.ic_aspect_ratio),
455
+ Triple("compare", "Original", R.drawable.ic_compare)
401
456
  )
402
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
+ */
403
463
  private fun createPhotoToolBar(session: PhotoEditSession, imageView: ZoomableImageView, overlay: CropOverlayView): LinearLayout {
404
464
  val backgroundColor = themeColor("toolbarColor", DesignTokens.withAlphaPercent(DesignTokens.surfaceContainerLowest, 95))
465
+ val itemWidth = photoToolItemWidth()
405
466
  return LinearLayout(this).apply {
406
467
  orientation = LinearLayout.VERTICAL
407
468
  setBackgroundColor(backgroundColor)
408
- setPadding(dp(DesignTokens.spaceXs), dp(DesignTokens.spaceSm), dp(DesignTokens.spaceXs), dp(DesignTokens.spaceLg))
409
-
410
- addView(
411
- View(this@PhotoVideoEditorActivity).apply { background = roundedDrawable(DesignTokens.surfaceVariant, DesignTokens.radiusSm) },
412
- LinearLayout.LayoutParams(dp(36), dp(4)).apply { gravity = Gravity.CENTER_HORIZONTAL; bottomMargin = dp(DesignTokens.spaceXs) }
413
- )
414
469
 
415
470
  val features = request.optJSONObject("features")
416
471
  addView(HorizontalScrollView(this@PhotoVideoEditorActivity).apply {
417
472
  isHorizontalScrollBarEnabled = false
473
+ clipToPadding = false
418
474
  photoToolScroll = this
419
475
  addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
420
476
  orientation = LinearLayout.HORIZONTAL
421
477
  gravity = Gravity.CENTER_VERTICAL
422
- setPadding(dp(DesignTokens.spaceSm), dp(DesignTokens.spaceXs), dp(DesignTokens.spaceSm), dp(DesignTokens.spaceXs))
423
478
  photoToolDefinitions.filter { features?.optBoolean(it.first, true) != false }.forEach { (key, label, iconRes) ->
424
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
+ }
425
490
  photoToolButtons[key] = node
426
- addView(node.root, LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), WRAP).apply { marginEnd = dp(DesignTokens.spaceSm) })
491
+ addView(node.root, LinearLayout.LayoutParams(itemWidth, WRAP))
427
492
  }
428
493
  })
429
494
  }, LinearLayout.LayoutParams(MATCH, WRAP))
430
495
  }
431
496
  }
432
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
+
433
505
  private fun refreshPhotoToolSelection() {
434
506
  val textColor = themeColor("textColor", DesignTokens.onSurface)
435
507
  photoToolButtons.forEach { (key, node) ->
@@ -438,6 +510,7 @@ class PhotoVideoEditorActivity : Activity() {
438
510
  "adjust" -> adjustMode
439
511
  "filters" -> filtersMode
440
512
  "stickers" -> stickersMode
513
+ "draw" -> drawMode
441
514
  "resize" -> resizeMode
442
515
  else -> false
443
516
  }
@@ -460,63 +533,120 @@ class PhotoVideoEditorActivity : Activity() {
460
533
  }
461
534
 
462
535
  private fun onPhotoToolTapped(key: String, session: PhotoEditSession, imageView: ZoomableImageView, overlay: CropOverlayView) {
536
+ if (key != "compare" && comparingOriginal) showOriginalPreview(false)
463
537
  when (key) {
538
+ "compare" -> showOriginalPreview(!comparingOriginal)
464
539
  "crop" -> setCropMode(true, session, imageView, overlay)
465
540
  "adjust" -> setAdjustMode(true, session)
466
541
  "filters" -> setFiltersMode(true, session)
467
542
  "text" -> showTextInputDialog(session)
468
- "stickers" -> setStickersMode(true, session)
469
- "overlay" -> launchOverlayPicker(REQUEST_CODE_OVERLAY_UPLOAD)
543
+ "stickers" -> showStickerBottomSheet(session)
544
+ "draw" -> setDrawMode(true, session)
470
545
  "resize" -> setResizeMode(true, session, imageView, overlay)
471
- "retouch" -> setAdjustMode(true, session)
472
- "layers" -> openLayerManager(session)
473
546
  else -> Toast.makeText(this, "$key is coming in a later milestone.", Toast.LENGTH_SHORT).show()
474
547
  }
475
548
  }
476
549
 
477
- private fun openLayerManager(session: PhotoEditSession) {
478
- val top = session.layerStack.layers.lastOrNull()
479
- if (top == null) Toast.makeText(this, "Add content to start a layer stack.", Toast.LENGTH_LONG).show()
480
- else selectLayer(top.id)
481
- }
482
-
483
550
  // ---------------------------------------------------------------------
484
551
  // Layers: text, stickers, shapes, freehand drawing, selection, and
485
552
  // transform/duplicate/reorder/lock/hide/delete with undo/redo (Milestone 4).
486
553
  // ---------------------------------------------------------------------
487
554
 
488
- private fun showTextInputDialog(session: PhotoEditSession) {
555
+ private fun showTextInputDialog(session: PhotoEditSession, editingLayer: PhotoLayer? = null) {
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
+ })
489
578
  val input = EditText(this).apply {
490
- inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE
491
- setTextColor(DesignTokens.onSurface)
492
- setHintTextColor(DesignTokens.outline)
579
+ inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
493
580
  hint = "Type something..."
494
- setPadding(dp(DesignTokens.spaceLg), dp(DesignTokens.spaceLg), dp(DesignTokens.spaceLg), dp(DesignTokens.spaceLg))
495
- }
496
- val container = FrameLayout(this).apply {
497
- background = roundedDrawable(DesignTokens.surfaceContainerHigh, DesignTokens.radiusLg)
498
- addView(input, FrameLayout.LayoutParams(MATCH, WRAP))
499
- }
500
- AlertDialog.Builder(this, android.R.style.Theme_Material_Dialog_Alert)
501
- .setTitle("Add text")
502
- .setView(container)
503
- .setPositiveButton("Add") { _, _ ->
504
- val text = input.text?.toString().orEmpty()
505
- if (text.isNotBlank()) {
506
- val layer = PhotoLayer(type = LayerType.TEXT, text = text)
507
- session.layerStack.commit { it + layer }
508
- selectLayer(layer.id)
509
- }
510
- }
511
- .setNegativeButton("Cancel", null)
512
- .show()
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)
588
+ setText(editingLayer?.text.orEmpty())
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) }
612
+ }
613
+ dialog.show()
513
614
  }
514
615
 
515
616
  private fun setStickersMode(enabled: Boolean, session: PhotoEditSession) {
516
617
  stickersMode = enabled
517
618
  stickersSubBar.visibility = if (enabled) View.VISIBLE else View.GONE
518
- mainToolBar.visibility = if (enabled) View.GONE else View.VISIBLE
619
+ mainToolBar.visibility = if (enabled || selectedLayerId != null) View.GONE else View.VISIBLE
620
+ refreshPhotoToolSelection()
621
+ }
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
+
637
+ private fun showStickerBottomSheet(session: PhotoEditSession) {
638
+ stickersMode = true
519
639
  refreshPhotoToolSelection()
640
+ OnlineStickerSheet(this, runtimeStickerAssets()) { path ->
641
+ val layer = newSticker(stickerUri = Uri.fromFile(File(path)).toString())
642
+ session.layerStack.commit { it + layer }
643
+ selectLayer(layer.id)
644
+ }.apply {
645
+ setOnDismissListener {
646
+ stickersMode = false
647
+ refreshPhotoToolSelection()
648
+ }
649
+ }.show()
520
650
  }
521
651
 
522
652
  /** One asset card in the Stickers library grid (see stickers_shapes_library mockup). */
@@ -567,7 +697,7 @@ class PhotoVideoEditorActivity : Activity() {
567
697
  val emoji = TextView(this@PhotoVideoEditorActivity).apply { text = PhotoLayerRenderer.glyphFor(id); textSize = 26f }
568
698
  addView(
569
699
  createAssetCard(emoji, null) {
570
- val layer = PhotoLayer(type = LayerType.STICKER, stickerId = id)
700
+ val layer = newSticker(stickerId = id)
571
701
  session.layerStack.commit { it + layer }
572
702
  selectLayer(layer.id)
573
703
  setStickersMode(false, session)
@@ -586,7 +716,7 @@ class PhotoVideoEditorActivity : Activity() {
586
716
  }
587
717
  addView(
588
718
  createAssetCard(icon, id) {
589
- val layer = PhotoLayer(type = LayerType.STICKER, stickerUri = uri)
719
+ val layer = newSticker(stickerUri = uri)
590
720
  session.layerStack.commit { it + layer }
591
721
  selectLayer(layer.id)
592
722
  setStickersMode(false, session)
@@ -605,24 +735,6 @@ class PhotoVideoEditorActivity : Activity() {
605
735
  },
606
736
  LinearLayout.LayoutParams(WRAP, WRAP).apply { marginEnd = dp(DesignTokens.spaceSm) }
607
737
  )
608
- val features = request.optJSONObject("features")
609
- if (features?.optBoolean("onlineStickers", true) != false) {
610
- val browseIcon = ImageView(this@PhotoVideoEditorActivity).apply {
611
- setImageResource(R.drawable.ic_photo_filter)
612
- setColorFilter(DesignTokens.tertiary)
613
- }
614
- addView(
615
- createAssetCard(browseIcon, "Browse") {
616
- OnlineStickerSheet(this@PhotoVideoEditorActivity) { path ->
617
- val layer = PhotoLayer(type = LayerType.STICKER, stickerUri = Uri.fromFile(File(path)).toString())
618
- session.layerStack.commit { it + layer }
619
- selectLayer(layer.id)
620
- setStickersMode(false, session)
621
- }.show()
622
- },
623
- LinearLayout.LayoutParams(WRAP, WRAP).apply { marginEnd = dp(DesignTokens.spaceSm) }
624
- )
625
- }
626
738
  })
627
739
  }, LinearLayout.LayoutParams(0, MATCH, 1f))
628
740
  addView(
@@ -632,6 +744,69 @@ class PhotoVideoEditorActivity : Activity() {
632
744
  }
633
745
  }
634
746
 
747
+ private fun setDrawMode(enabled: Boolean, session: PhotoEditSession) {
748
+ drawMode = enabled
749
+ drawOverlay.visibility = if (enabled) View.VISIBLE else View.GONE
750
+ drawSubBar.visibility = if (enabled) View.VISIBLE else View.GONE
751
+ mainToolBar.visibility = if (enabled) View.GONE else View.VISIBLE
752
+ layerOverlay.visibility = if (enabled) View.GONE else View.VISIBLE
753
+ photoImageView.panZoomEnabled = !enabled
754
+ if (enabled) {
755
+ drawOverlay.strokeColor = Color.RED
756
+ drawOverlay.strokeWidthPx = dp(4).toFloat()
757
+ drawOverlay.setImageBounds(photoImageView.currentImageBounds())
758
+ } else {
759
+ commitDrawStrokes(session)
760
+ drawOverlay.clearStrokes()
761
+ }
762
+ refreshPhotoToolSelection()
763
+ }
764
+
765
+ private fun commitDrawStrokes(session: PhotoEditSession) {
766
+ if (!drawOverlay.hasStrokes) return
767
+ val bounds = photoImageView.currentImageBounds()
768
+ val shortSide = minOf(bounds.width(), bounds.height())
769
+ if (shortSide <= 0f) return
770
+ val newLayers = drawOverlay.normalizedStrokes().mapNotNull { (points, stroke) ->
771
+ if (points.size < 2) return@mapNotNull null
772
+ val centerX = points.sumOf { it.first.toDouble() }.toFloat() / points.size
773
+ val centerY = points.sumOf { it.second.toDouble() }.toFloat() / points.size
774
+ PhotoLayer(
775
+ type = LayerType.DRAWING,
776
+ x = centerX,
777
+ y = centerY,
778
+ drawColor = stroke.color,
779
+ drawStrokeWidth = stroke.widthPx / shortSide,
780
+ drawPoints = points.map { (x, y) -> (x - centerX) to (y - centerY) }
781
+ )
782
+ }
783
+ if (newLayers.isNotEmpty()) session.layerStack.commit { it + newLayers }
784
+ onLayerStackChanged()
785
+ }
786
+
787
+ private fun createDrawSubBar(session: PhotoEditSession): LinearLayout {
788
+ val textColor = themeColor("textColor", DesignTokens.onSurface)
789
+ val primaryColor = themeColor("primaryColor", DesignTokens.primaryContainer)
790
+ val colors = listOf(Color.RED, Color.rgb(255, 214, 51), Color.rgb(76, 217, 100), Color.rgb(10, 132, 255), Color.WHITE, Color.BLACK)
791
+ return LinearLayout(this).apply {
792
+ gravity = Gravity.CENTER_VERTICAL
793
+ setPadding(dp(12), 0, dp(12), 0)
794
+ setBackgroundColor(themeColor("toolbarColor", DesignTokens.surfaceContainerLow))
795
+ addView(HorizontalScrollView(this@PhotoVideoEditorActivity).apply {
796
+ isHorizontalScrollBarEnabled = false
797
+ addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
798
+ orientation = LinearLayout.HORIZONTAL; gravity = Gravity.CENTER_VERTICAL
799
+ colors.forEach { color -> addView(createColorSwatch(color) { drawOverlay.strokeColor = color }, LinearLayout.LayoutParams(dp(36), dp(36)).apply { marginEnd = dp(6) }) }
800
+ addView(actionButton("Thin") { drawOverlay.strokeWidthPx = dp(2).toFloat() }.apply { setTextColor(textColor) })
801
+ addView(actionButton("Thick") { drawOverlay.strokeWidthPx = dp(10).toFloat() }.apply { setTextColor(textColor) })
802
+ addView(actionButton("Undo") { drawOverlay.undoLastStroke() }.apply { setTextColor(textColor) })
803
+ addView(actionButton("Clear") { drawOverlay.clearStrokes() }.apply { setTextColor(textColor) })
804
+ })
805
+ }, LinearLayout.LayoutParams(0, MATCH, 1f))
806
+ addView(actionButton("Done") { setDrawMode(false, session) }.apply { setTextColor(primaryColor) }, LinearLayout.LayoutParams(dp(72), MATCH))
807
+ }
808
+ }
809
+
635
810
  /** Launches the SAF image picker for either the sticker-upload or overlay-upload flow, dispatched in [onActivityResult]. */
636
811
  private fun launchOverlayPicker(requestCode: Int) {
637
812
  val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).setType("image/*").addCategory(Intent.CATEGORY_OPENABLE)
@@ -659,7 +834,7 @@ class PhotoVideoEditorActivity : Activity() {
659
834
  when (requestCode) {
660
835
  REQUEST_CODE_STICKER_UPLOAD -> {
661
836
  val session = photoSession ?: return
662
- val layer = PhotoLayer(type = LayerType.STICKER, stickerUri = resolvedUri)
837
+ val layer = newSticker(stickerUri = resolvedUri)
663
838
  session.layerStack.commit { it + layer }
664
839
  selectLayer(layer.id)
665
840
  setStickersMode(false, session)
@@ -679,7 +854,7 @@ class PhotoVideoEditorActivity : Activity() {
679
854
  }
680
855
  REQUEST_CODE_VIDEO_STICKER_UPLOAD -> {
681
856
  val session = videoSession ?: return
682
- val layer = PhotoLayer(type = LayerType.STICKER, stickerUri = resolvedUri)
857
+ val layer = newSticker(stickerUri = resolvedUri)
683
858
  session.layerStack.commit { it + layer }
684
859
  onVideoLayerStackChanged()
685
860
  selectVideoLayer(layer.id, session)
@@ -703,52 +878,37 @@ class PhotoVideoEditorActivity : Activity() {
703
878
 
704
879
  private fun selectLayer(id: String?) {
705
880
  val session = photoSession
881
+ if (selectedLayerId != id) activeLayerPropertyKey = ""
706
882
  selectedLayerId = id
707
883
  layerOverlay.selectedLayerId = id
708
884
  if (session != null) {
709
885
  layerOverlay.layers = session.layerStack.layers
710
886
  schedulePhotoPreviewRender()
711
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
+ }
712
894
  setLayerToolBarVisible(id != null)
713
895
  }
714
896
 
715
897
  private fun setLayerToolBarVisible(visible: Boolean) {
716
898
  layerToolBar.visibility = if (visible) View.VISIBLE else View.GONE
717
899
  mainToolBar.visibility = if (visible) View.GONE else View.VISIBLE
718
- if (visible) {
719
- // TEXT layers show only Opacity/Colors/Duplicate/Delete; STICKER layers show only
720
- // Opacity/Duplicate/Delete (no Colors either) Scale/Rotate/Size and the
721
- // lock/visibility/reorder icons don't apply to either and are hidden per each reduced list.
722
- val selectedType = currentSelectedLayer()?.type
723
- val isText = selectedType == LayerType.TEXT
724
- val isSticker = selectedType == LayerType.STICKER
725
- val reducedControls = isText || isSticker
726
- layerPropertyButtons["scale"]?.visibility = if (reducedControls) View.GONE else View.VISIBLE
727
- layerPropertyButtons["rotation"]?.visibility = if (reducedControls) View.GONE else View.VISIBLE
728
- layerPropertyButtons["fontSize"]?.visibility = View.GONE
729
- layerPropertyButtons["color"]?.visibility = if (isText) View.VISIBLE else View.GONE
730
- listOf("lock", "visibility", "front", "back").forEach { key ->
731
- layerIconButtons[key]?.visibility = if (reducedControls) View.GONE else View.VISIBLE
732
- }
733
- val allowedKeys = when {
734
- isText -> setOf("opacity", "color")
735
- isSticker -> setOf("opacity")
736
- else -> setOf("scale", "rotation", "opacity")
737
- }
738
- if (activeLayerPropertyKey !in allowedKeys) {
739
- activeLayerPropertyKey = "opacity"
740
- }
741
- layerPropertySeekBar.visibility = if (activeLayerPropertyKey == "color") View.GONE else View.VISIBLE
742
- layerColorSwatchRow.visibility = if (activeLayerPropertyKey == "color") View.VISIBLE else View.GONE
743
- syncLayerPropertySeekBar()
744
- refreshSelection(layerPropertyButtons, activeLayerPropertyKey, themeColor("textColor", DesignTokens.onSurface), themeColor("primaryColor", DesignTokens.primaryContainer))
745
- } else {
746
- layerPropertySeekBar.visibility = View.GONE
747
- layerColorSwatchRow.visibility = View.GONE
748
- }
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)
749
910
  }
750
911
 
751
- /** Circular color swatch, matching the drawing_brush_studio mockup's palette dots. */
752
912
  private fun createColorSwatch(color: Int, onClick: () -> Unit): FrameLayout {
753
913
  val outer = FrameLayout(this).apply {
754
914
  background = circleDrawable(DesignTokens.surfaceContainerHigh)
@@ -761,38 +921,29 @@ class PhotoVideoEditorActivity : Activity() {
761
921
  }
762
922
 
763
923
  /** Text color swatch row, shown when a TEXT layer's "Color" property is active (text_editor_typography_studio mockup). */
764
- private fun createTextColorSwatchRow(session: PhotoEditSession): LinearLayout {
765
- val toolbarColor = themeColor("toolbarColor", DesignTokens.surfaceContainerLow)
766
- val colors = listOf(
767
- Color.WHITE, Color.BLACK, Color.RED, Color.rgb(255, 214, 51),
768
- Color.rgb(76, 217, 100), Color.rgb(0, 145, 255), DesignTokens.primary, DesignTokens.tertiary
769
- )
770
- return LinearLayout(this).apply {
771
- gravity = Gravity.CENTER_VERTICAL
772
- setPadding(dp(12), 0, dp(12), 0)
773
- setBackgroundColor(toolbarColor)
774
- addView(HorizontalScrollView(this@PhotoVideoEditorActivity).apply {
775
- isHorizontalScrollBarEnabled = false
776
- addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
777
- orientation = LinearLayout.HORIZONTAL
778
- gravity = Gravity.CENTER_VERTICAL
779
- colors.forEach { color ->
780
- addView(
781
- createColorSwatch(color) {
782
- val id = selectedLayerId ?: return@createColorSwatch
783
- session.layerStack.commit { list -> list.map { if (it.id == id) it.copy(textColor = color) else it } }
784
- onLayerStackChanged()
785
- },
786
- LinearLayout.LayoutParams(dp(32), dp(32)).apply { marginEnd = dp(DesignTokens.spaceSm) }
787
- )
788
- }
789
- })
790
- }, LinearLayout.LayoutParams(0, MATCH, 1f))
791
- addView(
792
- actionButton("Done") { selectLayer(null) }.apply { setTextColor(themeColor("primaryColor", DesignTokens.primaryContainer)) },
793
- LinearLayout.LayoutParams(dp(72), MATCH)
794
- )
795
- }
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)))
796
947
  }
797
948
 
798
949
  private fun currentSelectedLayer(): PhotoLayer? {
@@ -803,13 +954,14 @@ class PhotoVideoEditorActivity : Activity() {
803
954
  private fun syncLayerPropertySeekBar() {
804
955
  val layer = currentSelectedLayer() ?: return
805
956
  val (min, max, value) = when (activeLayerPropertyKey) {
806
- "rotation" -> Triple(-180f, 180f, layer.rotationDegrees)
957
+ "rotation" -> Triple(-180f, 180f, ((layer.rotationDegrees % 360f + 540f) % 360f) - 180f)
807
958
  "opacity" -> Triple(0f, 100f, layer.opacity * 100f)
808
959
  "fontSize" -> Triple(12f, 160f, layer.fontSize)
809
960
  else -> Triple(20f, 800f, layer.scale * 100f)
810
961
  }
811
962
  layerPropertySeekBar.max = (max - min).roundToInt()
812
963
  layerPropertySeekBar.progress = (value - min).roundToInt()
964
+ (layerPropertySeekBar as? EditorToolSlider)?.propertyKey = activeLayerPropertyKey
813
965
  }
814
966
 
815
967
  private fun onLayerPropertyChanged(progress: Int) {
@@ -837,373 +989,292 @@ class PhotoVideoEditorActivity : Activity() {
837
989
  schedulePhotoPreviewRender()
838
990
  }
839
991
 
840
- private fun createLayerToolBar(session: PhotoEditSession): LinearLayout {
841
- val toolbarColor = themeColor("toolbarColor", DesignTokens.surfaceContainerLow)
842
- val textColor = themeColor("textColor", DesignTokens.onSurface)
843
- val primaryColor = themeColor("primaryColor", DesignTokens.primaryContainer)
844
- // "fontSize"/"color" only make sense for TEXT layers; their chips are hidden/shown per-selection in `setLayerToolBarVisible`.
845
- val properties = listOf("scale" to "Scale", "rotation" to "Rotate", "opacity" to "Opacity", "fontSize" to "Size", "color" to "Color")
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)))
846
1022
  return LinearLayout(this).apply {
847
1023
  gravity = Gravity.CENTER_VERTICAL
848
- setPadding(dp(12), 0, dp(12), 0)
849
- setBackgroundColor(toolbarColor)
1024
+ setBackgroundColor(themeColor("toolbarColor", DesignTokens.surfaceContainerLow))
850
1025
  addView(HorizontalScrollView(this@PhotoVideoEditorActivity).apply {
851
1026
  isHorizontalScrollBarEnabled = false
852
1027
  addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
853
- orientation = LinearLayout.HORIZONTAL
854
1028
  gravity = Gravity.CENTER_VERTICAL
855
- properties.forEach { (key, label) ->
856
- val chip = actionButton(label) {
857
- activeLayerPropertyKey = key
858
- layerPropertySeekBar.visibility = if (key == "color") View.GONE else View.VISIBLE
859
- layerColorSwatchRow.visibility = if (key == "color") View.VISIBLE else View.GONE
860
- if (key != "color") syncLayerPropertySeekBar()
861
- refreshSelection(layerPropertyButtons, activeLayerPropertyKey, textColor, primaryColor)
862
- }.apply { setTextColor(textColor) }
863
- layerPropertyButtons[key] = chip
864
- addView(chip, LinearLayout.LayoutParams(dp(80), MATCH))
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))
865
1034
  }
866
- addView(
867
- createIconButton(R.drawable.ic_content_copy, "Duplicate layer", textColor) {
868
- val original = currentSelectedLayer() ?: return@createIconButton
869
- val copy = original.copy(id = java.util.UUID.randomUUID().toString(), x = (original.x + 0.04f).coerceIn(0f, 1f), y = (original.y + 0.04f).coerceIn(0f, 1f))
870
- session.layerStack.commit { it + copy }
871
- selectLayer(copy.id)
872
- },
873
- LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin))
874
- )
875
- addView(
876
- createIconButton(R.drawable.ic_lock, "Toggle lock", textColor) {
877
- val id = selectedLayerId ?: return@createIconButton
878
- session.layerStack.commit { list -> list.map { if (it.id == id) it.copy(locked = !it.locked) else it } }
879
- onLayerStackChanged()
880
- }.also { layerIconButtons["lock"] = it },
881
- LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin))
882
- )
883
- addView(
884
- createIconButton(R.drawable.ic_visibility_off, "Toggle visibility", textColor) {
885
- val id = selectedLayerId ?: return@createIconButton
886
- session.layerStack.commit { list -> list.map { if (it.id == id) it.copy(visible = !it.visible) else it } }
887
- onLayerStackChanged()
888
- }.also { layerIconButtons["visibility"] = it },
889
- LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin))
890
- )
891
- addView(
892
- createIconButton(R.drawable.ic_chevron_right, "Bring to front", textColor) {
893
- val id = selectedLayerId ?: return@createIconButton
894
- session.layerStack.commit { list -> (list.filter { it.id != id } + list.first { it.id == id }) }
895
- onLayerStackChanged()
896
- }.also { layerIconButtons["front"] = it },
897
- LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin))
898
- )
899
- addView(
900
- createIconButton(R.drawable.ic_chevron_left, "Send to back", textColor) {
901
- val id = selectedLayerId ?: return@createIconButton
902
- session.layerStack.commit { list -> (listOf(list.first { it.id == id }) + list.filter { it.id != id }) }
903
- onLayerStackChanged()
904
- }.also { layerIconButtons["back"] = it },
905
- LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin))
906
- )
907
- addView(
908
- createIconButton(R.drawable.ic_delete, "Delete layer", textColor) {
909
- val id = selectedLayerId ?: return@createIconButton
910
- session.layerStack.commit { list -> list.filterNot { it.id == id } }
911
- selectLayer(null)
912
- onLayerStackChanged()
913
- },
914
- LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin))
915
- )
916
1035
  })
917
1036
  }, LinearLayout.LayoutParams(0, MATCH, 1f))
918
- addView(
919
- actionButton("Done") { selectLayer(null) }.apply { setTextColor(primaryColor) },
920
- LinearLayout.LayoutParams(dp(72), MATCH)
921
- )
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))
922
1040
  }
923
1041
  }
924
1042
 
925
- /** Adjust (image_adjustments_workspace mockup) — Brightness/Contrast/Saturation/etc sliders + Mirror. */
926
1043
  private fun setAdjustMode(enabled: Boolean, session: PhotoEditSession) {
927
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.
928
1047
  adjustSubBar.visibility = if (enabled) View.VISIBLE else View.GONE
929
- adjustmentSeekBar.visibility = if (enabled) View.VISIBLE else View.GONE
930
1048
  mainToolBar.visibility = if (enabled) View.GONE else View.VISIBLE
931
1049
  layerOverlay.visibility = if (enabled) View.GONE else View.VISIBLE
932
- 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) }
933
1053
  refreshPhotoToolSelection()
934
- refreshAdjustSelection()
1054
+ refreshAdjustSelection(session)
935
1055
  }
936
1056
 
937
- /** Filters (filter_browser_library mockup) — preset thumbnail chips + strength slider. */
1057
+ /** Filters — preset thumbnail carousel with an intensity slider above it. */
938
1058
  private fun setFiltersMode(enabled: Boolean, session: PhotoEditSession) {
939
1059
  filtersMode = enabled
940
1060
  filtersSubBar.visibility = if (enabled) View.VISIBLE else View.GONE
941
- adjustmentSeekBar.visibility = if (enabled) View.VISIBLE else View.GONE
942
1061
  mainToolBar.visibility = if (enabled) View.GONE else View.VISIBLE
943
1062
  layerOverlay.visibility = if (enabled) View.GONE else View.VISIBLE
944
- if (enabled) { activeAdjustmentKey = "filterStrength"; syncAdjustmentSeekBar(session) }
1063
+ if (enabled) { syncAdjustmentSeekBar(session); fadeInPanel(filtersSubBar) }
945
1064
  refreshPhotoToolSelection()
946
1065
  refreshFiltersSelection(session)
947
1066
  }
948
1067
 
949
- private fun refreshAdjustSelection() {
950
- val textColor = themeColor("textColor", DesignTokens.onSurface)
951
- val primaryColor = themeColor("primaryColor", DesignTokens.primaryContainer)
952
- 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()
953
1073
  }
954
1074
 
955
- private fun refreshFiltersSelection(session: PhotoEditSession) {
956
- val textColor = themeColor("textColor", DesignTokens.onSurface)
1075
+ private fun refreshAdjustSelection(session: PhotoEditSession) {
957
1076
  val primaryColor = themeColor("primaryColor", DesignTokens.primaryContainer)
958
- val activePreset = session.adjustments.filterPreset ?: ""
959
- filterPresetCards.forEach { (id, pair) ->
960
- val (frame, label) = pair
961
- val selected = id == activePreset
962
- frame.background = roundedDrawable(DesignTokens.surfaceContainerHighest, DesignTokens.radiusLg)
963
- frame.foreground = if (selected) {
964
- android.graphics.drawable.GradientDrawable().apply {
965
- cornerRadius = dp(DesignTokens.radiusLg).toFloat()
966
- setStroke(dp(2), DesignTokens.primaryContainer)
967
- }
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)
968
1083
  } else {
969
- null
1084
+ item.setSelected(key == activeAdjustmentKey, primaryColor)
1085
+ item.setModified(session.adjustments.value(key) != defaults.value(key), primaryColor)
970
1086
  }
971
- label.setTextColor(if (selected) primaryColor else DesignTokens.outline)
972
- label.setTypeface(label.typeface, if (selected) android.graphics.Typeface.BOLD else android.graphics.Typeface.NORMAL)
973
1087
  }
974
1088
  }
975
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. */
976
1099
  private fun syncAdjustmentSeekBar(session: PhotoEditSession) {
977
1100
  val (min, max) = PhotoAdjustments.rangeFor(activeAdjustmentKey)
978
1101
  adjustmentSeekBar.max = (max - min).roundToInt()
979
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()
980
1111
  }
981
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"
1116
+ }
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
+ */
982
1138
  private fun createAdjustSubBar(session: PhotoEditSession, imageView: ZoomableImageView): LinearLayout {
983
- val toolbarColor = themeColor("toolbarColor", DesignTokens.surfaceContainerLow)
984
- val textColor = themeColor("textColor", DesignTokens.onSurface)
985
1139
  val primaryColor = themeColor("primaryColor", DesignTokens.primaryContainer)
986
- val adjustmentChips = listOf(
987
- "brightness" to "Brightness", "contrast" to "Contrast", "saturation" to "Saturation",
988
- "exposure" to "Exposure", "temperature" to "Temp", "blurRadius" to "Blur"
989
- )
990
1140
  return LinearLayout(this).apply {
991
- gravity = Gravity.CENTER_VERTICAL
992
- setPadding(dp(12), 0, dp(12), 0)
993
- setBackgroundColor(toolbarColor)
994
- addView(HorizontalScrollView(this@PhotoVideoEditorActivity).apply {
995
- isHorizontalScrollBarEnabled = false
996
- addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
997
- orientation = LinearLayout.HORIZONTAL
998
- adjustmentChips.forEach { (key, label) ->
999
- val chip = actionButton(label) {
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) {
1000
1166
  activeAdjustmentKey = key
1001
1167
  syncAdjustmentSeekBar(session)
1002
- refreshAdjustSelection()
1003
- }.apply { setTextColor(textColor) }
1004
- filterAdjustmentButtons[key] = chip
1005
- 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))
1006
1172
  }
1007
- addView(
1008
- actionButton("Mirror") {
1009
- session.updateAdjustments { it.copy(mirror = !it.mirror) }
1010
- schedulePhotoPreviewRender()
1011
- }.apply { setTextColor(textColor) },
1012
- LinearLayout.LayoutParams(dp(84), MATCH)
1013
- )
1014
- addView(
1015
- actionButton("Reset") {
1016
- session.updateAdjustments { it.copy(brightness = 0f, contrast = 0f, saturation = 0f, exposure = 0f, temperature = 0f, blurRadius = 0f, mirror = false) }
1017
- syncAdjustmentSeekBar(session)
1018
- schedulePhotoPreviewRender()
1019
- refreshAdjustSelection()
1020
- }.apply { setTextColor(textColor) },
1021
- LinearLayout.LayoutParams(dp(72), MATCH)
1022
- )
1023
- })
1024
- }, LinearLayout.LayoutParams(0, MATCH, 1f))
1025
- addView(
1026
- actionButton("Done") { setAdjustMode(false, session) }.apply { setTextColor(primaryColor) },
1027
- LinearLayout.LayoutParams(dp(72), MATCH)
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))
1028
1184
  )
1029
1185
  }
1030
1186
  }
1031
1187
 
1032
- /** Preset thumbnail card (64x64 preview + label), matching the "Preset Thumbnail Cards" component. */
1033
- private fun createPresetCard(thumbnail: Bitmap?, labelText: String, textColor: Int, onClick: () -> Unit): Triple<LinearLayout, FrameLayout, TextView> {
1034
- val thumbSize = dp(64)
1035
- val image = ImageView(this).apply {
1036
- thumbnail?.let { setImageBitmap(it) }
1037
- scaleType = ImageView.ScaleType.CENTER_CROP
1038
- }
1039
- val frame = FrameLayout(this).apply {
1040
- background = roundedDrawable(DesignTokens.surfaceContainerHighest, DesignTokens.radiusLg)
1041
- clipToOutline = true
1042
- addView(image, FrameLayout.LayoutParams(MATCH, MATCH))
1043
- }
1044
- val label = TextView(this).apply {
1045
- text = labelText
1046
- textSize = 11f
1047
- gravity = Gravity.CENTER
1048
- setTextColor(DesignTokens.outline)
1049
- setPadding(0, dp(DesignTokens.spaceXs), 0, 0)
1050
- maxLines = 1
1051
- }
1052
- val root = LinearLayout(this).apply {
1053
- orientation = LinearLayout.VERTICAL
1054
- gravity = Gravity.CENTER_HORIZONTAL
1055
- isClickable = true
1056
- setPadding(dp(4), 0, dp(4), 0)
1057
- addView(frame, LinearLayout.LayoutParams(thumbSize, thumbSize))
1058
- addView(label, LinearLayout.LayoutParams(dp(72), WRAP))
1059
- setOnClickListener { onClick() }
1060
- }
1061
- return Triple(root, frame, label)
1062
- }
1063
-
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
+ */
1064
1193
  private fun createFiltersSubBar(session: PhotoEditSession, imageView: ZoomableImageView): LinearLayout {
1065
- val toolbarColor = themeColor("toolbarColor", DesignTokens.surfaceContainerLow)
1066
- val textColor = themeColor("textColor", DesignTokens.onSurface)
1067
1194
  val primaryColor = themeColor("primaryColor", DesignTokens.primaryContainer)
1068
1195
  return LinearLayout(this).apply {
1069
- gravity = Gravity.CENTER_VERTICAL
1070
- setPadding(dp(12), dp(8), dp(12), dp(8))
1071
- setBackgroundColor(toolbarColor)
1072
- addView(HorizontalScrollView(this@PhotoVideoEditorActivity).apply {
1073
- isHorizontalScrollBarEnabled = false
1074
- addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
1075
- orientation = LinearLayout.HORIZONTAL
1076
- gravity = Gravity.CENTER_VERTICAL
1077
- val (originalCard, originalFrame, originalLabel) = createPresetCard(session.baseBitmap, "Original", textColor) {
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") {
1078
1211
  session.updateAdjustments { it.copy(filterPreset = null, filterStrength = 100f) }
1079
1212
  syncAdjustmentSeekBar(session)
1080
1213
  schedulePhotoPreviewRender()
1081
1214
  refreshFiltersSelection(session)
1082
1215
  }
1083
- 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) })
1084
1219
  PhotoFilterPresets.PRESET_IDS.forEach { id ->
1085
- val (card, frame, label) = createPresetCard(null, PhotoFilterPresets.labelFor(id), textColor) {
1220
+ val thumbnail = editorFilterThumbnail(this@PhotoVideoEditorActivity, PhotoFilterPresets.labelFor(id)) {
1086
1221
  session.updateAdjustments { it.copy(filterPreset = if (it.filterPreset == id) null else id) }
1087
- activeAdjustmentKey = "filterStrength"
1088
1222
  syncAdjustmentSeekBar(session)
1089
1223
  schedulePhotoPreviewRender()
1090
1224
  refreshFiltersSelection(session)
1091
1225
  }
1092
- val thumbnailView = frame.getChildAt(0) as ImageView
1093
- filterThumbnailLoader.load(session.baseBitmap, id, dp(64)) { thumbnailView.setImageBitmap(it) }
1094
- filterPresetCards[id] = Pair(frame, label)
1095
- addView(card, LinearLayout.LayoutParams(WRAP, WRAP).apply { marginEnd = dp(DesignTokens.spaceSm) })
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) })
1096
1233
  }
1097
- filterPresetCards[""] = Pair(originalFrame, originalLabel)
1098
- })
1099
- }, LinearLayout.LayoutParams(0, MATCH, 1f))
1100
- addView(
1101
- actionButton("Done") { setFiltersMode(false, session) }.apply { setTextColor(primaryColor) },
1102
- LinearLayout.LayoutParams(dp(72), MATCH)
1234
+ },
1235
+ LinearLayout.LayoutParams(MATCH, dp(EditorPanelMetrics.STRIP_HEIGHT_DP + 24))
1103
1236
  )
1104
1237
  }
1105
1238
  }
1106
1239
 
1107
1240
  private fun setCropMode(enabled: Boolean, session: PhotoEditSession, imageView: ZoomableImageView, overlay: CropOverlayView) {
1241
+ if (enabled && !cropMode) cropEntryState = session.state
1108
1242
  cropMode = enabled
1109
1243
  imageView.panZoomEnabled = !enabled
1110
1244
  overlay.visibility = if (enabled) View.VISIBLE else View.GONE
1111
- straightenSeekBar.visibility = if (enabled) View.VISIBLE else View.GONE
1112
1245
  cropSubBar.visibility = if (enabled) View.VISIBLE else View.GONE
1113
1246
  mainToolBar.visibility = if (enabled) View.GONE else View.VISIBLE
1114
1247
  layerOverlay.visibility = if (enabled) View.GONE else View.VISIBLE
1115
- if (enabled) {
1116
- imageView.resetToFit()
1117
- val state = session.state
1118
- val isIdentityCrop = state.cropLeft == 0f && state.cropTop == 0f && state.cropRight == 1f && state.cropBottom == 1f
1119
- overlay.setImageBounds(imageView.currentImageBounds(), resetCrop = isIdentityCrop)
1120
- }
1248
+ if (!enabled) cropEntryState = null
1249
+ photoCropPanel?.sync(session.state)
1250
+ schedulePhotoPreviewRender()
1121
1251
  refreshPhotoToolSelection()
1122
- refreshSelection(cropAspectButtons, session.state.aspectRatio, themeColor("textColor", DesignTokens.onSurface), themeColor("primaryColor", DesignTokens.primaryContainer))
1123
1252
  }
1124
1253
 
1125
- private fun createCropSubBar(session: PhotoEditSession, imageView: ZoomableImageView, overlay: CropOverlayView): LinearLayout {
1126
- val toolbarColor = themeColor("toolbarColor", DesignTokens.surfaceContainerLow)
1127
- val textColor = themeColor("textColor", DesignTokens.onSurface)
1128
- val primaryColor = themeColor("primaryColor", DesignTokens.primaryContainer)
1129
- val aspectPresets = listOf<Pair<String, Float?>>(
1130
- "Free" to null, "1:1" to 1f, "4:5" to 4f / 5f, "3:4" to 3f / 4f, "9:16" to 9f / 16f, "16:9" to 16f / 9f
1131
- )
1132
- return LinearLayout(this).apply {
1133
- orientation = LinearLayout.VERTICAL
1134
- setPadding(0, dp(6), 0, dp(6))
1135
- setBackgroundColor(toolbarColor)
1136
-
1137
- addView(TextView(this@PhotoVideoEditorActivity).apply {
1138
- text = "Crop Mode • TRANSFORM & ASPECT RATIO"
1139
- textSize = 14f
1140
- setTypeface(typeface, android.graphics.Typeface.BOLD)
1141
- setTextColor(textColor)
1142
- gravity = Gravity.CENTER_VERTICAL
1143
- setPadding(dp(DesignTokens.spaceLg), 0, dp(DesignTokens.spaceLg), 0)
1144
- }, LinearLayout.LayoutParams(MATCH, dp(36)))
1145
-
1146
- // Rotate/Flip row — moved here from the main tool rail to match the crop_transform_editor mockup.
1147
- addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
1148
- orientation = LinearLayout.HORIZONTAL
1149
- gravity = Gravity.CENTER
1150
- addView(
1151
- createIconButton(R.drawable.ic_rotate_left, "Rotate left", textColor) {
1152
- session.update { it.rotatedRight().rotatedRight().rotatedRight() }
1153
- schedulePhotoPreviewRender()
1154
- imageView.post { imageView.resetToFit(); overlay.setImageBounds(imageView.currentImageBounds(), resetCrop = true) }
1155
- },
1156
- LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin))
1157
- )
1158
- addView(
1159
- createIconButton(R.drawable.ic_rotate_right, "Rotate right", textColor) {
1160
- session.update { it.rotatedRight() }
1161
- schedulePhotoPreviewRender()
1162
- imageView.post { imageView.resetToFit(); overlay.setImageBounds(imageView.currentImageBounds(), resetCrop = true) }
1163
- },
1164
- LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin))
1165
- )
1166
- }, LinearLayout.LayoutParams(MATCH, dp(DesignTokens.touchTargetMin)))
1254
+ private fun cancelCropMode(session: PhotoEditSession, imageView: ZoomableImageView, overlay: CropOverlayView) {
1255
+ cropEntryState?.let { snapshot -> session.update { snapshot } }
1256
+ setCropMode(false, session, imageView, overlay)
1257
+ }
1167
1258
 
1168
- addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
1169
- gravity = Gravity.CENTER_VERTICAL
1170
- setPadding(dp(12), 0, dp(12), 0)
1171
- addView(HorizontalScrollView(this@PhotoVideoEditorActivity).apply {
1172
- isHorizontalScrollBarEnabled = false
1173
- addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
1174
- orientation = LinearLayout.HORIZONTAL
1175
- aspectPresets.forEach { (label, ratio) ->
1176
- val chip = actionButton(label) {
1177
- overlay.setAspectRatio(ratio)
1178
- session.update { it.withAspectRatio(ratio) }
1179
- refreshSelection(cropAspectButtons, ratio, textColor, primaryColor)
1180
- }.apply { setTextColor(textColor) }
1181
- cropAspectButtons[ratio] = chip
1182
- addView(chip, LinearLayout.LayoutParams(dp(64), MATCH))
1183
- }
1184
- addView(
1185
- actionButton("Reset") {
1186
- session.update { it.reset() }
1187
- overlay.setAspectRatio(null)
1188
- straightenSeekBar.progress = 45
1189
- schedulePhotoPreviewRender()
1190
- imageView.resetToFit()
1191
- overlay.setImageBounds(imageView.currentImageBounds(), resetCrop = true)
1192
- refreshSelection(cropAspectButtons, null, textColor, primaryColor)
1193
- }.apply { setTextColor(textColor) },
1194
- LinearLayout.LayoutParams(dp(72), MATCH)
1195
- )
1196
- })
1197
- }, LinearLayout.LayoutParams(0, MATCH, 1f))
1198
- addView(
1199
- actionButton("Done") { setCropMode(false, session, imageView, overlay) }.apply { setTextColor(primaryColor) },
1200
- LinearLayout.LayoutParams(dp(72), MATCH)
1201
- )
1202
- }, LinearLayout.LayoutParams(MATCH, dp(56)))
1203
- }
1259
+ private fun createCropSubBar(session: PhotoEditSession, imageView: ZoomableImageView, overlay: CropOverlayView): LinearLayout {
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) }
1204
1276
  }
1205
1277
 
1206
- /** Canvas Resize & Social Presets (canvas_resize_social_presets mockup): reuses the crop-aspect mechanism to frame the shot, plus an exact export pixel size for the chosen platform. */
1207
1278
  private fun setResizeMode(enabled: Boolean, session: PhotoEditSession, imageView: ZoomableImageView, overlay: CropOverlayView) {
1208
1279
  resizeMode = enabled
1209
1280
  imageView.panZoomEnabled = !enabled
@@ -1266,6 +1337,7 @@ class PhotoVideoEditorActivity : Activity() {
1266
1337
  finishWithError("Nothing to export.", "E_INTERNAL")
1267
1338
  return
1268
1339
  }
1340
+ if (drawMode) setDrawMode(false, session)
1269
1341
  exportProgressView.visibility = View.VISIBLE
1270
1342
  val state = session.state
1271
1343
  val adjustments = session.adjustments
@@ -1316,7 +1388,12 @@ class PhotoVideoEditorActivity : Activity() {
1316
1388
  root.addView(header, LinearLayout.LayoutParams(MATCH, headerHeight))
1317
1389
 
1318
1390
  val preview = FrameLayout(this).apply { setBackgroundColor(Color.BLACK) }
1319
- 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
+ )
1320
1397
 
1321
1398
  val session = VideoEditSession(this, sourceUri)
1322
1399
  videoSession = session
@@ -1349,6 +1426,7 @@ class PhotoVideoEditorActivity : Activity() {
1349
1426
  useController = false
1350
1427
  contentDescription = "Selected video preview"
1351
1428
  }
1429
+ cropPlayerView = playerView
1352
1430
  preview.addView(playerView, FrameLayout.LayoutParams(MATCH, MATCH))
1353
1431
 
1354
1432
  val overlayView = ImageView(this).apply {
@@ -1360,9 +1438,33 @@ class PhotoVideoEditorActivity : Activity() {
1360
1438
  videoOverlayImageView = overlayView
1361
1439
  preview.addView(overlayView, FrameLayout.LayoutParams(MATCH, MATCH))
1362
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
+ }
1363
1458
  val layerOverlay = LayerOverlayView(this)
1364
1459
  videoLayerOverlay = layerOverlay
1365
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
+ }
1366
1468
  layerOverlay.onLayerTapped = { id -> selectVideoLayer(id, session) }
1367
1469
  layerOverlay.onLayerTransformChanged = { id, x, y, scale, rotation ->
1368
1470
  if (videoDragStartSnapshot == null) videoDragStartSnapshot = session.layerStack.layers
@@ -1378,44 +1480,82 @@ class PhotoVideoEditorActivity : Activity() {
1378
1480
  onVideoLayerStackChanged()
1379
1481
  }
1380
1482
 
1381
- val playPause = Button(this).apply {
1382
- text = "Play"
1383
- setTextColor(Color.WHITE)
1384
- setBackgroundColor(Color.TRANSPARENT)
1483
+ val accentColor = themeColor("primaryColor", DesignTokens.primaryContainer)
1484
+
1485
+ val playPause = ImageButton(this).apply {
1486
+ contentDescription = "Play video"
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))
1385
1492
  setOnClickListener {
1386
- if (session.player.isPlaying) session.player.pause() else session.player.play()
1493
+ if (session.player.isPlaying) {
1494
+ session.player.pause()
1495
+ } else {
1496
+ if (session.player.playbackState == Player.STATE_ENDED || session.globalPositionMs() >= session.durationMs - 50L) {
1497
+ session.seekToGlobalMs(0)
1498
+ }
1499
+ session.player.play()
1500
+ }
1501
+ setVideoControlsVisible(true)
1387
1502
  }
1503
+ applyPressScale()
1388
1504
  }
1389
1505
  playPauseButton = playPause
1390
1506
  val time = TextView(this).apply {
1391
1507
  text = "0:00 / 0:00"
1392
- setTextColor(textColor)
1393
- textSize = 12f
1508
+ setTextColor(Color.WHITE)
1509
+ textSize = 11f
1394
1510
  }
1395
1511
  timeLabel = time
1396
1512
  val transport = LinearLayout(this).apply {
1397
1513
  orientation = LinearLayout.HORIZONTAL
1398
1514
  gravity = Gravity.CENTER_VERTICAL
1399
- setBackgroundColor(toolbarColor)
1400
- setPadding(dp(8), 0, dp(8), 0)
1401
- addView(playPause, LinearLayout.LayoutParams(dp(72), WRAP))
1402
- addView(time, LinearLayout.LayoutParams(WRAP, WRAP))
1515
+ addView(playPause, LinearLayout.LayoutParams(dp(44), dp(44)))
1516
+ addView(time, LinearLayout.LayoutParams(WRAP, WRAP).apply { marginStart = dp(DesignTokens.spaceSm) })
1403
1517
  }
1404
- root.addView(transport, LinearLayout.LayoutParams(MATCH, dp(40)))
1405
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
+ }
1406
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
1407
1535
  max = 1000
1408
1536
  setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
1409
1537
  override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
1410
1538
  if (!fromUser || session.durationMs <= 0) return
1411
1539
  session.seekToGlobalMs(session.durationMs * progress / 1000)
1540
+ updateTimeLabel()
1412
1541
  }
1413
- override fun onStartTrackingTouch(seekBar: SeekBar?) { seeking = true }
1414
- 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) }
1415
1544
  })
1416
1545
  }
1417
1546
  positionSeekBar = seek
1418
- 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) }
1419
1559
 
1420
1560
  val trim = TrimRangeView(this).apply {
1421
1561
  // Bound to the currently selected clip (Milestone 7): fractions are relative to that clip's
@@ -1433,14 +1573,16 @@ class PhotoVideoEditorActivity : Activity() {
1433
1573
  }
1434
1574
  }
1435
1575
  trimRangeView = trim
1436
- root.addView(trim, LinearLayout.LayoutParams(MATCH, dp(48)).apply { setMargins(dp(8), dp(4), dp(8), 0) })
1576
+ // Single-video editor: keep the internal range initialized for export,
1577
+ // but do not show the trim/clip timeline UI.
1437
1578
 
1438
1579
  val strip = LinearLayout(this).apply {
1439
1580
  orientation = LinearLayout.HORIZONTAL
1440
1581
  setPadding(dp(8), dp(4), dp(8), dp(4))
1441
1582
  }
1583
+ // Keep the internal container initialized for the single-clip session,
1584
+ // but do not add the multi-clip strip/actions to the video editor UI.
1442
1585
  clipStripBar = strip
1443
- root.addView(strip, LinearLayout.LayoutParams(MATCH, WRAP))
1444
1586
 
1445
1587
  session.onClipsReady = {
1446
1588
  runOnUiThread {
@@ -1460,6 +1602,17 @@ class PhotoVideoEditorActivity : Activity() {
1460
1602
  }
1461
1603
  }
1462
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)))
1463
1616
  val aspectBar = createVideoAspectSubBar(session, playerView)
1464
1617
  aspectBar.visibility = View.GONE
1465
1618
  videoAspectSubBar = aspectBar
@@ -1484,32 +1637,43 @@ class PhotoVideoEditorActivity : Activity() {
1484
1637
  videoFiltersSubBar = filtersBar
1485
1638
  root.addView(filtersBar, LinearLayout.LayoutParams(MATCH, dp(56)))
1486
1639
 
1487
- 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
1488
1643
  visibility = View.GONE
1489
1644
  setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
1490
1645
  override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
1491
1646
  if (fromUser) onVideoLayerPropertyChanged(progress, session)
1492
1647
  }
1493
- override fun onStartTrackingTouch(seekBar: SeekBar?) {}
1494
- 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() }
1495
1650
  })
1496
1651
  }
1497
1652
  videoLayerPropertySeekBar = layerPropertySeek
1498
1653
  root.addView(layerPropertySeek, LinearLayout.LayoutParams(MATCH, WRAP).apply { setMargins(dp(8), dp(4), dp(8), 0) })
1499
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)))
1500
1662
  val layerBar = createVideoLayerToolBar(session)
1501
1663
  layerBar.visibility = View.GONE
1502
1664
  videoLayerToolBar = layerBar
1503
- root.addView(layerBar, LinearLayout.LayoutParams(MATCH, dp(56)))
1665
+ root.addView(layerBar, LinearLayout.LayoutParams(MATCH, dp(76)))
1504
1666
 
1505
- 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)
1506
1670
  val toolbar = createVideoToolBar(session, playerView)
1507
1671
  videoToolBar = toolbar
1508
1672
  root.addView(toolbar, LinearLayout.LayoutParams(MATCH, toolbarHeight))
1509
1673
  applySystemBarInsets(
1510
1674
  header,
1511
1675
  headerHeight,
1512
- 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))
1513
1677
  )
1514
1678
 
1515
1679
  startPositionPolling(session)
@@ -1517,13 +1681,20 @@ class PhotoVideoEditorActivity : Activity() {
1517
1681
  }
1518
1682
 
1519
1683
  private fun startPositionPolling(session: VideoEditSession) {
1684
+ var wasPlaying = false
1520
1685
  val runnable = object : Runnable {
1521
1686
  override fun run() {
1522
1687
  if (!seeking && session.durationMs > 0) {
1523
1688
  positionSeekBar.progress = (session.globalPositionMs() * 1000 / session.durationMs).toInt().coerceIn(0, 1000)
1524
1689
  updateTimeLabel()
1525
1690
  }
1526
- playPauseButton.text = if (session.player.isPlaying) "Pause" else "Play"
1691
+ val playing = session.player.isPlaying
1692
+ playPauseButton.setImageResource(if (playing) R.drawable.ic_pause else R.drawable.ic_play)
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
+ }
1527
1698
  refreshVideoOverlayPreview(session)
1528
1699
  positionPollHandler.postDelayed(this, 250)
1529
1700
  }
@@ -1532,6 +1703,35 @@ class PhotoVideoEditorActivity : Activity() {
1532
1703
  positionPollHandler.postDelayed(runnable, 250)
1533
1704
  }
1534
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
+
1535
1735
  private fun updateTimeLabel() {
1536
1736
  val session = videoSession ?: return
1537
1737
  timeLabel.text = "${formatMs(session.globalPositionMs())} / ${formatMs(session.durationMs)}"
@@ -1561,9 +1761,16 @@ class PhotoVideoEditorActivity : Activity() {
1561
1761
  videoOverlayRenderKey = null
1562
1762
  return
1563
1763
  }
1564
- val videoSize = session.player.videoSize
1565
- val sourceWidth = videoSize.width.takeIf { it > 0 } ?: 1280
1566
- 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)
1567
1774
  val previewScale = minOf(1f, 1280f / maxOf(sourceWidth, sourceHeight))
1568
1775
  val width = (sourceWidth * previewScale).roundToInt().coerceAtLeast(2)
1569
1776
  val height = (sourceHeight * previewScale).roundToInt().coerceAtLeast(2)
@@ -1589,23 +1796,62 @@ class PhotoVideoEditorActivity : Activity() {
1589
1796
  return bitmap
1590
1797
  }
1591
1798
 
1592
- /** Where the video actually renders within [view] under `FIT_CENTER`-style letterboxing (mirrors `ZoomableImageView.resetToFit`'s math). */
1593
- private fun computeVideoLetterboxBounds(session: VideoEditSession, view: View): android.graphics.RectF {
1594
- val viewWidth = view.width.toFloat()
1595
- val viewHeight = view.height.toFloat()
1596
- val videoSize = session.player.videoSize
1597
- if (viewWidth <= 0 || viewHeight <= 0 || videoSize.width <= 0 || videoSize.height <= 0) {
1598
- return android.graphics.RectF(0f, 0f, viewWidth, viewHeight)
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)
1599
1844
  }
1600
- val scale = minOf(viewWidth / videoSize.width, viewHeight / videoSize.height)
1601
- val fittedWidth = videoSize.width * scale
1602
- val fittedHeight = videoSize.height * scale
1603
- val left = (viewWidth - fittedWidth) / 2f
1604
- val top = (viewHeight - fittedHeight) / 2f
1605
- return android.graphics.RectF(left, top, left + fittedWidth, top + fittedHeight)
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)
1606
1853
  }
1607
1854
 
1608
- /** Mirrors [onLayerStackChanged] for the video overlay stack (Milestone 6). */
1609
1855
  private fun onVideoLayerStackChanged() {
1610
1856
  val session = videoSession ?: return
1611
1857
  if (selectedVideoLayerId != null && session.layerStack.layers.none { it.id == selectedVideoLayerId }) {
@@ -1621,16 +1867,25 @@ class PhotoVideoEditorActivity : Activity() {
1621
1867
  }
1622
1868
 
1623
1869
  private fun selectVideoLayer(id: String?, session: VideoEditSession) {
1870
+ if (selectedVideoLayerId != id) activeVideoLayerPropertyKey = ""
1624
1871
  selectedVideoLayerId = id
1625
1872
  videoLayerOverlay.selectedLayerId = id
1626
1873
  setVideoLayerToolBarVisible(id != null, session)
1627
1874
  }
1628
1875
 
1629
1876
  private fun setVideoLayerToolBarVisible(visible: Boolean, session: VideoEditSession) {
1877
+ if (visible) setVideoControlsVisible(true)
1630
1878
  videoLayerToolBar.visibility = if (visible) View.VISIBLE else View.GONE
1631
- videoLayerPropertySeekBar.visibility = if (visible) View.VISIBLE else View.GONE
1632
1879
  videoToolBar.visibility = if (visible) View.GONE else View.VISIBLE
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
1633
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)
1634
1889
  }
1635
1890
 
1636
1891
  private fun currentSelectedVideoLayer(session: VideoEditSession): PhotoLayer? =
@@ -1639,12 +1894,14 @@ class PhotoVideoEditorActivity : Activity() {
1639
1894
  private fun syncVideoLayerPropertySeekBar(session: VideoEditSession) {
1640
1895
  val layer = currentSelectedVideoLayer(session) ?: return
1641
1896
  val (min, max, value) = when (activeVideoLayerPropertyKey) {
1642
- "rotation" -> Triple(-180f, 180f, layer.rotationDegrees)
1897
+ "rotation" -> Triple(-180f, 180f, ((layer.rotationDegrees % 360f + 540f) % 360f) - 180f)
1643
1898
  "opacity" -> Triple(0f, 100f, layer.opacity * 100f)
1899
+ "fontSize" -> Triple(12f, 160f, layer.fontSize)
1644
1900
  "start" -> Triple(0f, session.durationMs.toFloat(), layer.startMs.toFloat())
1645
1901
  "end" -> Triple(0f, session.durationMs.toFloat(), session.durationMs.effectiveEndOr(layer.endMs).toFloat())
1646
1902
  else -> Triple(20f, 800f, layer.scale * 100f)
1647
1903
  }
1904
+ (videoLayerPropertySeekBar as? EditorToolSlider)?.propertyKey = activeVideoLayerPropertyKey
1648
1905
  videoLayerPropertySeekBar.max = (max - min).roundToInt()
1649
1906
  videoLayerPropertySeekBar.progress = (value - min).roundToInt()
1650
1907
  }
@@ -1656,6 +1913,7 @@ class PhotoVideoEditorActivity : Activity() {
1656
1913
  val min = when (activeVideoLayerPropertyKey) {
1657
1914
  "rotation" -> -180f
1658
1915
  "opacity", "start", "end" -> 0f
1916
+ "fontSize" -> 12f
1659
1917
  else -> 20f
1660
1918
  }
1661
1919
  val value = min + progress
@@ -1665,6 +1923,7 @@ class PhotoVideoEditorActivity : Activity() {
1665
1923
  when (activeVideoLayerPropertyKey) {
1666
1924
  "rotation" -> layer.copy(rotationDegrees = value)
1667
1925
  "opacity" -> layer.copy(opacity = value / 100f)
1926
+ "fontSize" -> layer.copy(fontSize = value)
1668
1927
  "start" -> layer.copy(startMs = value.toLong().coerceAtMost(layer.endMs.takeIf { it > 0 } ?: session.durationMs))
1669
1928
  "end" -> layer.copy(endMs = value.toLong().coerceAtLeast(layer.startMs))
1670
1929
  else -> layer.copy(scale = value / 100f)
@@ -1674,101 +1933,102 @@ class PhotoVideoEditorActivity : Activity() {
1674
1933
  refreshVideoOverlayPreview(session)
1675
1934
  }
1676
1935
 
1677
- private fun createVideoLayerToolBar(session: VideoEditSession): LinearLayout {
1678
- val toolbarColor = themeColor("toolbarColor", DesignTokens.surfaceContainerLow)
1679
- val textColor = themeColor("textColor", DesignTokens.onSurface)
1680
- val primaryColor = themeColor("primaryColor", DesignTokens.primaryContainer)
1681
- val properties = listOf(
1682
- "scale" to "Scale", "rotation" to "Rotate", "opacity" to "Opacity", "start" to "Start", "end" to "End"
1683
- )
1684
- return LinearLayout(this).apply {
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
1685
1969
  gravity = Gravity.CENTER_VERTICAL
1686
- setPadding(dp(12), 0, dp(12), 0)
1687
- setBackgroundColor(toolbarColor)
1688
- addView(HorizontalScrollView(this@PhotoVideoEditorActivity).apply {
1689
- isHorizontalScrollBarEnabled = false
1690
- addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
1691
- orientation = LinearLayout.HORIZONTAL
1692
- properties.forEach { (key, label) ->
1693
- val chip = actionButton(label) {
1694
- activeVideoLayerPropertyKey = key
1695
- syncVideoLayerPropertySeekBar(session)
1696
- refreshSelection(videoLayerPropertyButtons, activeVideoLayerPropertyKey, textColor, primaryColor)
1697
- }.apply { setTextColor(textColor) }
1698
- videoLayerPropertyButtons[key] = chip
1699
- addView(chip, LinearLayout.LayoutParams(dp(72), MATCH))
1700
- }
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) ->
1701
1974
  addView(
1702
- actionButton("Delete") {
1703
- val id = selectedVideoLayerId ?: return@actionButton
1704
- session.layerStack.commit { list -> list.filterNot { it.id == id } }
1705
- selectVideoLayer(null, session)
1706
- onVideoLayerStackChanged()
1707
- }.apply { setTextColor(textColor) },
1708
- LinearLayout.LayoutParams(dp(80), MATCH)
1975
+ createVideoDockAction(key, label) { onVideoToolTapped(key, session, playerView) },
1976
+ LinearLayout.LayoutParams(0, MATCH, 1f)
1709
1977
  )
1710
- })
1711
- }, LinearLayout.LayoutParams(0, MATCH, 1f))
1978
+ }
1979
+ }
1980
+ return FrameLayout(this).apply {
1712
1981
  addView(
1713
- actionButton("Done") { selectVideoLayer(null, session) }.apply { setTextColor(primaryColor) },
1714
- 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
+ }
1715
1986
  )
1716
1987
  }
1717
1988
  }
1718
1989
 
1719
- private fun showVideoTextInputDialog(session: VideoEditSession) {
1720
- val input = EditText(this).apply { inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE }
1721
- AlertDialog.Builder(this)
1722
- .setTitle("Add text")
1723
- .setView(input)
1724
- .setPositiveButton("Add") { _, _ ->
1725
- val text = input.text?.toString().orEmpty()
1726
- if (text.isNotBlank()) {
1727
- val layer = PhotoLayer(type = LayerType.TEXT, text = text)
1728
- session.layerStack.commit { it + layer }
1729
- onVideoLayerStackChanged()
1730
- selectVideoLayer(layer.id, session)
1731
- }
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)
1732
2002
  }
1733
- .setNegativeButton("Cancel", null)
1734
- .show()
1735
- }
1736
-
1737
- private fun createVideoToolBar(session: VideoEditSession, playerView: PlayerView): LinearLayout {
1738
- val backgroundColor = themeColor("toolbarColor", DesignTokens.withAlphaPercent(DesignTokens.surfaceContainerLowest, 95))
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)
1739
2010
  return LinearLayout(this).apply {
1740
- orientation = LinearLayout.VERTICAL
1741
- setBackgroundColor(backgroundColor)
1742
- setPadding(dp(DesignTokens.spaceXs), dp(DesignTokens.spaceSm), dp(DesignTokens.spaceXs), dp(DesignTokens.spaceLg))
1743
-
1744
- addView(
1745
- View(this@PhotoVideoEditorActivity).apply { background = roundedDrawable(DesignTokens.surfaceVariant, DesignTokens.radiusSm) },
1746
- LinearLayout.LayoutParams(dp(36), dp(4)).apply { gravity = Gravity.CENTER_HORIZONTAL; bottomMargin = dp(DesignTokens.spaceXs) }
1747
- )
1748
-
1749
- val features = request.optJSONObject("features")
1750
- val tools = listOf(
1751
- Triple("cover", "Cover", R.drawable.ic_movie),
1752
- Triple("speed", "Speed 1x", R.drawable.ic_speed),
1753
- Triple("crop", "Crop", R.drawable.ic_crop),
1754
- Triple("filters", "Filters", R.drawable.ic_photo_filter),
1755
- Triple("text", "Text", R.drawable.ic_title),
1756
- Triple("stickers", "Stickers", R.drawable.ic_sentiment_satisfied),
1757
- Triple("overlay", "Overlay", R.drawable.ic_wallpaper)
1758
- )
1759
- addView(HorizontalScrollView(this@PhotoVideoEditorActivity).apply {
1760
- isHorizontalScrollBarEnabled = false
1761
- addView(LinearLayout(this@PhotoVideoEditorActivity).apply {
1762
- orientation = LinearLayout.HORIZONTAL
1763
- gravity = Gravity.CENTER_VERTICAL
1764
- setPadding(dp(DesignTokens.spaceSm), dp(DesignTokens.spaceXs), dp(DesignTokens.spaceSm), dp(DesignTokens.spaceXs))
1765
- tools.filter { features?.optBoolean(it.first, true) != false }.forEach { (key, label, iconRes) ->
1766
- val node = createToolNode(iconRes, label) { onVideoToolTapped(key, session, playerView) }
1767
- videoToolButtons[key] = node
1768
- addView(node.root, LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), WRAP).apply { marginEnd = dp(DesignTokens.spaceSm) })
1769
- }
1770
- })
1771
- }, LinearLayout.LayoutParams(MATCH, WRAP))
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()
1772
2032
  }
1773
2033
  }
1774
2034
 
@@ -1786,6 +2046,7 @@ class PhotoVideoEditorActivity : Activity() {
1786
2046
 
1787
2047
  private fun onVideoToolTapped(key: String, session: VideoEditSession, playerView: PlayerView) {
1788
2048
  when (key) {
2049
+ "crop" -> setVideoCropMode(true, session)
1789
2050
  "cover" -> {
1790
2051
  val globalMs = session.globalPositionMs()
1791
2052
  session.update { it.withCoverFrame(globalMs) }
@@ -1810,45 +2071,21 @@ class PhotoVideoEditorActivity : Activity() {
1810
2071
  }
1811
2072
 
1812
2073
  private fun showVideoStickerPicker(session: VideoEditSession) {
1813
- val consumerAssets = request.optJSONArray("stickerAssets")
1814
- val builtin = PhotoLayerRenderer.builtinStickerIds()
1815
- val consumerCount = consumerAssets?.length() ?: 0
1816
- val features = request.optJSONObject("features")
1817
- val onlineStickersEnabled = features?.optBoolean("onlineStickers", true) != false
1818
- val labels = (
1819
- builtin.map { PhotoLayerRenderer.glyphFor(it) } +
1820
- (0 until consumerCount).map { i -> consumerAssets?.optJSONObject(i)?.optString("id", "asset$i") ?: "asset$i" } +
1821
- listOf("Upload from device") +
1822
- (if (onlineStickersEnabled) listOf("Browse online") else emptyList())
1823
- ).toTypedArray()
1824
- AlertDialog.Builder(this)
1825
- .setTitle("Add sticker")
1826
- .setItems(labels) { _, index ->
1827
- when {
1828
- index < builtin.size -> {
1829
- val layer = PhotoLayer(type = LayerType.STICKER, stickerId = builtin[index])
1830
- session.layerStack.commit { it + layer }
1831
- onVideoLayerStackChanged()
1832
- selectVideoLayer(layer.id, session)
1833
- }
1834
- index < builtin.size + consumerCount -> {
1835
- val asset = consumerAssets?.optJSONObject(index - builtin.size)
1836
- val uri = asset?.optString("uri").takeUnless { it.isNullOrBlank() } ?: return@setItems
1837
- val layer = PhotoLayer(type = LayerType.STICKER, stickerUri = uri)
1838
- session.layerStack.commit { it + layer }
1839
- onVideoLayerStackChanged()
1840
- selectVideoLayer(layer.id, session)
1841
- }
1842
- index == builtin.size + consumerCount -> launchOverlayPicker(REQUEST_CODE_VIDEO_STICKER_UPLOAD)
1843
- else -> OnlineStickerSheet(this) { path ->
1844
- val layer = PhotoLayer(type = LayerType.STICKER, stickerUri = Uri.fromFile(File(path)).toString())
1845
- session.layerStack.commit { it + layer }
1846
- onVideoLayerStackChanged()
1847
- selectVideoLayer(layer.id, session)
1848
- }.show()
1849
- }
1850
- }
1851
- .show()
2074
+ OnlineStickerSheet(this, runtimeStickerAssets()) { path ->
2075
+ val layer = newSticker(stickerUri = Uri.fromFile(File(path)).toString())
2076
+ session.layerStack.commit { it + layer }
2077
+ onVideoLayerStackChanged()
2078
+ selectVideoLayer(layer.id, session)
2079
+ }.show()
2080
+ }
2081
+
2082
+ private fun runtimeStickerAssets(): List<RuntimeSticker> {
2083
+ val assets = request.optJSONArray("stickerAssets") ?: return emptyList()
2084
+ return (0 until assets.length()).mapNotNull { index ->
2085
+ val asset = assets.optJSONObject(index) ?: return@mapNotNull null
2086
+ val uri = asset.optString("uri").takeIf { it.isNotBlank() } ?: return@mapNotNull null
2087
+ RuntimeSticker(asset.optString("id").takeIf { it.isNotBlank() } ?: "sticker-${index + 1}", uri)
2088
+ }
1852
2089
  }
1853
2090
 
1854
2091
  private fun formatSpeedLabel(speed: Float): String {
@@ -2227,7 +2464,9 @@ class PhotoVideoEditorActivity : Activity() {
2227
2464
 
2228
2465
  addView(TextView(this@PhotoVideoEditorActivity).apply {
2229
2466
  text = if (mediaType == "photo") "Photo Editor" else "Video Editor"
2230
- textSize = 16f
2467
+ textSize = 14f
2468
+ maxLines = 1
2469
+ ellipsize = android.text.TextUtils.TruncateAt.END
2231
2470
  gravity = Gravity.CENTER
2232
2471
  setTextColor(textColor)
2233
2472
  setTypeface(typeface, android.graphics.Typeface.BOLD)
@@ -2241,25 +2480,16 @@ class PhotoVideoEditorActivity : Activity() {
2241
2480
  if (mediaType == "photo") { photoSession?.layerStack?.redo(); onLayerStackChanged() }
2242
2481
  else { videoSession?.layerStack?.redo(); onVideoLayerStackChanged() }
2243
2482
  }
2483
+ undo.isEnabled = false; undo.alpha = 0.4f
2484
+ redo.isEnabled = false; redo.alpha = 0.4f
2244
2485
  undoButton = undo
2245
2486
  redoButton = redo
2246
2487
  addView(undo, LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin)))
2247
2488
  addView(redo, LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin)))
2248
2489
 
2249
- if (mediaType == "photo") {
2250
- addView(createIconButton(R.drawable.ic_compare, "Hold to compare with original", textColor) {}.apply {
2251
- setOnTouchListener { _, event ->
2252
- when (event.actionMasked) {
2253
- MotionEvent.ACTION_DOWN -> showOriginalPreview(true)
2254
- MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> showOriginalPreview(false)
2255
- }
2256
- true
2257
- }
2258
- }, LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin)))
2259
- }
2260
2490
 
2261
2491
  addView(
2262
- editorPrimaryButton(this@PhotoVideoEditorActivity, "Export", R.drawable.ic_share, primaryColor, onPrimaryColor) {
2492
+ editorPrimaryButton(this@PhotoVideoEditorActivity, "Export", null, primaryColor, onPrimaryColor) {
2263
2493
  if (mediaType == "photo") showPhotoExportConfiguration() else finishDone()
2264
2494
  },
2265
2495
  LinearLayout.LayoutParams(WRAP, dp(DesignTokens.touchTargetMin)).apply { marginStart = dp(DesignTokens.spaceXs) }
@@ -2381,17 +2611,20 @@ class PhotoVideoEditorActivity : Activity() {
2381
2611
  val label = TextView(this).apply {
2382
2612
  text = labelText
2383
2613
  textSize = 11f
2614
+ maxLines = 1
2615
+ includeFontPadding = false
2384
2616
  gravity = Gravity.CENTER
2385
2617
  setTextColor(DesignTokens.outline)
2386
- setPadding(0, dp(DesignTokens.spaceXs), 0, 0)
2618
+ setPadding(0, dp(2), 0, 0)
2387
2619
  }
2388
2620
  val root = LinearLayout(this).apply {
2389
2621
  orientation = LinearLayout.VERTICAL
2390
2622
  gravity = Gravity.CENTER_HORIZONTAL
2391
2623
  isClickable = true
2392
2624
  isFocusable = true
2625
+ contentDescription = labelText
2393
2626
  background = roundedDrawable(Color.TRANSPARENT, DesignTokens.radiusLg)
2394
- setPadding(0, dp(4), 0, dp(4))
2627
+ setPadding(0, dp(2), 0, dp(2))
2395
2628
  addView(tile, LinearLayout.LayoutParams(dp(DesignTokens.touchTargetMin), dp(DesignTokens.touchTargetMin)))
2396
2629
  addView(label, LinearLayout.LayoutParams(WRAP, WRAP))
2397
2630
  setOnClickListener { onClick() }
@@ -2401,17 +2634,23 @@ class PhotoVideoEditorActivity : Activity() {
2401
2634
  }
2402
2635
 
2403
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
+ */
2404
2641
  private fun setToolNodeSelected(node: ToolNode, selected: Boolean, textColor: Int) {
2405
- node.tile.background = roundedDrawable(
2406
- if (selected) themeColor("primaryColor", DesignTokens.primaryContainer) else DesignTokens.surfaceContainerLow,
2407
- DesignTokens.radiusLg
2408
- )
2409
- node.icon.setColorFilter(if (selected) DesignTokens.onPrimaryContainer else DesignTokens.outline)
2410
- node.label.setTextColor(if (selected) DesignTokens.primary else DesignTokens.outline)
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)
2411
2647
  }
2412
2648
 
2413
2649
  private fun <K> refreshToolNodeSelection(nodes: Map<K, ToolNode>, activeKey: K?, textColor: Int) {
2414
- 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
+ }
2415
2654
  }
2416
2655
 
2417
2656
  private fun showPreviewError(container: FrameLayout) {
@@ -2460,7 +2699,13 @@ class PhotoVideoEditorActivity : Activity() {
2460
2699
  }
2461
2700
 
2462
2701
  @Deprecated("Deprecated in Java")
2463
- 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
+ }
2464
2709
 
2465
2710
  override fun onTrimMemory(level: Int) {
2466
2711
  super.onTrimMemory(level)
@@ -2493,12 +2738,17 @@ class PhotoVideoEditorActivity : Activity() {
2493
2738
 
2494
2739
  companion object {
2495
2740
  /** Rotate icon row (44dp) + aspect-preset chip row (56dp). */
2496
- const val CROP_SUB_BAR_HEIGHT_DP = 148
2497
- const val ADJUST_SUB_BAR_HEIGHT_DP = 112
2498
- /** 64dp preset thumbnail + label + vertical padding, matching the "Preset Thumbnail Cards" spec. */
2499
- const val FILTERS_SUB_BAR_HEIGHT_DP = 104
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
2500
2746
  /** 64dp asset card + optional caption + vertical padding, for the Stickers/Shapes grid. */
2501
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
2502
2752
  private const val TIMELINE_THUMBNAIL_COUNT = 12
2503
2753
  const val EXTRA_URI = "photoVideoEditor.uri"
2504
2754
  const val EXTRA_TYPE = "photoVideoEditor.type"