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,319 @@
|
|
|
1
|
+
package com.photovideoeditor.video.export
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.graphics.Bitmap
|
|
5
|
+
import android.graphics.BitmapFactory
|
|
6
|
+
import android.media.MediaMetadataRetriever
|
|
7
|
+
import android.net.Uri
|
|
8
|
+
import android.os.Handler
|
|
9
|
+
import android.os.Looper
|
|
10
|
+
import androidx.media3.common.Effect
|
|
11
|
+
import androidx.media3.common.MediaItem
|
|
12
|
+
import androidx.media3.common.MimeTypes
|
|
13
|
+
import androidx.media3.common.util.Size
|
|
14
|
+
import androidx.media3.effect.BitmapOverlay
|
|
15
|
+
import androidx.media3.effect.Brightness
|
|
16
|
+
import androidx.media3.effect.Contrast
|
|
17
|
+
import androidx.media3.effect.HslAdjustment
|
|
18
|
+
import androidx.media3.effect.OverlayEffect
|
|
19
|
+
import androidx.media3.effect.Presentation
|
|
20
|
+
import androidx.media3.effect.ScaleAndRotateTransformation
|
|
21
|
+
import androidx.media3.effect.TextureOverlay
|
|
22
|
+
import androidx.media3.transformer.Composition
|
|
23
|
+
import androidx.media3.transformer.EditedMediaItem
|
|
24
|
+
import androidx.media3.transformer.EditedMediaItemSequence
|
|
25
|
+
import androidx.media3.transformer.Effects
|
|
26
|
+
import androidx.media3.transformer.ExportException
|
|
27
|
+
import androidx.media3.transformer.ExportResult
|
|
28
|
+
import androidx.media3.transformer.ProgressHolder
|
|
29
|
+
import androidx.media3.transformer.Transformer
|
|
30
|
+
import com.photovideoeditor.files.SourceResolver
|
|
31
|
+
import com.photovideoeditor.photo.render.PhotoLayer
|
|
32
|
+
import com.photovideoeditor.photo.render.PhotoLayerRenderer
|
|
33
|
+
import com.photovideoeditor.video.render.VideoClip
|
|
34
|
+
import com.photovideoeditor.video.render.VideoTransformState
|
|
35
|
+
import org.json.JSONObject
|
|
36
|
+
import java.io.File
|
|
37
|
+
import java.util.UUID
|
|
38
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
39
|
+
|
|
40
|
+
data class VideoExportResult(
|
|
41
|
+
val uri: String,
|
|
42
|
+
val width: Int,
|
|
43
|
+
val height: Int,
|
|
44
|
+
val durationMs: Long,
|
|
45
|
+
val fileSize: Long,
|
|
46
|
+
val mimeType: String
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
class VideoExportException(val code: String, message: String) : Exception(message)
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Wraps Media3 Transformer to apply trim/rotate/aspect-ratio/overlays
|
|
53
|
+
* to the full-resolution source video and mux an H.264/AAC MP4. The source
|
|
54
|
+
* file is never modified. Must be constructed and used from a thread with a
|
|
55
|
+
* Looper (Transformer requirement) — the main thread satisfies this.
|
|
56
|
+
*
|
|
57
|
+
* Milestone 7 (multi-clip timeline): each [VideoClip] becomes its own
|
|
58
|
+
* [EditedMediaItem] (own trim, same global rotate/aspect/filter effects),
|
|
59
|
+
* concatenated gapless via a single [EditedMediaItemSequence] passed to
|
|
60
|
+
* [Composition] — verified against the actual Media3 1.4.1 bytecode (`javap`)
|
|
61
|
+
* before writing this: `EditedMediaItemSequence(List<EditedMediaItem>)`,
|
|
62
|
+
* `Composition.Builder(EditedMediaItemSequence, EditedMediaItemSequence...)`,
|
|
63
|
+
* and `Transformer.start(Composition, String)` all exist in this version.
|
|
64
|
+
* Overlay timing is authored against the *global* composed timeline (matching
|
|
65
|
+
* `VideoEditSession.globalPositionMs()`), so each clip's overlay callback
|
|
66
|
+
* offsets its own (per-item, zero-based) `presentationTimeUs` by the summed
|
|
67
|
+
* trimmed duration of the clips before it to recover the global position.
|
|
68
|
+
*
|
|
69
|
+
* `state.speed` is intentionally NOT applied here. Media3 1.4.1 (the version
|
|
70
|
+
* this module is pinned to, verified via a live Gradle dependency resolution)
|
|
71
|
+
* does not expose `SpeedProvider`/`TimestampAdjustment(SpeedProvider)` — that
|
|
72
|
+
* landed in a later release. Bumping to a newer Media3 version to get it broke
|
|
73
|
+
* other already-verified APIs used elsewhere in this file (`Transformer.Builder
|
|
74
|
+
* .setTransformationRequest`, `OverlayEffect`'s list parameter type), so rather
|
|
75
|
+
* than chase version bumps mid-session and risk destabilizing the working
|
|
76
|
+
* export pipeline, this is a deliberate, documented scope cut — see
|
|
77
|
+
* docs/video-editor.md. Preview still honors speed via `ExoPlayer.setPlaybackSpeed`.
|
|
78
|
+
*/
|
|
79
|
+
class VideoExporter(private val context: Context) {
|
|
80
|
+
private var transformer: Transformer? = null
|
|
81
|
+
private var outputFile: File? = null
|
|
82
|
+
private var polling = false
|
|
83
|
+
private val handler = Handler(Looper.getMainLooper())
|
|
84
|
+
private val overlayBitmaps = ConcurrentHashMap.newKeySet<Bitmap>()
|
|
85
|
+
|
|
86
|
+
fun export(
|
|
87
|
+
clips: List<VideoClip>,
|
|
88
|
+
state: VideoTransformState,
|
|
89
|
+
layers: List<PhotoLayer>,
|
|
90
|
+
exportOptions: JSONObject?,
|
|
91
|
+
onProgress: (Float) -> Unit,
|
|
92
|
+
onComplete: (VideoExportResult) -> Unit,
|
|
93
|
+
onError: (VideoExportException) -> Unit
|
|
94
|
+
) {
|
|
95
|
+
releaseOverlayBitmaps()
|
|
96
|
+
if (clips.isEmpty()) {
|
|
97
|
+
onError(VideoExportException("E_SOURCE_NOT_FOUND", "There are no clips to export."))
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
val totalDurationMs = clips.sumOf { it.trimmedDurationMs() }
|
|
101
|
+
|
|
102
|
+
val outputDir = File(context.cacheDir, "photovideoeditor").apply { mkdirs() }
|
|
103
|
+
val output = File(outputDir, "${UUID.randomUUID()}.mp4")
|
|
104
|
+
outputFile = output
|
|
105
|
+
|
|
106
|
+
val (sourceWidth, sourceHeight) = readUprightVideoSize(mediaUri(clips.first().sourceUri))
|
|
107
|
+
|
|
108
|
+
val editedItems = mutableListOf<EditedMediaItem>()
|
|
109
|
+
var precedingDurationMs = 0L
|
|
110
|
+
for (clip in clips) {
|
|
111
|
+
val effects = mutableListOf<Effect>()
|
|
112
|
+
if (state.rotationDegrees != 0) {
|
|
113
|
+
effects.add(
|
|
114
|
+
ScaleAndRotateTransformation.Builder()
|
|
115
|
+
.setRotationDegrees(state.rotationDegrees.toFloat())
|
|
116
|
+
.build()
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
state.aspectRatio?.let { ratio ->
|
|
120
|
+
effects.add(Presentation.createForAspectRatio(ratio, Presentation.LAYOUT_SCALE_TO_FIT_WITH_CROP))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Video-level filters: export-only for now, no live preview — see docs/video-editor.md.
|
|
124
|
+
if (state.brightness != 0f) effects.add(Brightness(state.brightness / 100f))
|
|
125
|
+
if (state.contrast != 0f) effects.add(Contrast(state.contrast / 100f))
|
|
126
|
+
if (state.saturation != 0f) {
|
|
127
|
+
effects.add(HslAdjustment.Builder().adjustSaturation(state.saturation / 100f).build())
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Each overlay has its own [PhotoLayer.startMs]/[endMs] on the *global* composed timeline, so the
|
|
131
|
+
// composited bitmap is genuinely time-varying across clip boundaries. Re-rendering on every decoded
|
|
132
|
+
// frame would be wasteful since the active-layer set only actually changes at a handful of
|
|
133
|
+
// timestamps (each overlay's start/end), so the rendered result is cached and only recomputed when
|
|
134
|
+
// the active-layer signature changes between frames.
|
|
135
|
+
//
|
|
136
|
+
// Timestamp caveat (unverified — no device available to confirm): this item's own `getBitmap`
|
|
137
|
+
// presentationTimeUs is assumed to be zero-based at this clip's own trimmed start (matching
|
|
138
|
+
// Transformer's typical per-item effect-pipeline behavior), so the summed duration of preceding
|
|
139
|
+
// clips is added to recover the global timeline position that the overlay's startMs/endMs are
|
|
140
|
+
// authored against.
|
|
141
|
+
if (layers.isNotEmpty()) {
|
|
142
|
+
val clipOffsetMs = precedingDurationMs
|
|
143
|
+
var cachedSignature: List<String>? = null
|
|
144
|
+
var cachedBitmap: Bitmap? = null
|
|
145
|
+
effects.add(
|
|
146
|
+
OverlayEffect(
|
|
147
|
+
listOf<TextureOverlay>(
|
|
148
|
+
object : BitmapOverlay() {
|
|
149
|
+
@Volatile private var outputWidth = sourceWidth.coerceAtLeast(2)
|
|
150
|
+
@Volatile private var outputHeight = sourceHeight.coerceAtLeast(2)
|
|
151
|
+
|
|
152
|
+
override fun configure(videoSize: Size) {
|
|
153
|
+
super.configure(videoSize)
|
|
154
|
+
// Media3 maps a same-aspect overlay texture onto the complete background
|
|
155
|
+
// frame. Bound the longest edge to avoid allocating a huge ARGB bitmap for
|
|
156
|
+
// extreme (4K/8K) outputs, while preserving exact normalized geometry. The
|
|
157
|
+
// cap is set to cover common Full HD exports (1920px long edge) natively —
|
|
158
|
+
// a lower cap visibly softens overlay text/sticker edges vs. the preview,
|
|
159
|
+
// since it downscales the overlay before compositing.
|
|
160
|
+
val scale = minOf(1f, MAX_OVERLAY_EDGE_PX / maxOf(videoSize.width, videoSize.height).coerceAtLeast(1))
|
|
161
|
+
outputWidth = (videoSize.width * scale).toInt().coerceAtLeast(2)
|
|
162
|
+
outputHeight = (videoSize.height * scale).toInt().coerceAtLeast(2)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
override fun getBitmap(presentationTimeUs: Long): Bitmap {
|
|
166
|
+
val positionMs = presentationTimeUs / 1000 + clipOffsetMs
|
|
167
|
+
val activeLayers = layers.filter { it.isActiveAt(positionMs, totalDurationMs) }
|
|
168
|
+
val signature = activeLayers.map { it.id }
|
|
169
|
+
if (signature != cachedSignature || cachedBitmap == null) {
|
|
170
|
+
val input = Bitmap.createBitmap(outputWidth, outputHeight, Bitmap.Config.ARGB_8888)
|
|
171
|
+
cachedBitmap = PhotoLayerRenderer.render(input, activeLayers) { uri -> resolveImageUri(context, uri) }
|
|
172
|
+
if (cachedBitmap !== input && !input.isRecycled) input.recycle()
|
|
173
|
+
// BitmapOverlay may retain the returned frame in the GL
|
|
174
|
+
// pipeline. Keep it alive until Transformer terminates.
|
|
175
|
+
cachedBitmap?.let { overlayBitmaps.add(it) }
|
|
176
|
+
cachedSignature = signature
|
|
177
|
+
}
|
|
178
|
+
return cachedBitmap!!
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
val mediaItem = MediaItem.Builder()
|
|
187
|
+
.setUri(mediaUri(clip.sourceUri))
|
|
188
|
+
.setClippingConfiguration(
|
|
189
|
+
MediaItem.ClippingConfiguration.Builder()
|
|
190
|
+
.setStartPositionMs(clip.trimStartMs)
|
|
191
|
+
.setEndPositionMs(clip.effectiveTrimEndMs())
|
|
192
|
+
.build()
|
|
193
|
+
)
|
|
194
|
+
.build()
|
|
195
|
+
|
|
196
|
+
// `state.speed` is intentionally not applied here — see the class doc comment above.
|
|
197
|
+
editedItems.add(
|
|
198
|
+
EditedMediaItem.Builder(mediaItem)
|
|
199
|
+
.setEffects(Effects(emptyList(), effects))
|
|
200
|
+
.build()
|
|
201
|
+
)
|
|
202
|
+
precedingDurationMs += clip.trimmedDurationMs()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
val composition = Composition.Builder(EditedMediaItemSequence(editedItems)).build()
|
|
206
|
+
|
|
207
|
+
val transformerBuilder = Transformer.Builder(context)
|
|
208
|
+
.setVideoMimeType(MimeTypes.VIDEO_H264)
|
|
209
|
+
.setAudioMimeType(MimeTypes.AUDIO_AAC)
|
|
210
|
+
|
|
211
|
+
val builtTransformer = transformerBuilder
|
|
212
|
+
.addListener(object : Transformer.Listener {
|
|
213
|
+
override fun onCompleted(composition: Composition, exportResult: ExportResult) {
|
|
214
|
+
polling = false
|
|
215
|
+
transformer = null
|
|
216
|
+
// The completed file now belongs to the caller. cancel()/destroy must
|
|
217
|
+
// never delete it after the editor activity finishes.
|
|
218
|
+
outputFile = null
|
|
219
|
+
onComplete(
|
|
220
|
+
VideoExportResult(
|
|
221
|
+
uri = Uri.fromFile(output).toString(),
|
|
222
|
+
width = exportResult.width.coerceAtLeast(0),
|
|
223
|
+
height = exportResult.height.coerceAtLeast(0),
|
|
224
|
+
durationMs = if (exportResult.durationMs > 0) exportResult.durationMs else totalDurationMs,
|
|
225
|
+
fileSize = output.length(),
|
|
226
|
+
mimeType = "video/mp4"
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
releaseOverlayBitmaps()
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
override fun onError(composition: Composition, exportResult: ExportResult, exportException: ExportException) {
|
|
233
|
+
polling = false
|
|
234
|
+
transformer = null
|
|
235
|
+
output.delete()
|
|
236
|
+
releaseOverlayBitmaps()
|
|
237
|
+
onError(VideoExportException("E_EXPORT_FAILED", exportException.message ?: "Unable to export the video."))
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
.build()
|
|
241
|
+
transformer = builtTransformer
|
|
242
|
+
try {
|
|
243
|
+
builtTransformer.start(composition, output.absolutePath)
|
|
244
|
+
startPolling(onProgress)
|
|
245
|
+
} catch (error: Exception) {
|
|
246
|
+
polling = false
|
|
247
|
+
transformer = null
|
|
248
|
+
output.delete()
|
|
249
|
+
releaseOverlayBitmaps()
|
|
250
|
+
onError(VideoExportException("E_EXPORT_FAILED", error.message ?: "Unable to start video export."))
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private fun resolveImageUri(context: Context, uri: String): Bitmap? {
|
|
255
|
+
val path = SourceResolver.resolvePath(context, uri, "pve_video_layer_image_export") ?: return null
|
|
256
|
+
return BitmapFactory.decodeFile(path)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The video's decoded frame size after its own rotation metadata is applied (i.e. as it will actually be displayed/exported before any additional user rotation/crop). */
|
|
260
|
+
private fun readUprightVideoSize(uri: Uri): Pair<Int, Int> {
|
|
261
|
+
val retriever = MediaMetadataRetriever()
|
|
262
|
+
return try {
|
|
263
|
+
retriever.setDataSource(context, uri)
|
|
264
|
+
val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 1280
|
|
265
|
+
val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() ?: 720
|
|
266
|
+
val rotation = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull() ?: 0
|
|
267
|
+
if (rotation == 90 || rotation == 270) height to width else width to height
|
|
268
|
+
} catch (_: Exception) {
|
|
269
|
+
1280 to 720
|
|
270
|
+
} finally {
|
|
271
|
+
retriever.release()
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private fun mediaUri(value: String): Uri {
|
|
276
|
+
val parsed = Uri.parse(value)
|
|
277
|
+
return if (parsed.scheme == null) Uri.fromFile(File(value)) else parsed
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private fun startPolling(onProgress: (Float) -> Unit) {
|
|
281
|
+
polling = true
|
|
282
|
+
val holder = ProgressHolder()
|
|
283
|
+
val runnable = object : Runnable {
|
|
284
|
+
override fun run() {
|
|
285
|
+
val current = transformer
|
|
286
|
+
if (current == null || !polling) return
|
|
287
|
+
try {
|
|
288
|
+
current.getProgress(holder)
|
|
289
|
+
onProgress((holder.progress.coerceIn(0, 100)) / 100f)
|
|
290
|
+
if (polling) handler.postDelayed(this, 300)
|
|
291
|
+
} catch (_: IllegalStateException) {
|
|
292
|
+
// Transformer completed/cancelled between the null check and poll.
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
handler.postDelayed(runnable, 300)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Cancels an in-progress export and deletes the partial output file. */
|
|
300
|
+
fun cancel() {
|
|
301
|
+
polling = false
|
|
302
|
+
handler.removeCallbacksAndMessages(null)
|
|
303
|
+
transformer?.cancel()
|
|
304
|
+
transformer = null
|
|
305
|
+
outputFile?.delete()
|
|
306
|
+
outputFile = null
|
|
307
|
+
releaseOverlayBitmaps()
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private fun releaseOverlayBitmaps() {
|
|
311
|
+
overlayBitmaps.forEach { if (!it.isRecycled) it.recycle() }
|
|
312
|
+
overlayBitmaps.clear()
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private companion object {
|
|
316
|
+
/** Overlay-compositing bitmap longest-edge cap; see the comment at its call site. */
|
|
317
|
+
const val MAX_OVERLAY_EDGE_PX = 1920f
|
|
318
|
+
}
|
|
319
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
package com.photovideoeditor.video.render
|
|
2
|
+
|
|
3
|
+
import java.util.UUID
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One segment in the multi-clip timeline. Every clip in this milestone shares
|
|
7
|
+
* the same source file (created via Split/Duplicate of one input) — importing
|
|
8
|
+
* separate additional source files from a system picker is a follow-up, not
|
|
9
|
+
* attempted here.
|
|
10
|
+
*/
|
|
11
|
+
data class VideoClip(
|
|
12
|
+
val id: String = UUID.randomUUID().toString(),
|
|
13
|
+
val sourceUri: String,
|
|
14
|
+
val originalDurationMs: Long,
|
|
15
|
+
var trimStartMs: Long = 0L,
|
|
16
|
+
var trimEndMs: Long = 0L
|
|
17
|
+
) {
|
|
18
|
+
fun effectiveTrimEndMs(): Long = if (trimEndMs in 1..originalDurationMs) trimEndMs else originalDurationMs
|
|
19
|
+
fun trimmedDurationMs(): Long = (effectiveTrimEndMs() - trimStartMs).coerceAtLeast(0L)
|
|
20
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
package com.photovideoeditor.video.render
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.net.Uri
|
|
5
|
+
import androidx.media3.common.MediaItem
|
|
6
|
+
import androidx.media3.common.PlaybackException
|
|
7
|
+
import androidx.media3.common.Player
|
|
8
|
+
import androidx.media3.exoplayer.ExoPlayer
|
|
9
|
+
import com.photovideoeditor.photo.render.PhotoLayerStack
|
|
10
|
+
import java.io.File
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Owns the ExoPlayer preview instance, the ordered [VideoClip] list (the
|
|
14
|
+
* multi-clip timeline, Milestone 7), and the [VideoTransformState] that
|
|
15
|
+
* applies globally across the whole composed sequence. The source is never
|
|
16
|
+
* modified — [com.photovideoeditor.video.export.VideoExporter] reads the
|
|
17
|
+
* clip list again to produce the exported file.
|
|
18
|
+
*/
|
|
19
|
+
class VideoEditSession(context: Context, val sourceUri: String) {
|
|
20
|
+
val player: ExoPlayer = ExoPlayer.Builder(context).build()
|
|
21
|
+
|
|
22
|
+
/** Text/sticker/shape overlays. Reuses the photo layer model/stack — see `PhotoLayer.startMs`/`endMs` for timing. */
|
|
23
|
+
val layerStack = PhotoLayerStack()
|
|
24
|
+
|
|
25
|
+
var clips: List<VideoClip> = emptyList()
|
|
26
|
+
private set
|
|
27
|
+
|
|
28
|
+
var state: VideoTransformState = VideoTransformState()
|
|
29
|
+
private set
|
|
30
|
+
|
|
31
|
+
/** Fires once the source is probed and the initial single-clip timeline is ready. */
|
|
32
|
+
var onClipsReady: (() -> Unit)? = null
|
|
33
|
+
set(value) {
|
|
34
|
+
field = value
|
|
35
|
+
// Activity view construction may finish after a very fast local source
|
|
36
|
+
// reaches STATE_READY. Never lose that one-shot notification.
|
|
37
|
+
if (value != null && clips.isNotEmpty()) value.invoke()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Reports decoder/source failures instead of leaving a blank player forever. */
|
|
41
|
+
var onPlaybackError: ((String) -> Unit)? = null
|
|
42
|
+
set(value) {
|
|
43
|
+
field = value
|
|
44
|
+
pendingPlaybackError?.let { error -> value?.invoke(error) }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private var pendingPlaybackError: String? = null
|
|
48
|
+
private var initialTimelineCreated = false
|
|
49
|
+
|
|
50
|
+
init {
|
|
51
|
+
player.addListener(object : Player.Listener {
|
|
52
|
+
override fun onPlaybackStateChanged(playbackState: Int) {
|
|
53
|
+
if (playbackState == Player.STATE_READY && !initialTimelineCreated && player.duration > 0) {
|
|
54
|
+
initialTimelineCreated = true
|
|
55
|
+
clips = listOf(VideoClip(sourceUri = sourceUri, originalDurationMs = player.duration))
|
|
56
|
+
rebuildPlayerTimeline(preserveIndex = 0)
|
|
57
|
+
onClipsReady?.invoke()
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
override fun onPlayerError(error: PlaybackException) {
|
|
62
|
+
val message = error.message ?: "The selected video could not be played."
|
|
63
|
+
pendingPlaybackError = message
|
|
64
|
+
onPlaybackError?.invoke(message)
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
player.setMediaItem(MediaItem.fromUri(playbackUri(sourceUri)))
|
|
68
|
+
player.prepare()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Sum of every clip's trimmed duration — the length of the composed timeline. */
|
|
72
|
+
val durationMs: Long get() = clips.sumOf { it.trimmedDurationMs() }
|
|
73
|
+
|
|
74
|
+
fun update(transform: (VideoTransformState) -> VideoTransformState) {
|
|
75
|
+
state = transform(state)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Applies [transform] to the clip list and rebuilds the player's playlist to match. */
|
|
79
|
+
fun updateClips(transform: (List<VideoClip>) -> List<VideoClip>) {
|
|
80
|
+
val previousIndex = player.currentMediaItemIndex
|
|
81
|
+
clips = transform(clips)
|
|
82
|
+
rebuildPlayerTimeline(preserveIndex = previousIndex)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private fun rebuildPlayerTimeline(preserveIndex: Int) {
|
|
86
|
+
if (clips.isEmpty()) return
|
|
87
|
+
val items = clips.map { clip ->
|
|
88
|
+
MediaItem.Builder()
|
|
89
|
+
.setUri(Uri.parse(clip.sourceUri))
|
|
90
|
+
.setClippingConfiguration(
|
|
91
|
+
MediaItem.ClippingConfiguration.Builder()
|
|
92
|
+
.setStartPositionMs(clip.trimStartMs)
|
|
93
|
+
.setEndPositionMs(clip.effectiveTrimEndMs())
|
|
94
|
+
.build()
|
|
95
|
+
)
|
|
96
|
+
.build()
|
|
97
|
+
}
|
|
98
|
+
player.setMediaItems(items)
|
|
99
|
+
player.prepare()
|
|
100
|
+
val safeIndex = preserveIndex.coerceIn(0, clips.size - 1)
|
|
101
|
+
player.seekTo(safeIndex, 0)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Position within the whole multi-clip timeline: preceding clips' trimmed durations + position in the current clip. */
|
|
105
|
+
fun globalPositionMs(): Long {
|
|
106
|
+
if (clips.isEmpty()) return 0
|
|
107
|
+
val index = player.currentMediaItemIndex.coerceIn(0, clips.size - 1)
|
|
108
|
+
val preceding = clips.take(index).sumOf { it.trimmedDurationMs() }
|
|
109
|
+
return preceding + player.currentPosition.coerceAtLeast(0)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Seeks the player to a position expressed in the composed (global) timeline. */
|
|
113
|
+
fun seekToGlobalMs(targetMs: Long) {
|
|
114
|
+
if (clips.isEmpty()) return
|
|
115
|
+
var remaining = targetMs.coerceIn(0, durationMs)
|
|
116
|
+
for ((index, clip) in clips.withIndex()) {
|
|
117
|
+
val clipDuration = clip.trimmedDurationMs()
|
|
118
|
+
if (remaining <= clipDuration || index == clips.size - 1) {
|
|
119
|
+
player.seekTo(index, remaining.coerceIn(0, clipDuration))
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
remaining -= clipDuration
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
fun release() {
|
|
127
|
+
onClipsReady = null
|
|
128
|
+
onPlaybackError = null
|
|
129
|
+
player.release()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private fun playbackUri(value: String): Uri {
|
|
133
|
+
val parsed = Uri.parse(value)
|
|
134
|
+
return if (parsed.scheme == null) Uri.fromFile(File(value)) else parsed
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
package com.photovideoeditor.video.render
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.graphics.Bitmap
|
|
5
|
+
import android.media.MediaMetadataRetriever
|
|
6
|
+
import android.net.Uri
|
|
7
|
+
import java.io.File
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Extracts a fixed number of evenly spaced thumbnails for the trim strip.
|
|
11
|
+
* Must be called from a background thread — decoding is not cheap.
|
|
12
|
+
*/
|
|
13
|
+
object VideoThumbnailGenerator {
|
|
14
|
+
fun generate(context: Context, sourceUri: String, durationMs: Long, count: Int = 12): List<Bitmap> {
|
|
15
|
+
if (durationMs <= 0) return emptyList()
|
|
16
|
+
val retriever = MediaMetadataRetriever()
|
|
17
|
+
return try {
|
|
18
|
+
val parsed = Uri.parse(sourceUri)
|
|
19
|
+
retriever.setDataSource(context, if (parsed.scheme == null) Uri.fromFile(File(sourceUri)) else parsed)
|
|
20
|
+
val step = durationMs / count
|
|
21
|
+
(0 until count).mapNotNull { index ->
|
|
22
|
+
val timeUs = (index * step + step / 2) * 1000
|
|
23
|
+
retriever.getFrameAtTime(timeUs, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
|
|
24
|
+
}
|
|
25
|
+
} catch (_: Exception) {
|
|
26
|
+
emptyList()
|
|
27
|
+
} finally {
|
|
28
|
+
retriever.release()
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
package com.photovideoeditor.video.render
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Non-destructive video edit state that applies globally across the whole
|
|
5
|
+
* multi-clip timeline (Milestone 7) — trim now lives per clip on [VideoClip]
|
|
6
|
+
* instead of here. `coverFrameMs` is a position in the composed timeline
|
|
7
|
+
* (sum of preceding clips' trimmed durations + offset into the current one),
|
|
8
|
+
* not a position within any single clip's own source file.
|
|
9
|
+
*/
|
|
10
|
+
data class VideoTransformState(
|
|
11
|
+
val rotationDegrees: Int = 0,
|
|
12
|
+
val aspectRatio: Float? = null,
|
|
13
|
+
val coverFrameMs: Long = 0L,
|
|
14
|
+
/** Preview-only for now: cycled via the Speed tool. Export does not yet honor this — see docs/video-editor.md. */
|
|
15
|
+
val speed: Float = 1f,
|
|
16
|
+
/** -100..100. Export-only for now — no live preview. See docs/video-editor.md. */
|
|
17
|
+
val brightness: Float = 0f,
|
|
18
|
+
/** -100..100. Export-only for now — no live preview. */
|
|
19
|
+
val contrast: Float = 0f,
|
|
20
|
+
/** -100..100. Export-only for now — no live preview. */
|
|
21
|
+
val saturation: Float = 0f
|
|
22
|
+
) {
|
|
23
|
+
fun rotatedRight(): VideoTransformState = copy(rotationDegrees = (rotationDegrees + 90) % 360)
|
|
24
|
+
fun withAspectRatio(ratio: Float?): VideoTransformState = copy(aspectRatio = ratio)
|
|
25
|
+
fun withCoverFrame(atMs: Long): VideoTransformState = copy(coverFrameMs = atMs)
|
|
26
|
+
|
|
27
|
+
fun filterValue(key: String): Float = when (key) {
|
|
28
|
+
"brightness" -> brightness
|
|
29
|
+
"contrast" -> contrast
|
|
30
|
+
"saturation" -> saturation
|
|
31
|
+
else -> 0f
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
fun withFilter(key: String, value: Float): VideoTransformState = when (key) {
|
|
35
|
+
"brightness" -> copy(brightness = value)
|
|
36
|
+
"contrast" -> copy(contrast = value)
|
|
37
|
+
"saturation" -> copy(saturation = value)
|
|
38
|
+
else -> this
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
fun withFiltersReset(): VideoTransformState = copy(brightness = 0f, contrast = 0f, saturation = 0f)
|
|
42
|
+
|
|
43
|
+
private val speedSteps = listOf(0.5f, 1f, 1.5f, 2f)
|
|
44
|
+
fun cycledSpeed(): VideoTransformState {
|
|
45
|
+
val currentIndex = speedSteps.indexOf(speed).takeIf { it >= 0 } ?: 1
|
|
46
|
+
return copy(speed = speedSteps[(currentIndex + 1) % speedSteps.size])
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
package com.photovideoeditor.video.ui
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.graphics.Bitmap
|
|
5
|
+
import android.graphics.Canvas
|
|
6
|
+
import android.graphics.Color
|
|
7
|
+
import android.graphics.Paint
|
|
8
|
+
import android.graphics.RectF
|
|
9
|
+
import android.view.MotionEvent
|
|
10
|
+
import android.view.View
|
|
11
|
+
import kotlin.math.abs
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Dual-handle trim range selector drawn over a horizontal thumbnail strip.
|
|
15
|
+
* `startFraction`/`endFraction` are fractions (0..1) of the video's total
|
|
16
|
+
* duration; a minimum gap between them is enforced so a trim can never
|
|
17
|
+
* collapse to (or past) zero length.
|
|
18
|
+
*/
|
|
19
|
+
class TrimRangeView(context: Context) : View(context) {
|
|
20
|
+
var thumbnails: List<Bitmap> = emptyList()
|
|
21
|
+
set(value) {
|
|
22
|
+
field = value
|
|
23
|
+
invalidate()
|
|
24
|
+
}
|
|
25
|
+
var startFraction: Float = 0f
|
|
26
|
+
private set
|
|
27
|
+
var endFraction: Float = 1f
|
|
28
|
+
private set
|
|
29
|
+
var minDurationFraction: Float = 0.02f
|
|
30
|
+
var onRangeChanged: ((start: Float, end: Float) -> Unit)? = null
|
|
31
|
+
|
|
32
|
+
private val handleWidthPx = 28f
|
|
33
|
+
private var activeHandle = Handle.NONE
|
|
34
|
+
|
|
35
|
+
private enum class Handle { NONE, START, END }
|
|
36
|
+
|
|
37
|
+
private val dimPaint = Paint().apply { color = Color.argb(160, 0, 0, 0) }
|
|
38
|
+
private val handlePaint = Paint().apply { color = Color.WHITE }
|
|
39
|
+
private val borderPaint = Paint().apply { color = Color.WHITE; style = Paint.Style.STROKE; strokeWidth = 3f }
|
|
40
|
+
private val backgroundPaint = Paint().apply { color = Color.DKGRAY }
|
|
41
|
+
|
|
42
|
+
fun setRange(start: Float, end: Float) {
|
|
43
|
+
startFraction = start.coerceIn(0f, 1f)
|
|
44
|
+
endFraction = end.coerceIn(startFraction, 1f)
|
|
45
|
+
invalidate()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
override fun onDraw(canvas: Canvas) {
|
|
49
|
+
super.onDraw(canvas)
|
|
50
|
+
val w = width.toFloat()
|
|
51
|
+
val h = height.toFloat()
|
|
52
|
+
if (w <= 0 || h <= 0) return
|
|
53
|
+
if (thumbnails.isNotEmpty()) {
|
|
54
|
+
val thumbWidth = w / thumbnails.size
|
|
55
|
+
thumbnails.forEachIndexed { index, bitmap ->
|
|
56
|
+
canvas.drawBitmap(bitmap, null, RectF(index * thumbWidth, 0f, (index + 1) * thumbWidth, h), null)
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
canvas.drawRect(0f, 0f, w, h, backgroundPaint)
|
|
60
|
+
}
|
|
61
|
+
val startX = startFraction * w
|
|
62
|
+
val endX = endFraction * w
|
|
63
|
+
canvas.drawRect(0f, 0f, startX, h, dimPaint)
|
|
64
|
+
canvas.drawRect(endX, 0f, w, h, dimPaint)
|
|
65
|
+
canvas.drawRect(startX, 0f, endX, h, borderPaint)
|
|
66
|
+
canvas.drawRect(startX - handleWidthPx / 2, 0f, startX + handleWidthPx / 2, h, handlePaint)
|
|
67
|
+
canvas.drawRect(endX - handleWidthPx / 2, 0f, endX + handleWidthPx / 2, h, handlePaint)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
override fun onTouchEvent(event: MotionEvent): Boolean {
|
|
71
|
+
val w = width.toFloat()
|
|
72
|
+
if (w <= 0) return false
|
|
73
|
+
when (event.actionMasked) {
|
|
74
|
+
MotionEvent.ACTION_DOWN -> {
|
|
75
|
+
val startX = startFraction * w
|
|
76
|
+
val endX = endFraction * w
|
|
77
|
+
activeHandle = when {
|
|
78
|
+
abs(event.x - startX) < handleWidthPx -> Handle.START
|
|
79
|
+
abs(event.x - endX) < handleWidthPx -> Handle.END
|
|
80
|
+
else -> Handle.NONE
|
|
81
|
+
}
|
|
82
|
+
return activeHandle != Handle.NONE
|
|
83
|
+
}
|
|
84
|
+
MotionEvent.ACTION_MOVE -> {
|
|
85
|
+
if (activeHandle == Handle.NONE) return false
|
|
86
|
+
val fraction = (event.x / w).coerceIn(0f, 1f)
|
|
87
|
+
when (activeHandle) {
|
|
88
|
+
Handle.START -> startFraction = fraction.coerceAtMost(endFraction - minDurationFraction).coerceAtLeast(0f)
|
|
89
|
+
Handle.END -> endFraction = fraction.coerceAtLeast(startFraction + minDurationFraction).coerceAtMost(1f)
|
|
90
|
+
Handle.NONE -> return false
|
|
91
|
+
}
|
|
92
|
+
invalidate()
|
|
93
|
+
onRangeChanged?.invoke(startFraction, endFraction)
|
|
94
|
+
return true
|
|
95
|
+
}
|
|
96
|
+
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
|
97
|
+
val handled = activeHandle != Handle.NONE
|
|
98
|
+
activeHandle = Handle.NONE
|
|
99
|
+
return handled
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return false
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
2
|
+
android:width="24dp"
|
|
3
|
+
android:height="24dp"
|
|
4
|
+
android:viewportWidth="960"
|
|
5
|
+
android:viewportHeight="960">
|
|
6
|
+
<group android:translateX="-0" android:translateY="960">
|
|
7
|
+
<path android:fillColor="#FFFFFF" android:pathData="M440-440H200v-80h240v-240h80v240h240v80H520v240h-80v-240Z"/>
|
|
8
|
+
</group>
|
|
9
|
+
</vector>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
2
|
+
android:width="24dp"
|
|
3
|
+
android:height="24dp"
|
|
4
|
+
android:viewportWidth="960"
|
|
5
|
+
android:viewportHeight="960">
|
|
6
|
+
<group android:translateX="-0" android:translateY="960">
|
|
7
|
+
<path android:fillColor="#FFFFFF" android:pathData="m313-440 224 224-57 56-320-320 320-320 57 56-224 224h487v80H313Z"/>
|
|
8
|
+
</group>
|
|
9
|
+
</vector>
|