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