react-native-video-trim 6.1.0 → 6.2.1

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.
@@ -0,0 +1,1170 @@
1
+ package com.videotrim.widgets
2
+
3
+ import android.content.Context
4
+ import android.content.pm.ActivityInfo
5
+ import android.content.res.Configuration
6
+ import android.graphics.Color
7
+ import android.graphics.drawable.GradientDrawable
8
+ import android.media.MediaMetadataRetriever
9
+ import android.media.MediaPlayer
10
+ import android.net.Uri
11
+ import android.os.Build
12
+ import android.os.Handler
13
+ import android.os.VibrationEffect
14
+ import android.os.Vibrator
15
+ import android.util.AttributeSet
16
+ import android.util.Log
17
+ import android.util.TypedValue
18
+ import android.view.GestureDetector
19
+ import android.view.LayoutInflater
20
+ import android.view.MotionEvent
21
+ import android.view.View
22
+ import android.widget.FrameLayout
23
+ import android.widget.ImageView
24
+ import android.widget.LinearLayout
25
+ import android.widget.ProgressBar
26
+ import android.widget.RelativeLayout
27
+ import android.widget.TextView
28
+ import android.widget.VideoView
29
+
30
+ import androidx.appcompat.app.AlertDialog
31
+
32
+ import com.arthenica.ffmpegkit.FFmpegSession
33
+ import com.facebook.react.bridge.ReactApplicationContext
34
+ import com.facebook.react.bridge.ReadableMap
35
+ import com.facebook.react.bridge.UiThreadUtil.runOnUiThread
36
+ import com.videotrim.R
37
+ import com.videotrim.enums.ErrorCode
38
+ import com.videotrim.interfaces.IVideoTrimmerView
39
+ import com.videotrim.interfaces.VideoTrimListener
40
+ import com.videotrim.utils.MediaMetadataUtil
41
+ import com.videotrim.utils.StorageUtil
42
+ import com.videotrim.utils.VideoTrimmerUtil
43
+ import com.videotrim.utils.VideoTrimmerUtil.RECYCLER_VIEW_PADDING
44
+ import com.videotrim.utils.VideoTrimmerUtil.VIDEO_FRAMES_WIDTH
45
+
46
+ import iknow.android.utils.DeviceUtil
47
+ import iknow.android.utils.thread.BackgroundExecutor
48
+ import iknow.android.utils.thread.UiThreadExecutor
49
+
50
+ import java.io.IOException
51
+ import java.util.Locale
52
+ import androidx.core.graphics.toColorInt
53
+
54
+ class VideoTrimmerView(
55
+ context: ReactApplicationContext,
56
+ config: ReadableMap?,
57
+ attrs: AttributeSet? = null,
58
+ defStyleAttr: Int = 0
59
+ ) : FrameLayout(context, attrs, defStyleAttr), IVideoTrimmerView {
60
+
61
+ companion object {
62
+ private val TAG: String = VideoTrimmerView::class.java.simpleName
63
+ private const val TIMING_UPDATE_INTERVAL = 30L
64
+ }
65
+
66
+ private var mContext: ReactApplicationContext = context
67
+ private lateinit var mVideoView: VideoView
68
+
69
+ // mediaPlayer is used for both video/audio
70
+ // the reason we use mediaPlayer for Video: https://stackoverflow.com/a/73361868/7569705
71
+ // the videoPlayer is to solve the issue after manually seek -> hit play -> it starts from a position slightly before with the one we just sought to
72
+ private var mediaPlayer: MediaPlayer? = null
73
+ private lateinit var mPlayView: ImageView
74
+ private lateinit var mThumbnailContainer: LinearLayout
75
+ private var mSourceUri: Uri? = null
76
+ private lateinit var mOnTrimVideoListener: VideoTrimListener
77
+ private var mDuration = 0
78
+ private var mMaxDuration = Long.MAX_VALUE
79
+ private var mMinDuration = VideoTrimmerUtil.MIN_SHOOT_DURATION
80
+
81
+ private val mTimingHandler = Handler()
82
+ private var mTimingRunnable: Runnable? = null
83
+ private lateinit var currentTimeText: TextView
84
+ private lateinit var startTimeText: TextView
85
+ private lateinit var endTimeText: TextView
86
+ private lateinit var progressIndicator: View
87
+ private lateinit var trimmerContainer: View
88
+ // background of the trimmer container, its width never changes
89
+ // this is to make sure when we calculate position of the progress indicator, we don't need to consider the width of the trimmer container
90
+ private lateinit var trimmerContainerBg: View
91
+ private lateinit var leadingHandle: FrameLayout
92
+ private lateinit var trailingHandle: View
93
+ private lateinit var leadingOverlay: View
94
+ private lateinit var trailingOverlay: View
95
+ private lateinit var trimmerContainerWrapper: RelativeLayout
96
+
97
+ private var startTime = 0L
98
+ private var endTime = 0L
99
+ private var enableRotation = false
100
+ private var rotationAngle = 0.0
101
+ private var zoomOnWaitingDuration = 5000L
102
+
103
+ private var vibrator: Vibrator? = null
104
+ private var didClampWhilePanning = false
105
+
106
+ // zoom
107
+ private var isZoomedIn = false
108
+ private val zoomWaitTimer = Handler()
109
+ private var zoomRunnable: Runnable? = null
110
+ private var zoomedInRangeStart = 0L
111
+ private var zoomedInRangeDuration = 0L
112
+ private var isTrimmingLeading = false
113
+
114
+ // range drag
115
+ private var isRangeDragging = false
116
+ private var rangeDragInitialRawX = 0f
117
+ private var rangeDragInitialStartTime = 0L
118
+ private var rangeDragInitialEndTime = 0L
119
+ private lateinit var rangeDragGestureDetector: GestureDetector
120
+
121
+ // thumbnail caching for zoom functionality
122
+ private val cachedFullViewThumbnails = mutableListOf<ImageView>()
123
+ @Volatile
124
+ private var isGeneratingThumbnails = false
125
+
126
+ private var mediaMetadataRetriever: MediaMetadataRetriever? = null
127
+ private lateinit var loadingIndicator: ProgressBar
128
+ private lateinit var saveBtn: TextView
129
+ private lateinit var cancelBtn: TextView
130
+ private lateinit var audioBannerView: FrameLayout
131
+ private var isVideoType = true
132
+ private lateinit var failToLoadBtn: ImageView
133
+
134
+ private var mOutputExt = "mp4"
135
+ private var enableHapticFeedback = true
136
+ private var autoplay = false
137
+ private var jumpToPositionOnLoad = 0L
138
+ private lateinit var headerView: FrameLayout
139
+ private lateinit var headerText: TextView
140
+ private var ffmpegSession: FFmpegSession? = null
141
+ private var alertOnFailToLoad = true
142
+ private var alertOnFailTitle = "Error"
143
+ private var alertOnFailMessage = "Fail to load media. Possibly invalid file or no network connection"
144
+ private var alertOnFailCloseText = "Close"
145
+ private var currentSelectedhandle: View? = null
146
+
147
+ private lateinit var trimmerView: RelativeLayout
148
+
149
+ private var trimmerColor = context.getString(R.string.trim_color).toColorInt()
150
+ private var handleIconColor = Color.BLACK
151
+ private lateinit var leadingChevron: ImageView
152
+ private lateinit var trailingChevron: ImageView
153
+
154
+ init {
155
+ init(context, config)
156
+ }
157
+
158
+ private fun init(context: ReactApplicationContext, config: ReadableMap?) {
159
+ mContext = context
160
+
161
+ context.currentActivity!!.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
162
+ LayoutInflater.from(context).inflate(R.layout.video_trimmer_view, this, true)
163
+ vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
164
+
165
+ initializeViews()
166
+ if (config != null) configure(config)
167
+ setUpListeners()
168
+ initRangeDragDetector()
169
+ setProgressIndicatorTouchListener()
170
+ }
171
+
172
+ private fun initRangeDragDetector() {
173
+ rangeDragGestureDetector = GestureDetector(mContext, object : GestureDetector.SimpleOnGestureListener() {
174
+ override fun onLongPress(e: MotionEvent) {
175
+ isRangeDragging = true
176
+ rangeDragInitialRawX = e.rawX
177
+ rangeDragInitialStartTime = startTime
178
+ rangeDragInitialEndTime = endTime
179
+ playHapticFeedback(true)
180
+ fadeOutProgressIndicator()
181
+ }
182
+ })
183
+ }
184
+
185
+ private fun initializeViews() {
186
+ mThumbnailContainer = findViewById(R.id.thumbnailContainer)
187
+ mVideoView = findViewById(R.id.video_loader)
188
+ mPlayView = findViewById(R.id.icon_video_play)
189
+ startTimeText = findViewById(R.id.startTime)
190
+ currentTimeText = findViewById(R.id.currentTime)
191
+ endTimeText = findViewById(R.id.endTime)
192
+ progressIndicator = findViewById(R.id.progressIndicator)
193
+ trimmerContainer = findViewById(R.id.trimmerContainer)
194
+ trimmerContainerBg = findViewById(R.id.trimmerContainerBg)
195
+ leadingHandle = findViewById(R.id.leadingHandle)
196
+ trailingHandle = findViewById(R.id.trailingHandle)
197
+ leadingOverlay = findViewById(R.id.leadingOverlay)
198
+ trailingOverlay = findViewById(R.id.trailingOverlay)
199
+
200
+ trimmerContainerWrapper = findViewById(R.id.trimmerContainerWrapper)
201
+ trimmerContainerWrapper.visibility = View.INVISIBLE
202
+ trimmerContainerWrapper.alpha = 0f
203
+
204
+ loadingIndicator = findViewById(R.id.loadingIndicator)
205
+ saveBtn = findViewById(R.id.saveBtn)
206
+ cancelBtn = findViewById(R.id.cancelBtn)
207
+ audioBannerView = findViewById(R.id.audioBannerView)
208
+ failToLoadBtn = findViewById(R.id.failToLoadBtn)
209
+
210
+ headerView = findViewById(R.id.headerView)
211
+ headerText = findViewById(R.id.headerText)
212
+
213
+ trimmerView = findViewById(R.id.trimmerView)
214
+
215
+ leadingChevron = findViewById(R.id.leadingChevron)
216
+ trailingChevron = findViewById(R.id.trailingChevron)
217
+ }
218
+
219
+ fun initByURI(videoURI: Uri) {
220
+ mSourceUri = videoURI
221
+
222
+ if (isVideoType) {
223
+ mVideoView.setVideoURI(videoURI)
224
+ mVideoView.requestFocus()
225
+
226
+ mVideoView.setOnPreparedListener { mp ->
227
+ mp.setVideoScalingMode(MediaPlayer.VIDEO_SCALING_MODE_SCALE_TO_FIT)
228
+ mediaPlayer = mp
229
+ mediaPrepared()
230
+ }
231
+
232
+ mVideoView.setOnErrorListener { mp, what, extra -> onFailToLoadMedia(mp, what, extra) }
233
+ mVideoView.setOnCompletionListener { mediaCompleted() }
234
+ } else {
235
+ mVideoView.visibility = View.GONE
236
+ audioBannerView.alpha = 0f
237
+ audioBannerView.visibility = View.VISIBLE
238
+ audioBannerView.animate().alpha(1f).setDuration(500).start()
239
+
240
+ mediaPlayer = MediaPlayer()
241
+ try {
242
+ mediaPlayer!!.setDataSource(videoURI.toString())
243
+ mediaPlayer!!.setOnPreparedListener { mediaPrepared() }
244
+ mediaPlayer!!.setOnCompletionListener { mediaCompleted() }
245
+ mediaPlayer!!.setOnErrorListener { mp, what, extra -> onFailToLoadMedia(mp, what, extra) }
246
+ mediaPlayer!!.prepareAsync()
247
+ } catch (e: IOException) {
248
+ e.printStackTrace()
249
+ mediaFailed()
250
+ mOnTrimVideoListener.onError("Error initializing audio player. Please try again.", ErrorCode.FAIL_TO_INITIALIZE_AUDIO_PLAYER)
251
+ }
252
+ }
253
+ }
254
+
255
+ private fun onFailToLoadMedia(mp: MediaPlayer, what: Int, extra: Int): Boolean {
256
+ mediaFailed()
257
+ mOnTrimVideoListener.onError("Error loading media file. Please try again.", ErrorCode.FAIL_TO_LOAD_MEDIA)
258
+ if (alertOnFailToLoad) {
259
+ val builder = AlertDialog.Builder(mContext.currentActivity!!)
260
+ builder.setMessage(alertOnFailMessage)
261
+ builder.setTitle(alertOnFailTitle)
262
+ builder.setCancelable(false)
263
+ builder.setPositiveButton(alertOnFailCloseText) { dialog, _ -> dialog.cancel() }
264
+
265
+ val alertDialog = builder.create()
266
+ alertDialog.show()
267
+ }
268
+ return true
269
+ }
270
+
271
+ private fun startShootVideoThumbs(context: Context, totalThumbsCount: Int, startPosition: Long, endPosition: Long) {
272
+ mThumbnailContainer.removeAllViews()
273
+ cachedFullViewThumbnails.clear()
274
+
275
+ val containerContentWidth = mThumbnailContainer.width - mThumbnailContainer.paddingLeft - mThumbnailContainer.paddingRight
276
+ val effectiveWidth = if (containerContentWidth > 0) containerContentWidth else VideoTrimmerUtil.VIDEO_FRAMES_WIDTH
277
+ val baseThumbWidth = effectiveWidth / totalThumbsCount
278
+ val remainder = effectiveWidth % totalThumbsCount
279
+
280
+ VideoTrimmerUtil.shootVideoThumbInBackground(mediaMetadataRetriever!!, totalThumbsCount, startPosition, endPosition) { bitmap, interval ->
281
+ if (bitmap != null) {
282
+ runOnUiThread {
283
+ val index = mThumbnailContainer.childCount
284
+ val width = if (index < remainder) baseThumbWidth + 1 else baseThumbWidth
285
+ val layoutParams = LinearLayout.LayoutParams(width, LayoutParams.MATCH_PARENT)
286
+
287
+ val thumbImageView = ImageView(context)
288
+ thumbImageView.setImageBitmap(bitmap)
289
+ thumbImageView.scaleType = ImageView.ScaleType.CENTER_CROP
290
+ thumbImageView.layoutParams = layoutParams
291
+ mThumbnailContainer.addView(thumbImageView)
292
+
293
+ val cachedView = ImageView(context)
294
+ cachedView.setImageBitmap(bitmap)
295
+ cachedView.scaleType = ImageView.ScaleType.CENTER_CROP
296
+ cachedView.layoutParams = LinearLayout.LayoutParams(width, LayoutParams.MATCH_PARENT)
297
+ cachedFullViewThumbnails.add(cachedView)
298
+ }
299
+ }
300
+ }
301
+ }
302
+
303
+ private fun mediaPrepared() {
304
+ mDuration = mediaPlayer!!.duration
305
+ mMaxDuration = mMaxDuration.coerceAtMost(mDuration.toLong())
306
+
307
+ if (isVideoType) {
308
+ mediaMetadataRetriever = MediaMetadataUtil.getMediaMetadataRetriever(mSourceUri.toString())
309
+ if (mediaMetadataRetriever == null) {
310
+ mOnTrimVideoListener.onError("Error when retrieving video info. Please try again.", ErrorCode.FAIL_TO_GET_VIDEO_INFO)
311
+ return
312
+ }
313
+
314
+ val bitmap = mediaMetadataRetriever!!.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
315
+
316
+ if (bitmap != null) {
317
+ val bitmapHeight = if (bitmap.height > 0) bitmap.height else VideoTrimmerUtil.THUMB_HEIGHT
318
+ val bitmapWidth = if (bitmap.width > 0) bitmap.width else VideoTrimmerUtil.THUMB_WIDTH
319
+ VideoTrimmerUtil.mThumbWidth = VideoTrimmerUtil.THUMB_HEIGHT * bitmapWidth / bitmapHeight
320
+ }
321
+
322
+ VideoTrimmerUtil.SCREEN_WIDTH_FULL = getScreenWidthInPortraitMode()
323
+ VideoTrimmerUtil.VIDEO_FRAMES_WIDTH = VideoTrimmerUtil.SCREEN_WIDTH_FULL - RECYCLER_VIEW_PADDING * 2
324
+ VideoTrimmerUtil.MAX_COUNT_RANGE = if (VideoTrimmerUtil.mThumbWidth != 0)
325
+ maxOf(VIDEO_FRAMES_WIDTH / VideoTrimmerUtil.mThumbWidth, VideoTrimmerUtil.MAX_COUNT_RANGE)
326
+ else
327
+ VideoTrimmerUtil.MAX_COUNT_RANGE
328
+
329
+ startShootVideoThumbs(mContext, VideoTrimmerUtil.MAX_COUNT_RANGE, 0, mDuration.toLong())
330
+ }
331
+
332
+ endTime = if (mMaxDuration < mDuration) mMaxDuration else mDuration.toLong()
333
+ updateHandlePositions()
334
+
335
+ loadingIndicator.visibility = View.GONE
336
+ mPlayView.visibility = View.VISIBLE
337
+ saveBtn.visibility = View.VISIBLE
338
+
339
+ if (jumpToPositionOnLoad > 0) {
340
+ seekTo(if (jumpToPositionOnLoad > mDuration) mDuration.toLong() else jumpToPositionOnLoad, true)
341
+ }
342
+
343
+ if (autoplay) {
344
+ playOrPause()
345
+ }
346
+
347
+ mOnTrimVideoListener.onLoad(mDuration)
348
+ ignoreSystemGestureForView(trimmerView)
349
+ }
350
+
351
+ private fun updateGradientColors(startColor: Int, endColor: Int) {
352
+ val gradientDrawable = GradientDrawable().apply {
353
+ shape = GradientDrawable.RECTANGLE
354
+ cornerRadius = 6f
355
+ colors = intArrayOf(startColor, endColor)
356
+ orientation = GradientDrawable.Orientation.LEFT_RIGHT
357
+ }
358
+ mThumbnailContainer.background = gradientDrawable
359
+ }
360
+
361
+ private fun mediaFailed() {
362
+ loadingIndicator.visibility = View.GONE
363
+ failToLoadBtn.visibility = View.VISIBLE
364
+ }
365
+
366
+ private fun updateHandlePositions() {
367
+ val leadingHandleX = positionForTime(startTime)
368
+ val trailingHandleX = positionForTime(endTime)
369
+
370
+ leadingHandle.x = leadingHandleX
371
+ trailingHandle.x = trailingHandleX + trailingHandle.width
372
+
373
+ updateTrimmerContainerWidth()
374
+ updateCurrentTime(false)
375
+
376
+ trimmerContainerWrapper.visibility = View.VISIBLE
377
+ trimmerContainerWrapper.animate().alpha(1f).setDuration(250).start()
378
+ }
379
+
380
+ private fun mediaCompleted() {
381
+ onMediaPause()
382
+ // when mediaCompleted is called, the endTime may not be exactly at the end of the video (can be slightly before), therefore we should seek to exact position on ended
383
+ seekTo(endTime, true)
384
+ }
385
+
386
+ private fun playOrPause() {
387
+ val player = mediaPlayer ?: return
388
+ if (player.isPlaying) {
389
+ onMediaPause()
390
+ } else {
391
+ if (player.currentPosition >= endTime) {
392
+ seekTo(startTime, true)
393
+ }
394
+ player.start()
395
+ startTimingRunnable()
396
+ }
397
+ setPlayPauseViewIcon(player.isPlaying)
398
+ }
399
+
400
+ fun onMediaPause() {
401
+ mTimingRunnable?.let { mTimingHandler.removeCallbacks(it) }
402
+ val player = mediaPlayer ?: return
403
+ if (player.isPlaying) {
404
+ player.pause()
405
+ }
406
+ setPlayPauseViewIcon(false)
407
+ }
408
+
409
+ fun setOnTrimVideoListener(onTrimVideoListener: VideoTrimListener) {
410
+ mOnTrimVideoListener = onTrimVideoListener
411
+ }
412
+
413
+ private fun setUpListeners() {
414
+ cancelBtn.setOnClickListener { mOnTrimVideoListener.onCancel() }
415
+ saveBtn.setOnClickListener { mOnTrimVideoListener.onSave() }
416
+ mPlayView.setOnClickListener { playOrPause() }
417
+ setHandleTouchListener(leadingHandle, true)
418
+ setHandleTouchListener(trailingHandle, false)
419
+ }
420
+
421
+ fun onSaveClicked() {
422
+ onMediaPause()
423
+ ffmpegSession = VideoTrimmerUtil.trim(
424
+ mSourceUri.toString(),
425
+ StorageUtil.getOutputPath(mContext, mOutputExt),
426
+ mDuration,
427
+ startTime,
428
+ endTime,
429
+ enableRotation,
430
+ rotationAngle,
431
+ mOnTrimVideoListener
432
+ )
433
+ }
434
+
435
+ fun onCancelTrimClicked() {
436
+ if (ffmpegSession != null) {
437
+ ffmpegSession!!.cancel()
438
+ } else {
439
+ mOnTrimVideoListener.onCancelTrim()
440
+ }
441
+ }
442
+
443
+ private fun seekTo(msec: Long, needUpdateProgress: Boolean) {
444
+ val player = mediaPlayer ?: return
445
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
446
+ player.seekTo(msec, MediaPlayer.SEEK_CLOSEST)
447
+ } else {
448
+ player.seekTo(msec.toInt())
449
+ }
450
+ updateCurrentTime(needUpdateProgress)
451
+ }
452
+
453
+ private fun setPlayPauseViewIcon(isPlaying: Boolean) {
454
+ mPlayView.setImageResource(if (isPlaying) R.drawable.pause_fill else R.drawable.play_fill)
455
+ }
456
+
457
+ override fun onDetachedFromWindow() {
458
+ super.onDetachedFromWindow()
459
+ mContext.currentActivity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
460
+ }
461
+
462
+ override fun onDestroy() {
463
+ isGeneratingThumbnails = false
464
+ BackgroundExecutor.cancelAll("", true)
465
+ UiThreadExecutor.cancelAll("")
466
+ mContext.currentActivity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
467
+ mTimingRunnable?.let { mTimingHandler.removeCallbacks(it) }
468
+ zoomRunnable?.let { zoomWaitTimer.removeCallbacks(it) }
469
+
470
+ cachedFullViewThumbnails.clear()
471
+
472
+ try {
473
+ mediaMetadataRetriever?.release()
474
+ } catch (e: Exception) {
475
+ e.printStackTrace()
476
+ }
477
+
478
+ try {
479
+ mediaPlayer?.stop()
480
+ mediaPlayer?.release()
481
+ } catch (e: IllegalStateException) {
482
+ e.printStackTrace()
483
+ Log.d(TAG, "onDestroy mediaPlayer is already released")
484
+ }
485
+ }
486
+
487
+ private fun getScreenWidthInPortraitMode(): Int {
488
+ val screenWidth = DeviceUtil.getDeviceWidth()
489
+ val screenHeight = DeviceUtil.getDeviceHeight()
490
+ val currentOrientation = resources.configuration.orientation
491
+ return if (currentOrientation == Configuration.ORIENTATION_LANDSCAPE) screenHeight else screenWidth
492
+ }
493
+
494
+ private fun configure(config: ReadableMap) {
495
+ if (config.hasKey("maxDuration") && config.getDouble("maxDuration") > 0) {
496
+ mMaxDuration = maxOf(0, config.getDouble("maxDuration").toLong())
497
+ }
498
+
499
+ if (config.hasKey("minDuration") && config.getDouble("minDuration") > 0) {
500
+ mMinDuration = maxOf(1000L, config.getDouble("minDuration").toLong())
501
+ }
502
+
503
+ cancelBtn.text = config.getString("cancelButtonText")
504
+ saveBtn.text = config.getString("saveButtonText")
505
+ isVideoType = config.hasKey("type") && config.getString("type") == "video"
506
+ println("isVideoType: $isVideoType")
507
+
508
+ mOutputExt = if (config.hasKey("outputExt")) config.getString("outputExt") ?: "mp4" else "mp4"
509
+ if (!isVideoType) {
510
+ mOutputExt = "wav"
511
+ }
512
+ enableHapticFeedback = config.hasKey("enableHapticFeedback") && config.getBoolean("enableHapticFeedback")
513
+ autoplay = config.hasKey("autoplay") && config.getBoolean("autoplay")
514
+
515
+ if (config.hasKey("jumpToPositionOnLoad") && config.getDouble("jumpToPositionOnLoad") > 0) {
516
+ jumpToPositionOnLoad = maxOf(0, (config.getDouble("jumpToPositionOnLoad") * 1000L).toLong())
517
+ }
518
+ headerText.text = if (config.hasKey("headerText")) config.getString("headerText") ?: "" else ""
519
+
520
+ var textSize = if (config.hasKey("headerTextSize")) config.getInt("headerTextSize") else 16
521
+ if (textSize < 0) {
522
+ textSize = 16
523
+ }
524
+
525
+ headerText.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize.toFloat())
526
+ headerText.setTextColor(if (config.hasKey("headerTextColor")) config.getInt("headerTextColor") else Color.BLACK)
527
+
528
+ headerView.visibility = View.VISIBLE
529
+ alertOnFailToLoad = config.hasKey("alertOnFailToLoad") && config.getBoolean("alertOnFailToLoad")
530
+ alertOnFailTitle = if (config.hasKey("alertOnFailTitle")) config.getString("alertOnFailTitle") ?: "Error" else "Error"
531
+ alertOnFailMessage = if (config.hasKey("alertOnFailMessage")) config.getString("alertOnFailMessage") ?: "Fail to load media. Possibly invalid file or no network connection" else "Fail to load media. Possibly invalid file or no network connection"
532
+ alertOnFailCloseText = if (config.hasKey("alertOnFailCloseText")) config.getString("alertOnFailCloseText") ?: "Close" else "Close"
533
+ enableRotation = config.hasKey("enableRotation") && config.getBoolean("enableRotation")
534
+ rotationAngle = if (config.hasKey("rotationAngle")) config.getDouble("rotationAngle") else 0.0
535
+
536
+ if (config.hasKey("zoomOnWaitingDuration") && config.getDouble("zoomOnWaitingDuration") > 0) {
537
+ zoomOnWaitingDuration = config.getDouble("zoomOnWaitingDuration").toLong()
538
+ Log.d(TAG, "Configured zoom on waiting duration: ${zoomOnWaitingDuration / 1000.0} seconds")
539
+ }
540
+
541
+ trimmerColor = if (config.hasKey("trimmerColor")) config.getInt("trimmerColor") else context.getString(
542
+ R.string.trim_color
543
+ ).toColorInt()
544
+ handleIconColor = if (config.hasKey("handleIconColor")) config.getInt("handleIconColor") else Color.BLACK
545
+
546
+ applyTrimmerColor()
547
+ }
548
+
549
+ private fun applyTrimmerColor() {
550
+ val borderDrawable = GradientDrawable().apply {
551
+ shape = GradientDrawable.RECTANGLE
552
+ setColor(Color.TRANSPARENT)
553
+ setStroke(dpToPx(4), trimmerColor)
554
+ }
555
+ trimmerContainer.background = borderDrawable
556
+
557
+ val leadingHandleDrawable = GradientDrawable().apply {
558
+ shape = GradientDrawable.RECTANGLE
559
+ setColor(trimmerColor)
560
+ cornerRadii = floatArrayOf(dpToPx(6).toFloat(), dpToPx(6).toFloat(), 0f, 0f, 0f, 0f, dpToPx(6).toFloat(), dpToPx(6).toFloat())
561
+ }
562
+ leadingHandle.background = leadingHandleDrawable
563
+
564
+ val trailingHandleDrawable = GradientDrawable().apply {
565
+ shape = GradientDrawable.RECTANGLE
566
+ setColor(trimmerColor)
567
+ cornerRadii = floatArrayOf(0f, 0f, dpToPx(6).toFloat(), dpToPx(6).toFloat(), dpToPx(6).toFloat(), dpToPx(6).toFloat(), 0f, 0f)
568
+ }
569
+ trailingHandle.background = trailingHandleDrawable
570
+
571
+ leadingChevron.setColorFilter(handleIconColor, android.graphics.PorterDuff.Mode.SRC_IN)
572
+ trailingChevron.setColorFilter(handleIconColor, android.graphics.PorterDuff.Mode.SRC_IN)
573
+ }
574
+
575
+ private fun dpToPx(dp: Int): Int {
576
+ return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp.toFloat(), resources.displayMetrics).toInt()
577
+ }
578
+
579
+ private fun startTimingRunnable() {
580
+ mTimingRunnable = object : Runnable {
581
+ override fun run() {
582
+ try {
583
+ val currentPosition = mediaPlayer!!.currentPosition
584
+ if (currentPosition >= endTime) {
585
+ onMediaPause()
586
+ seekTo(endTime, true)
587
+ } else {
588
+ updateCurrentTime(true)
589
+ mTimingHandler.postDelayed(this, TIMING_UPDATE_INTERVAL)
590
+ }
591
+ } catch (e: IllegalStateException) {
592
+ e.printStackTrace()
593
+ mTimingRunnable?.let { mTimingHandler.removeCallbacks(it) }
594
+ }
595
+ }
596
+ }
597
+ mTimingHandler.postDelayed(mTimingRunnable!!, TIMING_UPDATE_INTERVAL)
598
+ }
599
+
600
+ private fun updateCurrentTime(needUpdateProgress: Boolean) {
601
+ var currentPosition = mediaPlayer!!.currentPosition
602
+ val duration = mDuration
603
+
604
+ when {
605
+ currentPosition >= duration - 100 -> currentPosition = duration
606
+ currentPosition >= endTime - 100 -> currentPosition = endTime.toInt()
607
+ currentPosition <= startTime + 100 -> currentPosition = startTime.toInt()
608
+ }
609
+
610
+ currentTimeText.text = formatTime(currentPosition)
611
+ startTimeText.text = formatTime(startTime.toInt())
612
+ endTimeText.text = formatTime(endTime.toInt())
613
+
614
+ if (needUpdateProgress) {
615
+ val indicatorPosition: Float
616
+
617
+ if (isZoomedIn) {
618
+ val visibleRangeStart = getVisibleRangeStart()
619
+ val visibleRangeDuration = getVisibleRangeDuration()
620
+
621
+ var clampedPosition = currentPosition
622
+ if (clampedPosition < visibleRangeStart || clampedPosition > visibleRangeStart + visibleRangeDuration) {
623
+ clampedPosition = maxOf(visibleRangeStart, minOf(visibleRangeStart + visibleRangeDuration, currentPosition.toLong())).toInt()
624
+ }
625
+
626
+ val ratio = if (visibleRangeDuration > 0) (clampedPosition - visibleRangeStart).toFloat() / visibleRangeDuration else 0f
627
+ indicatorPosition = ratio * (trimmerContainerBg.width - progressIndicator.width) + leadingHandle.width
628
+ } else {
629
+ indicatorPosition = if (mDuration > 0)
630
+ currentPosition.toFloat() / mDuration * (trimmerContainerBg.width - progressIndicator.width) + leadingHandle.width
631
+ else
632
+ leadingHandle.width.toFloat()
633
+ }
634
+
635
+ val leftBoundary = leadingHandle.x + leadingHandle.width
636
+ val rightBoundary = trailingHandle.x - progressIndicator.width
637
+ val boundedPosition = maxOf(leftBoundary, minOf(rightBoundary, indicatorPosition))
638
+
639
+ when (currentSelectedhandle) {
640
+ leadingHandle -> progressIndicator.x = maxOf(leftBoundary, boundedPosition)
641
+ trailingHandle -> progressIndicator.x = minOf(rightBoundary, boundedPosition)
642
+ else -> progressIndicator.x = boundedPosition
643
+ }
644
+ }
645
+ }
646
+
647
+ private fun formatTime(milliseconds: Int): String {
648
+ val totalSeconds = milliseconds / 1000
649
+ val minutes = totalSeconds / 60
650
+ val seconds = totalSeconds % 60
651
+ val millis = milliseconds % 1000
652
+ return String.format(Locale.getDefault(), "%d:%02d.%03d", minutes, seconds, millis)
653
+ }
654
+
655
+ @Suppress("ClickableViewAccessibility")
656
+ private fun setProgressIndicatorTouchListener() {
657
+ trimmerContainerBg.setOnTouchListener { view, event ->
658
+ rangeDragGestureDetector.onTouchEvent(event)
659
+
660
+ when (event.action) {
661
+ MotionEvent.ACTION_DOWN -> {
662
+ isRangeDragging = false
663
+ didClampWhilePanning = false
664
+ onMediaPause()
665
+ onTrimmerContainerPanned(event)
666
+ playHapticFeedback(true)
667
+ }
668
+ MotionEvent.ACTION_MOVE -> {
669
+ if (isRangeDragging) {
670
+ onRangeDrag(event)
671
+ } else {
672
+ onTrimmerContainerPanned(event)
673
+ }
674
+ }
675
+ MotionEvent.ACTION_UP -> {
676
+ if (isRangeDragging) {
677
+ isRangeDragging = false
678
+ fadeInProgressIndicator()
679
+ updateCurrentTime(true)
680
+ }
681
+ view.performClick()
682
+ }
683
+ else -> return@setOnTouchListener false
684
+ }
685
+ true
686
+ }
687
+ }
688
+
689
+ private fun onTrimmerContainerPanned(event: MotionEvent) {
690
+ var newX = event.rawX
691
+ var didClamp = false
692
+
693
+ val leftBoundary = leadingHandle.x + leadingHandle.width
694
+ val rightBoundary = trailingHandle.x - progressIndicator.width
695
+
696
+ newX = maxOf(leftBoundary, newX)
697
+ newX = minOf(rightBoundary, newX)
698
+
699
+ if (newX <= leftBoundary || newX >= rightBoundary) {
700
+ didClamp = true
701
+ }
702
+
703
+ if (didClamp && !didClampWhilePanning) {
704
+ playHapticFeedback(false)
705
+ }
706
+ didClampWhilePanning = didClamp
707
+
708
+ progressIndicator.x = newX
709
+
710
+ val indicatorPosition = newX - trimmerContainerBg.x
711
+
712
+ val indicatorPositionPercent: Float
713
+ val newVideoPosition: Long
714
+
715
+ if (isZoomedIn) {
716
+ indicatorPositionPercent = indicatorPosition / (trimmerContainerBg.width - progressIndicator.width)
717
+ val visibleStart = getVisibleRangeStart()
718
+ val visibleDuration = getVisibleRangeDuration()
719
+ newVideoPosition = visibleStart + (indicatorPositionPercent * visibleDuration).toLong()
720
+ } else {
721
+ indicatorPositionPercent = indicatorPosition / (trimmerContainerBg.width - progressIndicator.width)
722
+ newVideoPosition = (indicatorPositionPercent * mDuration).toLong()
723
+ }
724
+
725
+ seekTo(newVideoPosition, false)
726
+ }
727
+
728
+ private fun onRangeDrag(event: MotionEvent) {
729
+ val deltaX = event.rawX - rangeDragInitialRawX
730
+ val containerWidth = trimmerContainerBg.width.toFloat()
731
+ if (containerWidth <= 0) return
732
+
733
+ val rangeDuration = rangeDragInitialEndTime - rangeDragInitialStartTime
734
+ val deltaTime = if (isZoomedIn) {
735
+ (deltaX / containerWidth * zoomedInRangeDuration).toLong()
736
+ } else {
737
+ (deltaX / containerWidth * mDuration).toLong()
738
+ }
739
+
740
+ var newStart = rangeDragInitialStartTime + deltaTime
741
+ var newEnd = newStart + rangeDuration
742
+
743
+ var didClamp = false
744
+ if (newStart < 0) {
745
+ newStart = 0
746
+ newEnd = rangeDuration
747
+ didClamp = true
748
+ }
749
+ if (newEnd > mDuration) {
750
+ newEnd = mDuration.toLong()
751
+ newStart = newEnd - rangeDuration
752
+ didClamp = true
753
+ }
754
+
755
+ if (didClamp && !didClampWhilePanning) {
756
+ playHapticFeedback(false)
757
+ }
758
+ didClampWhilePanning = didClamp
759
+
760
+ startTime = newStart
761
+ endTime = newEnd
762
+ updateHandlePositions()
763
+ seekTo(startTime, false)
764
+ }
765
+
766
+ @Suppress("ClickableViewAccessibility")
767
+ private fun setHandleTouchListener(handle: View, isLeading: Boolean) {
768
+ handle.setOnTouchListener { view, event ->
769
+ val draggingDisabled = mDuration < mMinDuration
770
+ when (event.action) {
771
+ MotionEvent.ACTION_DOWN -> {
772
+ currentSelectedhandle = handle
773
+ didClampWhilePanning = false
774
+ onMediaPause()
775
+ fadeOutProgressIndicator()
776
+ seekTo(if (isLeading) startTime else endTime, true)
777
+ playHapticFeedback(true)
778
+ isTrimmingLeading = isLeading
779
+ }
780
+ MotionEvent.ACTION_MOVE -> {
781
+ if (draggingDisabled) return@setOnTouchListener false
782
+
783
+ var didClamp = false
784
+ var newX = event.rawX - view.width.toFloat() / 2
785
+
786
+ if (isLeading) {
787
+ newX = maxOf(0f, minOf(newX, trailingHandle.x - view.width))
788
+ } else {
789
+ newX = minOf(trimmerContainerBg.width.toFloat() + view.width, maxOf(newX, leadingHandle.x + view.width))
790
+ }
791
+
792
+ view.x = newX
793
+
794
+ if (isLeading) {
795
+ val newStartTime = timeForPosition(newX)
796
+ val duration = endTime - newStartTime
797
+ when {
798
+ duration in mMinDuration..mMaxDuration -> {
799
+ startTime = newStartTime
800
+ val indicatorX = newX + view.width
801
+ val leftBoundary = leadingHandle.x + leadingHandle.width
802
+ val rightBoundary = trailingHandle.x - progressIndicator.width
803
+ progressIndicator.x = maxOf(leftBoundary, minOf(rightBoundary, indicatorX))
804
+ }
805
+ duration < mMinDuration -> {
806
+ didClamp = true
807
+ startTime = endTime - mMinDuration
808
+ if (isZoomedIn) {
809
+ val indicatorX = newX + view.width
810
+ val leftBoundary = leadingHandle.x + leadingHandle.width
811
+ val rightBoundary = trailingHandle.x - progressIndicator.width
812
+ progressIndicator.x = maxOf(leftBoundary, minOf(rightBoundary, indicatorX))
813
+ } else {
814
+ view.x = positionForTime(startTime)
815
+ val indicatorX = view.x + view.width
816
+ val leftBoundary = leadingHandle.x + leadingHandle.width
817
+ val rightBoundary = trailingHandle.x - progressIndicator.width
818
+ progressIndicator.x = maxOf(leftBoundary, minOf(rightBoundary, indicatorX))
819
+ }
820
+ }
821
+ else -> {
822
+ didClamp = true
823
+ startTime = endTime - mMaxDuration
824
+ if (isZoomedIn) {
825
+ val indicatorX = newX + view.width
826
+ val leftBoundary = leadingHandle.x + leadingHandle.width
827
+ val rightBoundary = trailingHandle.x - progressIndicator.width
828
+ progressIndicator.x = maxOf(leftBoundary, minOf(rightBoundary, indicatorX))
829
+ } else {
830
+ view.x = positionForTime(startTime)
831
+ val indicatorX = view.x + view.width
832
+ val leftBoundary = leadingHandle.x + leadingHandle.width
833
+ val rightBoundary = trailingHandle.x - progressIndicator.width
834
+ progressIndicator.x = maxOf(leftBoundary, minOf(rightBoundary, indicatorX))
835
+ }
836
+ }
837
+ }
838
+ } else {
839
+ val newEndTime = timeForPosition(newX - view.width)
840
+ val duration = newEndTime - startTime
841
+ when {
842
+ duration in mMinDuration..mMaxDuration -> {
843
+ endTime = newEndTime
844
+ val indicatorX = newX - progressIndicator.width
845
+ val leftBoundary = leadingHandle.x + leadingHandle.width
846
+ val rightBoundary = trailingHandle.x - progressIndicator.width
847
+ progressIndicator.x = maxOf(leftBoundary, minOf(rightBoundary, indicatorX))
848
+ }
849
+ duration < mMinDuration -> {
850
+ didClamp = true
851
+ endTime = startTime + mMinDuration
852
+ if (isZoomedIn) {
853
+ val indicatorX = newX - progressIndicator.width
854
+ val leftBoundary = leadingHandle.x + leadingHandle.width
855
+ val rightBoundary = trailingHandle.x - progressIndicator.width
856
+ progressIndicator.x = maxOf(leftBoundary, minOf(rightBoundary, indicatorX))
857
+ } else {
858
+ view.x = positionForTime(endTime) + view.width
859
+ val indicatorX = view.x - progressIndicator.width
860
+ val leftBoundary = leadingHandle.x + leadingHandle.width
861
+ val rightBoundary = trailingHandle.x - progressIndicator.width
862
+ progressIndicator.x = maxOf(leftBoundary, minOf(rightBoundary, indicatorX))
863
+ }
864
+ }
865
+ else -> {
866
+ didClamp = true
867
+ endTime = startTime + mMaxDuration
868
+ if (isZoomedIn) {
869
+ val indicatorX = newX - progressIndicator.width
870
+ val leftBoundary = leadingHandle.x + leadingHandle.width
871
+ val rightBoundary = trailingHandle.x - progressIndicator.width
872
+ progressIndicator.x = maxOf(leftBoundary, minOf(rightBoundary, indicatorX))
873
+ } else {
874
+ view.x = positionForTime(endTime) + view.width
875
+ val indicatorX = view.x - progressIndicator.width
876
+ val leftBoundary = leadingHandle.x + leadingHandle.width
877
+ val rightBoundary = trailingHandle.x - progressIndicator.width
878
+ progressIndicator.x = maxOf(leftBoundary, minOf(rightBoundary, indicatorX))
879
+ }
880
+ }
881
+ }
882
+ }
883
+
884
+ if (didClamp && !didClampWhilePanning) {
885
+ playHapticFeedback(false)
886
+ }
887
+ didClampWhilePanning = didClamp
888
+
889
+ updateTrimmerContainerWidth()
890
+ seekTo(if (isLeading) startTime else endTime, false)
891
+
892
+ startZoomWaitTimer()
893
+ }
894
+ MotionEvent.ACTION_UP -> {
895
+ stopZoomIfNeeded()
896
+ fadeInProgressIndicator()
897
+ view.performClick()
898
+ }
899
+ else -> return@setOnTouchListener false
900
+ }
901
+ true
902
+ }
903
+ }
904
+
905
+ private fun fadeOutProgressIndicator() {
906
+ progressIndicator.animate().alpha(0f).setDuration(250).withEndAction { progressIndicator.visibility = View.INVISIBLE }.start()
907
+ }
908
+
909
+ private fun fadeInProgressIndicator() {
910
+ progressIndicator.visibility = View.VISIBLE
911
+ progressIndicator.animate().alpha(1f).setDuration(250).start()
912
+ }
913
+
914
+ private fun updateTrimmerContainerWidth() {
915
+ val left = (leadingHandle.x + leadingHandle.width).toInt()
916
+ val right = trimmerContainerBg.width - kotlin.math.ceil(trailingHandle.x.toDouble()).toInt() + 2 * trailingHandle.width
917
+
918
+ val leadingOverlayParams = leadingOverlay.layoutParams as RelativeLayout.LayoutParams
919
+ leadingOverlayParams.width = left
920
+ leadingOverlayParams.height = RelativeLayout.LayoutParams.MATCH_PARENT
921
+ leadingOverlayParams.addRule(RelativeLayout.ALIGN_PARENT_START)
922
+ leadingOverlay.layoutParams = leadingOverlayParams
923
+
924
+ val trailingOverlayParams = trailingOverlay.layoutParams as RelativeLayout.LayoutParams
925
+ trailingOverlayParams.width = right
926
+ trailingOverlayParams.height = RelativeLayout.LayoutParams.MATCH_PARENT
927
+ trailingOverlayParams.addRule(RelativeLayout.ALIGN_PARENT_END)
928
+ trailingOverlay.layoutParams = trailingOverlayParams
929
+ }
930
+
931
+ private fun playHapticFeedback(isLight: Boolean) {
932
+ if (vibrator != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && enableHapticFeedback) {
933
+ vibrator!!.vibrate(VibrationEffect.createOneShot(if (isLight) 10L else 25L, VibrationEffect.DEFAULT_AMPLITUDE))
934
+ }
935
+ }
936
+
937
+ private fun startZoomWaitTimer() {
938
+ stopZoomWaitTimer()
939
+ if (isZoomedIn) return
940
+
941
+ zoomRunnable = Runnable {
942
+ stopZoomWaitTimer()
943
+ zoomIfNeeded()
944
+ }
945
+
946
+ zoomWaitTimer.postDelayed(zoomRunnable!!, 500)
947
+ }
948
+
949
+ private fun stopZoomWaitTimer() {
950
+ zoomRunnable?.let { zoomWaitTimer.removeCallbacks(it) }
951
+ }
952
+
953
+ private fun stopZoomIfNeeded() {
954
+ stopZoomWaitTimer()
955
+ if (isZoomedIn) {
956
+ isGeneratingThumbnails = false
957
+ BackgroundExecutor.cancelAll("progressive_thumbs", true)
958
+ isZoomedIn = false
959
+ restoreCachedThumbnails()
960
+ animateZoomTransition {
961
+ updateHandlePositions()
962
+ updateCurrentTime(true)
963
+ }
964
+ }
965
+ }
966
+
967
+ private fun zoomIfNeeded() {
968
+ if (isZoomedIn) return
969
+
970
+ val currentLeadingX = leadingHandle.x
971
+ val currentTrailingX = trailingHandle.x
972
+
973
+ var newDuration = minOf(zoomOnWaitingDuration, mDuration.toLong())
974
+
975
+ when {
976
+ mDuration < 2000 -> newDuration = maxOf(500L, mDuration.toLong() / 2)
977
+ mDuration < zoomOnWaitingDuration -> newDuration = maxOf(1000L, mDuration.toLong() / 2)
978
+ }
979
+
980
+ newDuration = minOf(newDuration, mDuration.toLong())
981
+
982
+ val rangeStart = if (isTrimmingLeading) {
983
+ var rs = maxOf(0L, startTime - newDuration / 2)
984
+ if (rs + newDuration > mDuration) rs = maxOf(0L, mDuration.toLong() - newDuration)
985
+ rs
986
+ } else {
987
+ var rs = maxOf(0L, endTime - newDuration / 2)
988
+ if (rs + newDuration > mDuration) rs = maxOf(0L, mDuration.toLong() - newDuration)
989
+ rs
990
+ }
991
+
992
+ zoomedInRangeStart = maxOf(0L, rangeStart)
993
+ zoomedInRangeDuration = minOf(newDuration, mDuration.toLong() - zoomedInRangeStart)
994
+
995
+ isZoomedIn = true
996
+
997
+ startProgressiveThumbnailGeneration()
998
+ updateHandlePositionsForZoom(currentLeadingX, currentTrailingX)
999
+ playHapticFeedback(true)
1000
+ }
1001
+
1002
+ private fun updateHandlePositionsForZoom(previousLeadingX: Float, previousTrailingX: Float) {
1003
+ Log.d(TAG, "Maintaining handle positions during zoom - Leading: $previousLeadingX, Trailing: $previousTrailingX")
1004
+
1005
+ leadingHandle.x = previousLeadingX
1006
+ trailingHandle.x = previousTrailingX
1007
+
1008
+ updateTrimmerContainerWidth()
1009
+
1010
+ val leftBoundary = leadingHandle.x + leadingHandle.width
1011
+ val rightBoundary = trailingHandle.x - progressIndicator.width
1012
+ val currentX = progressIndicator.x
1013
+
1014
+ if (currentX < leftBoundary || currentX > rightBoundary) {
1015
+ updateCurrentTime(true)
1016
+ } else {
1017
+ updateCurrentTime(false)
1018
+ }
1019
+
1020
+ trimmerContainerWrapper.visibility = View.VISIBLE
1021
+ if (trimmerContainerWrapper.alpha == 0f) {
1022
+ trimmerContainerWrapper.animate().alpha(1f).setDuration(250).start()
1023
+ }
1024
+ }
1025
+
1026
+ private fun startProgressiveThumbnailGeneration() {
1027
+ if (isGeneratingThumbnails || mediaMetadataRetriever == null) return
1028
+
1029
+ isGeneratingThumbnails = true
1030
+
1031
+ UiThreadExecutor.runTask("", {
1032
+ mThumbnailContainer.removeAllViews()
1033
+
1034
+ val containerContentWidth = mThumbnailContainer.width - mThumbnailContainer.paddingLeft - mThumbnailContainer.paddingRight
1035
+ val effectiveWidth = if (containerContentWidth > 0) containerContentWidth else VideoTrimmerUtil.VIDEO_FRAMES_WIDTH
1036
+ val thumbWidth = VideoTrimmerUtil.VIDEO_FRAMES_WIDTH / VideoTrimmerUtil.MAX_COUNT_RANGE
1037
+ val numberOfThumbnails = maxOf(8, effectiveWidth / maxOf(1, thumbWidth))
1038
+ val baseWidth = effectiveWidth / numberOfThumbnails
1039
+ val remainder = effectiveWidth % numberOfThumbnails
1040
+
1041
+ for (i in 0 until numberOfThumbnails) {
1042
+ val placeholder = ImageView(context)
1043
+ val width = if (i < remainder) baseWidth + 1 else baseWidth
1044
+ val layoutParams = LinearLayout.LayoutParams(width, LinearLayout.LayoutParams.MATCH_PARENT)
1045
+ placeholder.layoutParams = layoutParams
1046
+ placeholder.setBackgroundColor("#F0F0F0".toColorInt())
1047
+ placeholder.alpha = 0.2f
1048
+ mThumbnailContainer.addView(placeholder)
1049
+ }
1050
+ }, 0)
1051
+
1052
+ BackgroundExecutor.execute(object : BackgroundExecutor.Task("progressive_thumbs", 0L, "") {
1053
+ override fun execute() {
1054
+ try {
1055
+ val thumbnailWidth = VideoTrimmerUtil.VIDEO_FRAMES_WIDTH / VideoTrimmerUtil.MAX_COUNT_RANGE
1056
+ val numberOfThumbnails = maxOf(8, mThumbnailContainer.width / thumbnailWidth)
1057
+ val visibleDuration = if (isZoomedIn) zoomedInRangeDuration else mDuration.toLong()
1058
+ val visibleStart = if (isZoomedIn) zoomedInRangeStart else 0L
1059
+ val interval = if (visibleDuration > 0) visibleDuration / numberOfThumbnails else 0L
1060
+
1061
+ for (i in 0 until numberOfThumbnails) {
1062
+ if (!isGeneratingThumbnails || !isZoomedIn) {
1063
+ Log.d(TAG, "Thumbnail generation cancelled at index $i")
1064
+ return
1065
+ }
1066
+
1067
+ val index = i
1068
+ val timeUs = (visibleStart + i * interval) * 1000
1069
+ val clampedTimeUs = maxOf(0L, minOf(timeUs, mDuration * 1000L))
1070
+
1071
+ try {
1072
+ val bitmap = mediaMetadataRetriever?.getFrameAtTime(clampedTimeUs, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
1073
+ if (bitmap != null && isGeneratingThumbnails && isZoomedIn) {
1074
+ UiThreadExecutor.runTask("", {
1075
+ if (isZoomedIn && index < mThumbnailContainer.childCount) {
1076
+ val thumbnailView = mThumbnailContainer.getChildAt(index) as? ImageView
1077
+ if (thumbnailView != null) {
1078
+ thumbnailView.setImageBitmap(bitmap)
1079
+ thumbnailView.scaleType = ImageView.ScaleType.CENTER_CROP
1080
+ thumbnailView.background = null
1081
+
1082
+ thumbnailView.animate()
1083
+ .alpha(1.0f)
1084
+ .setDuration(150)
1085
+ .setStartDelay(index * 50L)
1086
+ .start()
1087
+ }
1088
+ }
1089
+ }, 0)
1090
+
1091
+ Thread.sleep(10)
1092
+ }
1093
+ } catch (e: Exception) {
1094
+ Log.w(TAG, "Error generating progressive thumbnail at $clampedTimeUs", e)
1095
+ }
1096
+ }
1097
+
1098
+ isGeneratingThumbnails = false
1099
+ } catch (e: Exception) {
1100
+ Log.e(TAG, "Error in progressive thumbnail generation", e)
1101
+ isGeneratingThumbnails = false
1102
+ }
1103
+ }
1104
+ })
1105
+ }
1106
+
1107
+ private fun animateZoomTransition(onComplete: Runnable?) {
1108
+ mThumbnailContainer.animate()
1109
+ .alpha(0.7f)
1110
+ .setDuration(200)
1111
+ .withEndAction {
1112
+ onComplete?.run()
1113
+ mThumbnailContainer.animate()
1114
+ .alpha(1.0f)
1115
+ .setDuration(200)
1116
+ .start()
1117
+ }
1118
+ .start()
1119
+ }
1120
+
1121
+ private fun ignoreSystemGestureForView(v: View) {
1122
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
1123
+ v.systemGestureExclusionRects = listOf(
1124
+ android.graphics.Rect(0, 0, DeviceUtil.getDeviceWidth(), DeviceUtil.getDeviceHeight())
1125
+ )
1126
+ }
1127
+ }
1128
+
1129
+ private fun timeForPosition(position: Float): Long {
1130
+ if (trimmerContainerBg.width <= 0) return 0
1131
+
1132
+ return if (isZoomedIn) {
1133
+ val ratio = position / trimmerContainerBg.width
1134
+ zoomedInRangeStart + (ratio * zoomedInRangeDuration).toLong()
1135
+ } else {
1136
+ (position / trimmerContainerBg.width * mDuration).toLong()
1137
+ }
1138
+ }
1139
+
1140
+ private fun positionForTime(time: Long): Float {
1141
+ return if (isZoomedIn) {
1142
+ if (zoomedInRangeDuration <= 0) return 0f
1143
+ val ratio = (time - zoomedInRangeStart).toFloat() / zoomedInRangeDuration
1144
+ maxOf(0f, minOf(trimmerContainerBg.width.toFloat(), ratio * trimmerContainerBg.width))
1145
+ } else {
1146
+ if (mDuration <= 0) return 0f
1147
+ maxOf(0f, minOf(trimmerContainerBg.width.toFloat(), time.toFloat() / mDuration * trimmerContainerBg.width))
1148
+ }
1149
+ }
1150
+
1151
+ private fun getVisibleRangeStart(): Long {
1152
+ return if (isZoomedIn) zoomedInRangeStart else 0
1153
+ }
1154
+
1155
+ private fun getVisibleRangeDuration(): Long {
1156
+ return if (isZoomedIn) zoomedInRangeDuration else mDuration.toLong()
1157
+ }
1158
+
1159
+ private fun restoreCachedThumbnails() {
1160
+ mThumbnailContainer.removeAllViews()
1161
+
1162
+ for (cachedThumbnail in cachedFullViewThumbnails) {
1163
+ val restoredView = ImageView(context)
1164
+ restoredView.setImageBitmap((cachedThumbnail.drawable as android.graphics.drawable.BitmapDrawable).bitmap)
1165
+ restoredView.scaleType = ImageView.ScaleType.CENTER_CROP
1166
+ restoredView.layoutParams = cachedThumbnail.layoutParams
1167
+ mThumbnailContainer.addView(restoredView)
1168
+ }
1169
+ }
1170
+ }