react-native-video-trim 6.2.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.
@@ -1,1322 +0,0 @@
1
- package com.videotrim.widgets;
2
-
3
- import static com.facebook.react.bridge.UiThreadUtil.runOnUiThread;
4
- import static com.videotrim.utils.VideoTrimmerUtil.RECYCLER_VIEW_PADDING;
5
- import static com.videotrim.utils.VideoTrimmerUtil.VIDEO_FRAMES_WIDTH;
6
-
7
- import android.content.Context;
8
- import android.content.pm.ActivityInfo;
9
- import android.content.res.Configuration;
10
- import android.graphics.Bitmap;
11
- import android.graphics.Color;
12
- import android.graphics.drawable.GradientDrawable;
13
- import android.media.MediaMetadataRetriever;
14
- import android.media.MediaPlayer;
15
- import android.net.Uri;
16
- import android.os.Build;
17
- import android.os.Handler;
18
- import android.os.VibrationEffect;
19
- import android.os.Vibrator;
20
- import android.util.AttributeSet;
21
- import android.util.Log;
22
- import android.util.TypedValue;
23
- import android.view.LayoutInflater;
24
- import android.view.GestureDetector;
25
- import android.view.MotionEvent;
26
- import android.view.View;
27
- import android.widget.FrameLayout;
28
- import android.widget.ImageView;
29
- import android.widget.LinearLayout;
30
- import android.widget.ProgressBar;
31
- import android.widget.RelativeLayout;
32
- import android.widget.TextView;
33
- import android.widget.VideoView;
34
-
35
- import androidx.appcompat.app.AlertDialog;
36
-
37
- import com.arthenica.ffmpegkit.FFmpegSession;
38
- import com.facebook.react.bridge.ReactApplicationContext;
39
- import com.facebook.react.bridge.ReadableMap;
40
- import com.videotrim.R;
41
- import com.videotrim.enums.ErrorCode;
42
- import com.videotrim.interfaces.IVideoTrimmerView;
43
- import com.videotrim.interfaces.VideoTrimListener;
44
- import com.videotrim.utils.MediaMetadataUtil;
45
- import com.videotrim.utils.StorageUtil;
46
- import com.videotrim.utils.VideoTrimmerUtil;
47
-
48
- import java.io.IOException;
49
- import java.util.Locale;
50
- import java.util.Objects;
51
-
52
- import iknow.android.utils.DeviceUtil;
53
- import iknow.android.utils.thread.BackgroundExecutor;
54
- import iknow.android.utils.thread.UiThreadExecutor;
55
-
56
- public class VideoTrimmerView extends FrameLayout implements IVideoTrimmerView {
57
-
58
- private static final String TAG = VideoTrimmerView.class.getSimpleName();
59
-
60
- private ReactApplicationContext mContext;
61
- private VideoView mVideoView;
62
-
63
- // mediaPlayer is used for both video/audio
64
- // the reason we use mediaPlayer for Video: https://stackoverflow.com/a/73361868/7569705
65
- // 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
66
- private MediaPlayer mediaPlayer;
67
- private ImageView mPlayView;
68
- private LinearLayout mThumbnailContainer;
69
- private Uri mSourceUri;
70
- private VideoTrimListener mOnTrimVideoListener;
71
- private int mDuration = 0;
72
- private long mMaxDuration = (long) Double.POSITIVE_INFINITY;
73
- private long mMinDuration = VideoTrimmerUtil.MIN_SHOOT_DURATION;
74
-
75
- private final Handler mTimingHandler = new Handler();
76
- private Runnable mTimingRunnable;
77
- private static final long TIMING_UPDATE_INTERVAL = 30; // Update every 30 milliseconds
78
- private TextView currentTimeText;
79
- private TextView startTimeText;
80
- private TextView endTimeText;
81
- private View progressIndicator;
82
- private View trimmerContainer;
83
- // background of the trimmer container, its width never changes
84
- // 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
85
- private View trimmerContainerBg;
86
- private FrameLayout leadingHandle;
87
- private View trailingHandle;
88
- private View leadingOverlay;
89
- private View trailingOverlay;
90
- private RelativeLayout trimmerContainerWrapper;
91
-
92
- private long startTime = 0, endTime = 0;
93
- private boolean enableRotation = false;
94
- private double rotationAngle = 0.0;
95
- private long zoomOnWaitingDuration = 5000; // Default: 5 seconds (in milliseconds)
96
-
97
- private Vibrator vibrator;
98
- private boolean didClampWhilePanning = false;
99
-
100
- // zoom
101
- private boolean isZoomedIn = false;
102
- private final Handler zoomWaitTimer = new Handler();
103
- private Runnable zoomRunnable;
104
- private long zoomedInRangeStart = 0;
105
- private long zoomedInRangeDuration = 0;
106
- private boolean isTrimmingLeading = false;
107
-
108
- // range drag
109
- private boolean isRangeDragging = false;
110
- private float rangeDragInitialRawX = 0;
111
- private long rangeDragInitialStartTime = 0;
112
- private long rangeDragInitialEndTime = 0;
113
- private GestureDetector rangeDragGestureDetector;
114
-
115
- // thumbnail caching for zoom functionality
116
- private final java.util.List<ImageView> cachedFullViewThumbnails = new java.util.ArrayList<>();
117
- private volatile boolean isGeneratingThumbnails = false;
118
-
119
- private MediaMetadataRetriever mediaMetadataRetriever;
120
- private ProgressBar loadingIndicator;
121
- private TextView saveBtn;
122
- private TextView cancelBtn;
123
- private FrameLayout audioBannerView;
124
- private boolean isVideoType = true;
125
- private ImageView failToLoadBtn;
126
-
127
- private String mOutputExt = "mp4";
128
- private boolean enableHapticFeedback = true;
129
- private boolean autoplay = false;
130
- private long jumpToPositionOnLoad = 0;
131
- private FrameLayout headerView;
132
- private TextView headerText;
133
- private FFmpegSession ffmpegSession;
134
- private boolean alertOnFailToLoad = true;
135
- private String alertOnFailTitle = "Error";
136
- private String alertOnFailMessage = "Fail to load media. Possibly invalid file or no network connection";
137
- private String alertOnFailCloseText = "Close";
138
- private View currentSelectedhandle;
139
-
140
- private RelativeLayout trimmerView;
141
-
142
- private int trimmerColor = Color.parseColor(getContext().getString(R.string.trim_color)); // Default color if not set
143
- private int handleIconColor = Color.BLACK; // Default chevron color
144
- private ImageView leadingChevron;
145
- private ImageView trailingChevron;
146
-
147
- public VideoTrimmerView(ReactApplicationContext context, ReadableMap config, AttributeSet attrs) {
148
- this(context, attrs, 0, config);
149
- }
150
-
151
- public VideoTrimmerView(ReactApplicationContext context, AttributeSet attrs, int defStyleAttr, ReadableMap config) {
152
- super(context, attrs, defStyleAttr);
153
- init(context, config);
154
- }
155
-
156
- private void init(ReactApplicationContext context, ReadableMap config) {
157
- this.mContext = context;
158
-
159
- context.getCurrentActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
160
- LayoutInflater.from(context).inflate(R.layout.video_trimmer_view, this, true);
161
- vibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE);
162
-
163
- initializeViews();
164
- configure(config);
165
- setUpListeners();
166
- initRangeDragDetector();
167
- setProgressIndicatorTouchListener();
168
- }
169
-
170
- private void initRangeDragDetector() {
171
- rangeDragGestureDetector = new GestureDetector(mContext, new GestureDetector.SimpleOnGestureListener() {
172
- @Override
173
- public void onLongPress(MotionEvent e) {
174
- isRangeDragging = true;
175
- rangeDragInitialRawX = e.getRawX();
176
- rangeDragInitialStartTime = startTime;
177
- rangeDragInitialEndTime = endTime;
178
- playHapticFeedback(true);
179
- fadeOutProgressIndicator();
180
- }
181
- });
182
- }
183
-
184
- private void initializeViews() {
185
- mThumbnailContainer = findViewById(R.id.thumbnailContainer);
186
- mVideoView = findViewById(R.id.video_loader);
187
- mPlayView = findViewById(R.id.icon_video_play);
188
- startTimeText = findViewById(R.id.startTime);
189
- currentTimeText = findViewById(R.id.currentTime);
190
- endTimeText = findViewById(R.id.endTime);
191
- progressIndicator = findViewById(R.id.progressIndicator);
192
- trimmerContainer = findViewById(R.id.trimmerContainer);
193
- trimmerContainerBg = findViewById(R.id.trimmerContainerBg);
194
- leadingHandle = findViewById(R.id.leadingHandle);
195
- trailingHandle = findViewById(R.id.trailingHandle);
196
- leadingOverlay = findViewById(R.id.leadingOverlay);
197
- trailingOverlay = findViewById(R.id.trailingOverlay);
198
-
199
- trimmerContainerWrapper = findViewById(R.id.trimmerContainerWrapper);
200
- trimmerContainerWrapper.setVisibility(View.INVISIBLE);
201
- trimmerContainerWrapper.setAlpha(0f);
202
-
203
- loadingIndicator = findViewById(R.id.loadingIndicator);
204
- saveBtn = findViewById(R.id.saveBtn);
205
- cancelBtn = findViewById(R.id.cancelBtn);
206
- audioBannerView = findViewById(R.id.audioBannerView);
207
- failToLoadBtn = findViewById(R.id.failToLoadBtn);
208
-
209
- headerView = findViewById(R.id.headerView);
210
- headerText = findViewById(R.id.headerText);
211
-
212
- trimmerView = findViewById(R.id.trimmerView);
213
-
214
- leadingChevron = findViewById(R.id.leadingChevron);
215
- trailingChevron = findViewById(R.id.trailingChevron);
216
- }
217
-
218
- public void initByURI(final Uri videoURI) {
219
- mSourceUri = videoURI;
220
-
221
- if (isVideoType) {
222
- mVideoView.setVideoURI(videoURI);
223
- mVideoView.requestFocus();
224
-
225
- mVideoView.setOnPreparedListener(mp -> {
226
- mp.setVideoScalingMode(MediaPlayer.VIDEO_SCALING_MODE_SCALE_TO_FIT);
227
- mediaPlayer = mp;
228
- mediaPrepared();
229
- });
230
-
231
- mVideoView.setOnErrorListener(this::onFailToLoadMedia);
232
- mVideoView.setOnCompletionListener(mp -> mediaCompleted());
233
- } else {
234
- mVideoView.setVisibility(View.GONE);
235
- audioBannerView.setAlpha(0f);
236
- audioBannerView.setVisibility(View.VISIBLE);
237
- audioBannerView.animate().alpha(1f).setDuration(500).start();
238
-
239
- mediaPlayer = new MediaPlayer();
240
- try {
241
- mediaPlayer.setDataSource(videoURI.toString());
242
- mediaPlayer.setOnPreparedListener(mp -> {
243
- mediaPrepared();
244
- });
245
- mediaPlayer.setOnCompletionListener(mp -> mediaCompleted());
246
- mediaPlayer.setOnErrorListener(this::onFailToLoadMedia);
247
-
248
- mediaPlayer.prepareAsync(); // use prepareAsync to avoid blocking the main thread
249
- } catch (IOException e) {
250
- e.printStackTrace();
251
- mediaFailed();
252
- mOnTrimVideoListener.onError("Error initializing audio player. Please try again.", ErrorCode.FAIL_TO_INITIALIZE_AUDIO_PLAYER);
253
- }
254
- }
255
- }
256
-
257
- private boolean onFailToLoadMedia(MediaPlayer mp, int what, int extra) {
258
- mediaFailed();
259
- mOnTrimVideoListener.onError("Error loading media file. Please try again.", ErrorCode.FAIL_TO_LOAD_MEDIA);
260
- if (alertOnFailToLoad) {
261
- AlertDialog.Builder builder = new AlertDialog.Builder(mContext.getCurrentActivity());
262
- builder.setMessage(alertOnFailMessage);
263
- builder.setTitle(alertOnFailTitle);
264
- builder.setCancelable(false);
265
- builder.setPositiveButton(alertOnFailCloseText, (dialog, which) -> {
266
- dialog.cancel();
267
- });
268
-
269
- AlertDialog alertDialog = builder.create();
270
- alertDialog.show();
271
- }
272
-
273
- return true;
274
- }
275
-
276
- private void startShootVideoThumbs(final Context context, int totalThumbsCount, long startPosition, long endPosition) {
277
- mThumbnailContainer.removeAllViews();
278
- cachedFullViewThumbnails.clear(); // Clear previous cache
279
-
280
- VideoTrimmerUtil.shootVideoThumbInBackground(mediaMetadataRetriever, totalThumbsCount, startPosition, endPosition,
281
- (bitmap, interval) -> {
282
- if (bitmap != null) {
283
- runOnUiThread(() -> {
284
- ImageView thumbImageView = new ImageView(context);
285
- thumbImageView.setImageBitmap(bitmap);
286
- thumbImageView.setScaleType(ImageView.ScaleType.CENTER_CROP);
287
- LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(100, LayoutParams.MATCH_PARENT);
288
- layoutParams.width = VideoTrimmerUtil.VIDEO_FRAMES_WIDTH / VideoTrimmerUtil.MAX_COUNT_RANGE;
289
- thumbImageView.setLayoutParams(layoutParams);
290
- mThumbnailContainer.addView(thumbImageView);
291
-
292
- // Cache the thumbnail for zoom functionality
293
- ImageView cachedView = new ImageView(context);
294
- cachedView.setImageBitmap(bitmap);
295
- cachedView.setScaleType(ImageView.ScaleType.CENTER_CROP);
296
- cachedView.setLayoutParams(layoutParams);
297
- cachedFullViewThumbnails.add(cachedView);
298
- });
299
- }
300
- });
301
- }
302
-
303
- private void mediaPrepared() {
304
- mDuration = mediaPlayer.getDuration();
305
- mMaxDuration = Math.min(mMaxDuration, mDuration);
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
- // take first frame
315
- Bitmap bitmap = mediaMetadataRetriever.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC);
316
-
317
- if (bitmap != null) {
318
- int bitmapHeight = bitmap.getHeight() > 0 ? bitmap.getHeight() : VideoTrimmerUtil.THUMB_HEIGHT;
319
- int bitmapWidth = bitmap.getWidth() > 0 ? bitmap.getWidth() : VideoTrimmerUtil.THUMB_WIDTH;
320
- VideoTrimmerUtil.mThumbWidth = VideoTrimmerUtil.THUMB_HEIGHT * bitmapWidth / bitmapHeight;
321
- }
322
-
323
- VideoTrimmerUtil.SCREEN_WIDTH_FULL = this.getScreenWidthInPortraitMode();
324
- VideoTrimmerUtil.VIDEO_FRAMES_WIDTH = VideoTrimmerUtil.SCREEN_WIDTH_FULL - RECYCLER_VIEW_PADDING * 2;
325
- VideoTrimmerUtil.MAX_COUNT_RANGE = VideoTrimmerUtil.mThumbWidth != 0 ? Math.max((VIDEO_FRAMES_WIDTH / VideoTrimmerUtil.mThumbWidth), VideoTrimmerUtil.MAX_COUNT_RANGE) : VideoTrimmerUtil.MAX_COUNT_RANGE;
326
-
327
- startShootVideoThumbs(mContext, VideoTrimmerUtil.MAX_COUNT_RANGE, 0, mDuration);
328
- }
329
-
330
- // Set initial handle positions if mMaxDuration < video duration
331
- if (mMaxDuration < mDuration) {
332
- endTime = mMaxDuration;
333
- } else {
334
- endTime = mDuration;
335
- }
336
- updateHandlePositions();
337
-
338
- loadingIndicator.setVisibility(View.GONE);
339
- mPlayView.setVisibility(View.VISIBLE);
340
- saveBtn.setVisibility(View.VISIBLE);
341
-
342
- if (jumpToPositionOnLoad > 0) {
343
- seekTo(jumpToPositionOnLoad > mDuration ? mDuration : jumpToPositionOnLoad, true);
344
- }
345
-
346
- if (autoplay) {
347
- playOrPause();
348
- }
349
-
350
- mOnTrimVideoListener.onLoad(mDuration);
351
- ignoreSystemGestureForView(trimmerView);
352
- }
353
-
354
- private void updateGradientColors(int startColor, int endColor) {
355
- GradientDrawable gradientDrawable = new GradientDrawable();
356
- gradientDrawable.setShape(GradientDrawable.RECTANGLE);
357
- gradientDrawable.setCornerRadius(6f); // Adjust corner radius as needed
358
- gradientDrawable.setColors(new int[]{startColor, endColor});
359
- gradientDrawable.setOrientation(GradientDrawable.Orientation.LEFT_RIGHT);
360
-
361
- mThumbnailContainer.setBackground(gradientDrawable);
362
- }
363
-
364
- private void mediaFailed() {
365
- loadingIndicator.setVisibility(View.GONE);
366
- failToLoadBtn.setVisibility(View.VISIBLE);
367
- }
368
-
369
- private void updateHandlePositions() {
370
- // Use zoom-aware position calculation
371
- float leadingHandleX = positionForTime(startTime);
372
- float trailingHandleX = positionForTime(endTime);
373
-
374
- leadingHandle.setX(leadingHandleX);
375
- trailingHandle.setX(trailingHandleX + trailingHandle.getWidth());
376
-
377
- updateTrimmerContainerWidth();
378
- updateCurrentTime(false);
379
-
380
- trimmerContainerWrapper.setVisibility(View.VISIBLE);
381
- trimmerContainerWrapper.animate().alpha(1f).setDuration(250).start();
382
- }
383
-
384
- private void mediaCompleted() {
385
- onMediaPause();
386
-
387
- // 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
388
- seekTo(endTime, true);
389
- }
390
-
391
- private void playOrPause() {
392
- if (mediaPlayer.isPlaying()) {
393
- onMediaPause();
394
- } else {
395
- if (mediaPlayer.getCurrentPosition() >= endTime) {
396
- seekTo(startTime, true);
397
- }
398
- mediaPlayer.start();
399
- startTimingRunnable();
400
- }
401
- setPlayPauseViewIcon(mediaPlayer.isPlaying());
402
- }
403
-
404
- public void onMediaPause() {
405
- mTimingHandler.removeCallbacks(mTimingRunnable);
406
- if (mediaPlayer.isPlaying()) {
407
- mediaPlayer.pause();
408
- }
409
- setPlayPauseViewIcon(false);
410
- }
411
-
412
- public void setOnTrimVideoListener(VideoTrimListener onTrimVideoListener) {
413
- mOnTrimVideoListener = onTrimVideoListener;
414
- }
415
-
416
- private void setUpListeners() {
417
- cancelBtn.setOnClickListener(view -> mOnTrimVideoListener.onCancel());
418
- saveBtn.setOnClickListener(view -> mOnTrimVideoListener.onSave());
419
- mPlayView.setOnClickListener(view -> playOrPause());
420
- setHandleTouchListener(leadingHandle, true);
421
- setHandleTouchListener(trailingHandle, false);
422
- }
423
-
424
- public void onSaveClicked() {
425
- onMediaPause();
426
- ffmpegSession = VideoTrimmerUtil.trim(
427
- mSourceUri.toString(),
428
- StorageUtil.getOutputPath(mContext, mOutputExt),
429
- mDuration,
430
- startTime,
431
- endTime,
432
- enableRotation,
433
- rotationAngle,
434
- mOnTrimVideoListener
435
- );
436
- }
437
-
438
- public void onCancelTrimClicked() {
439
- if (ffmpegSession != null) {
440
- ffmpegSession.cancel();
441
- } else {
442
- mOnTrimVideoListener.onCancelTrim();
443
- }
444
- }
445
-
446
- private void seekTo(long msec, boolean needUpdateProgress) {
447
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
448
- mediaPlayer.seekTo((int) msec, MediaPlayer.SEEK_CLOSEST);
449
- } else {
450
- mediaPlayer.seekTo((int) msec);
451
- }
452
-
453
- updateCurrentTime(needUpdateProgress);
454
- }
455
-
456
- private void setPlayPauseViewIcon(boolean isPlaying) {
457
- // note: icons imported from SF symbols have 0.85 opacity we should change to 1 here
458
- mPlayView.setImageResource(isPlaying ? R.drawable.pause_fill : R.drawable.play_fill);
459
- }
460
-
461
- @Override
462
- protected void onDetachedFromWindow() {
463
- super.onDetachedFromWindow();
464
- mContext.getCurrentActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
465
- }
466
-
467
- @Override
468
- public void onDestroy() {
469
- // Stop any ongoing operations
470
- isGeneratingThumbnails = false;
471
- BackgroundExecutor.cancelAll("", true);
472
- UiThreadExecutor.cancelAll("");
473
- mContext.getCurrentActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
474
- mTimingHandler.removeCallbacks(mTimingRunnable);
475
- zoomWaitTimer.removeCallbacks(zoomRunnable);
476
-
477
- // Clear cached thumbnails to prevent memory leaks
478
- cachedFullViewThumbnails.clear();
479
-
480
- try {
481
- if (mediaMetadataRetriever != null) {
482
- mediaMetadataRetriever.release();
483
- }
484
- } catch (Exception e) {
485
- e.printStackTrace();
486
- }
487
-
488
- try {
489
- if (mediaPlayer != null) {
490
- mediaPlayer.stop();
491
- mediaPlayer.release();
492
- }
493
- } catch (IllegalStateException e) {
494
- // if it's video, resource is released with the view, and here we also call .release which will throw exception
495
- e.printStackTrace();
496
- Log.d(TAG, "onDestroy mediaPlayer is already released");
497
- }
498
- }
499
-
500
- private int getScreenWidthInPortraitMode() {
501
- int screenWidth = DeviceUtil.getDeviceWidth();
502
- int screenHeight = DeviceUtil.getDeviceHeight();
503
- int currentOrientation = getResources().getConfiguration().orientation;
504
- if (currentOrientation == Configuration.ORIENTATION_LANDSCAPE) {
505
- return screenHeight;
506
- }
507
- return screenWidth;
508
- }
509
-
510
- private void configure(ReadableMap config) {
511
- if (config.hasKey("maxDuration") && config.getDouble("maxDuration") > 0) {
512
- mMaxDuration = (long) Math.max(0, config.getDouble("maxDuration"));
513
- }
514
-
515
- if (config.hasKey("minDuration") && config.getDouble("minDuration") > 0) {
516
- mMinDuration = (long) Math.max(1000L, config.getDouble("minDuration"));
517
- }
518
-
519
- cancelBtn.setText(config.getString("cancelButtonText"));
520
- saveBtn.setText(config.getString("saveButtonText"));
521
- isVideoType = config.hasKey("type") && Objects.equals(config.getString("type"), "video");
522
- System.out.println("isVideoType: " + isVideoType);
523
-
524
- mOutputExt = config.hasKey("outputExt") ? config.getString("outputExt") : "mp4";
525
- if (!isVideoType) {
526
- mOutputExt = "wav";
527
- }
528
- enableHapticFeedback = config.hasKey("enableHapticFeedback") && config.getBoolean("enableHapticFeedback");
529
- autoplay = config.hasKey("autoplay") && config.getBoolean("autoplay");
530
-
531
- if (config.hasKey("jumpToPositionOnLoad") && config.getDouble("jumpToPositionOnLoad") > 0) {
532
- jumpToPositionOnLoad = (long) Math.max(0, config.getDouble("jumpToPositionOnLoad") * 1000L);
533
- }
534
- headerText.setText(config.hasKey("headerText") ? config.getString("headerText") : "");
535
-
536
- int textSize = config.hasKey("headerTextSize") ? config.getInt("headerTextSize") : 16;
537
- if (textSize < 0) {
538
- textSize = 16;
539
- }
540
-
541
- headerText.setTextSize(TypedValue.COMPLEX_UNIT_DIP, textSize);
542
- headerText.setTextColor(config.hasKey("headerTextColor") ? config.getInt("headerTextColor") : Color.BLACK);
543
-
544
- headerView.setVisibility(View.VISIBLE);
545
- alertOnFailToLoad = config.hasKey("alertOnFailToLoad") && config.getBoolean("alertOnFailToLoad");
546
- alertOnFailTitle = config.hasKey("alertOnFailTitle") ? config.getString("alertOnFailTitle") : "Error";
547
- alertOnFailMessage = config.hasKey("alertOnFailMessage") ? config.getString("alertOnFailMessage") : "Fail to load media. Possibly invalid file or no network connection";
548
- alertOnFailCloseText = config.hasKey("alertOnFailCloseText") ? config.getString("alertOnFailCloseText") : "Close";
549
- enableRotation = config.hasKey("enableRotation") && config.getBoolean("enableRotation");
550
- rotationAngle = config.hasKey("rotationAngle") ? config.getDouble("rotationAngle") : 0.0;
551
-
552
- // Configure zoom on waiting duration (in seconds, converted to milliseconds)
553
- if (config.hasKey("zoomOnWaitingDuration") && config.getDouble("zoomOnWaitingDuration") > 0) {
554
- zoomOnWaitingDuration = (long) (config.getDouble("zoomOnWaitingDuration"));
555
- Log.d(TAG, "Configured zoom on waiting duration: " + (zoomOnWaitingDuration / 1000.0) + " seconds");
556
- }
557
-
558
- trimmerColor = config.hasKey("trimmerColor") ? config.getInt("trimmerColor") : Color.parseColor(getContext().getString(R.string.trim_color));
559
- handleIconColor = config.hasKey("handleIconColor") ? config.getInt("handleIconColor") : Color.BLACK;
560
-
561
- applyTrimmerColor();
562
- }
563
-
564
- private void applyTrimmerColor() {
565
- // Trimmer border (stroke only)
566
- GradientDrawable borderDrawable = new GradientDrawable();
567
- borderDrawable.setShape(GradientDrawable.RECTANGLE);
568
- borderDrawable.setColor(Color.TRANSPARENT);
569
- borderDrawable.setStroke(dpToPx(4), trimmerColor);
570
- trimmerContainer.setBackground(borderDrawable);
571
-
572
- // Leading handle (rounded left corners)
573
- GradientDrawable leadingHandleDrawable = new GradientDrawable();
574
- leadingHandleDrawable.setShape(GradientDrawable.RECTANGLE);
575
- leadingHandleDrawable.setColor(trimmerColor);
576
- leadingHandleDrawable.setCornerRadii(new float[]{dpToPx(6), dpToPx(6), 0, 0, 0, 0, dpToPx(6), dpToPx(6)});
577
- leadingHandle.setBackground(leadingHandleDrawable);
578
-
579
- // Trailing handle (rounded right corners)
580
- GradientDrawable trailingHandleDrawable = new GradientDrawable();
581
- trailingHandleDrawable.setShape(GradientDrawable.RECTANGLE);
582
- trailingHandleDrawable.setColor(trimmerColor);
583
- trailingHandleDrawable.setCornerRadii(new float[]{0, 0, dpToPx(6), dpToPx(6), dpToPx(6), dpToPx(6), 0, 0});
584
- trailingHandle.setBackground(trailingHandleDrawable);
585
-
586
- // Chevron colors
587
- leadingChevron.setColorFilter(handleIconColor, android.graphics.PorterDuff.Mode.SRC_IN);
588
- trailingChevron.setColorFilter(handleIconColor, android.graphics.PorterDuff.Mode.SRC_IN);
589
- }
590
-
591
- private int dpToPx(int dp) {
592
- return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, getResources().getDisplayMetrics());
593
- }
594
-
595
- private void startTimingRunnable() {
596
- mTimingRunnable = new Runnable() {
597
- @Override
598
- public void run() {
599
- try {
600
- int currentPosition = mediaPlayer.getCurrentPosition();
601
-
602
- if (currentPosition >= endTime) {
603
- onMediaPause();
604
- seekTo(endTime, true); // Ensure exact end time display
605
- } else {
606
- updateCurrentTime(true);
607
- mTimingHandler.postDelayed(this, TIMING_UPDATE_INTERVAL);
608
- }
609
- } catch (IllegalStateException e) {
610
- e.printStackTrace();
611
- mTimingHandler.removeCallbacks(mTimingRunnable);
612
- // this is to catch the error thrown if we close editor while playing (mediaPlayer is released)
613
- }
614
- }
615
- };
616
- mTimingHandler.postDelayed(mTimingRunnable, TIMING_UPDATE_INTERVAL);
617
- }
618
-
619
- private void updateCurrentTime(boolean needUpdateProgress) {
620
- int currentPosition = mediaPlayer.getCurrentPosition();
621
- int duration = mDuration;
622
-
623
- if (currentPosition >= duration - 100) {
624
- currentPosition = duration;
625
- } else if (currentPosition >= endTime - 100) {
626
- currentPosition = (int) endTime;
627
- } else if (currentPosition <= startTime + 100) {
628
- currentPosition = (int) startTime;
629
- }
630
-
631
- String currentTime = formatTime(currentPosition);
632
- currentTimeText.setText(currentTime);
633
-
634
- String startTime = formatTime((int) this.startTime);
635
- startTimeText.setText(startTime);
636
-
637
- String endTime = formatTime((int) this.endTime);
638
- endTimeText.setText(endTime);
639
-
640
- if (needUpdateProgress) {
641
- // Update progressIndicator position using zoom-aware calculation
642
- float indicatorPosition;
643
-
644
- if (isZoomedIn) {
645
- // Calculate position relative to zoomed range
646
- long visibleRangeStart = getVisibleRangeStart();
647
- long visibleRangeDuration = getVisibleRangeDuration();
648
-
649
- // Ensure current position is within visible range for proper calculation
650
- if (currentPosition < visibleRangeStart || currentPosition > visibleRangeStart + visibleRangeDuration) {
651
- // If current position is outside visible range, clamp it
652
- currentPosition = (int) Math.max(visibleRangeStart, Math.min(visibleRangeStart + visibleRangeDuration, currentPosition));
653
- }
654
-
655
- float ratio = visibleRangeDuration > 0 ? (float) (currentPosition - visibleRangeStart) / visibleRangeDuration : 0;
656
- indicatorPosition = ratio * (trimmerContainerBg.getWidth() - progressIndicator.getWidth()) + leadingHandle.getWidth();
657
- } else {
658
- // Calculate position relative to full duration (original logic)
659
- indicatorPosition = mDuration > 0 ? (float) currentPosition / mDuration * (trimmerContainerBg.getWidth() - progressIndicator.getWidth()) + leadingHandle.getWidth() : leadingHandle.getWidth();
660
- }
661
-
662
- // Ensure indicator stays within handle bounds using actual handle positions
663
- float leftBoundary = leadingHandle.getX() + leadingHandle.getWidth();
664
- float rightBoundary = trailingHandle.getX() - progressIndicator.getWidth();
665
-
666
- // Apply bounds checking based on actual handle positions
667
- indicatorPosition = Math.max(leftBoundary, Math.min(rightBoundary, indicatorPosition));
668
-
669
- if (currentSelectedhandle == leadingHandle) {
670
- progressIndicator.setX(Math.max(leftBoundary, indicatorPosition));
671
- } else if (currentSelectedhandle == trailingHandle) {
672
- progressIndicator.setX(Math.min(rightBoundary, indicatorPosition));
673
- } else {
674
- // Normal playback - use calculated position with handle bounds
675
- progressIndicator.setX(indicatorPosition);
676
- }
677
- }
678
- }
679
-
680
- private String formatTime(int milliseconds) {
681
- int totalSeconds = milliseconds / 1000;
682
- int minutes = totalSeconds / 60;
683
- int seconds = totalSeconds % 60;
684
- int millis = milliseconds % 1000;
685
- return String.format(Locale.getDefault(), "%d:%02d.%03d", minutes, seconds, millis);
686
- }
687
-
688
- private void setProgressIndicatorTouchListener() {
689
- trimmerContainerBg.setOnTouchListener((view, event) -> {
690
- rangeDragGestureDetector.onTouchEvent(event);
691
-
692
- switch (event.getAction()) {
693
- case MotionEvent.ACTION_DOWN:
694
- isRangeDragging = false;
695
- didClampWhilePanning = false;
696
- onMediaPause();
697
- onTrimmerContainerPanned(event);
698
- playHapticFeedback(true);
699
- break;
700
- case MotionEvent.ACTION_MOVE:
701
- if (isRangeDragging) {
702
- onRangeDrag(event);
703
- } else {
704
- onTrimmerContainerPanned(event);
705
- }
706
- break;
707
- case MotionEvent.ACTION_UP:
708
- if (isRangeDragging) {
709
- isRangeDragging = false;
710
- fadeInProgressIndicator();
711
- updateCurrentTime(true);
712
- }
713
- view.performClick();
714
- break;
715
- default:
716
- return false;
717
- }
718
- return true;
719
- });
720
- }
721
-
722
- private void onTrimmerContainerPanned(MotionEvent event) {
723
- float newX = event.getRawX();
724
- boolean didClamp = false;
725
-
726
- // Use handle positions for boundaries instead of trimmer container
727
- float leftBoundary = leadingHandle.getX() + leadingHandle.getWidth();
728
- float rightBoundary = trailingHandle.getX() - progressIndicator.getWidth();
729
-
730
- newX = Math.max(leftBoundary, newX);
731
- newX = Math.min(rightBoundary, newX);
732
-
733
- // check play haptic feedback
734
- if (newX <= leftBoundary) {
735
- didClamp = true;
736
- } else if (newX >= rightBoundary) {
737
- didClamp = true;
738
- }
739
-
740
- if (didClamp && !didClampWhilePanning) {
741
- playHapticFeedback(false);
742
- }
743
- didClampWhilePanning = didClamp;
744
-
745
- progressIndicator.setX(newX);
746
-
747
- float indicatorPosition = newX - (trimmerContainerBg.getX());
748
-
749
- // Calculate video position based on zoom state
750
- float indicatorPositionPercent;
751
- long newVideoPosition;
752
-
753
- if (isZoomedIn) {
754
- // Calculate relative to zoomed range
755
- indicatorPositionPercent = indicatorPosition / (trimmerContainerBg.getWidth() - progressIndicator.getWidth());
756
- long visibleStart = getVisibleRangeStart();
757
- long visibleDuration = getVisibleRangeDuration();
758
- newVideoPosition = visibleStart + (long) (indicatorPositionPercent * visibleDuration);
759
- } else {
760
- // Calculate relative to full duration
761
- indicatorPositionPercent = indicatorPosition / (trimmerContainerBg.getWidth() - progressIndicator.getWidth());
762
- newVideoPosition = (long) (indicatorPositionPercent * mDuration);
763
- }
764
-
765
- seekTo(newVideoPosition, false);
766
- }
767
-
768
- private void onRangeDrag(MotionEvent event) {
769
- float deltaX = event.getRawX() - rangeDragInitialRawX;
770
- float containerWidth = trimmerContainerBg.getWidth();
771
- if (containerWidth <= 0) return;
772
-
773
- long rangeDuration = rangeDragInitialEndTime - rangeDragInitialStartTime;
774
- long deltaTime;
775
- if (isZoomedIn) {
776
- deltaTime = (long) (deltaX / containerWidth * zoomedInRangeDuration);
777
- } else {
778
- deltaTime = (long) (deltaX / containerWidth * mDuration);
779
- }
780
-
781
- long newStart = rangeDragInitialStartTime + deltaTime;
782
- long newEnd = newStart + rangeDuration;
783
-
784
- boolean didClamp = false;
785
- if (newStart < 0) {
786
- newStart = 0;
787
- newEnd = rangeDuration;
788
- didClamp = true;
789
- }
790
- if (newEnd > mDuration) {
791
- newEnd = mDuration;
792
- newStart = newEnd - rangeDuration;
793
- didClamp = true;
794
- }
795
-
796
- if (didClamp && !didClampWhilePanning) {
797
- playHapticFeedback(false);
798
- }
799
- didClampWhilePanning = didClamp;
800
-
801
- startTime = newStart;
802
- endTime = newEnd;
803
- updateHandlePositions();
804
- seekTo(startTime, false);
805
- }
806
-
807
- private void setHandleTouchListener(View handle, boolean isLeading) {
808
- handle.setOnTouchListener((view, event) -> {
809
- boolean draggingDisabled = mDuration < mMinDuration; // if the video is shorter than the minimum duration, disable dragging
810
- switch (event.getAction()) {
811
- case MotionEvent.ACTION_DOWN:
812
- currentSelectedhandle = handle;
813
- didClampWhilePanning = false;
814
- onMediaPause();
815
- fadeOutProgressIndicator();
816
- seekTo(isLeading ? startTime : endTime, true);
817
- playHapticFeedback(true);
818
- isTrimmingLeading = isLeading;
819
- break;
820
- case MotionEvent.ACTION_MOVE:
821
- if (draggingDisabled) {
822
- return false;
823
- }
824
-
825
- boolean didClamp = false;
826
- float newX = event.getRawX() - ((float) view.getWidth() / 2);
827
-
828
- // Handle constraints need to consider zoom state
829
- if (isLeading) {
830
- newX = Math.max(0, Math.min(newX, trailingHandle.getX() - view.getWidth()));
831
- } else {
832
- newX = Math.min(trimmerContainerBg.getWidth() + view.getWidth(), Math.max(newX, leadingHandle.getX() + view.getWidth()));
833
- }
834
-
835
- view.setX(newX);
836
-
837
- // Calculate new startTime or endTime based on zoom state
838
- if (isLeading) {
839
- // Calculate the new startTime based on the handle's new position
840
- long newStartTime = timeForPosition(newX);
841
- // Calculate the duration between the new startTime and the current endTime
842
- long duration = endTime - newStartTime;
843
- if (duration >= mMinDuration && duration <= mMaxDuration) {
844
- // If the duration is within the allowed range, update startTime and move the progress indicator
845
- startTime = newStartTime;
846
- float indicatorX = newX + view.getWidth();
847
- // Ensure progress indicator stays within handle bounds
848
- float leftBoundary = leadingHandle.getX() + leadingHandle.getWidth();
849
- float rightBoundary = trailingHandle.getX() - progressIndicator.getWidth();
850
- progressIndicator.setX(Math.max(leftBoundary, Math.min(rightBoundary, indicatorX)));
851
- } else if (duration < mMinDuration) {
852
- didClamp = true;
853
- // If the duration is less than the minimum, calculate maximum startTime to maintain minimum duration
854
- startTime = endTime - mMinDuration;
855
-
856
- // In zoom mode, don't recalculate position - keep handle where it is but update times
857
- if (isZoomedIn) {
858
- // Keep handle at current position but clamp times
859
- float indicatorX = newX + view.getWidth();
860
- float leftBoundary = leadingHandle.getX() + leadingHandle.getWidth();
861
- float rightBoundary = trailingHandle.getX() - progressIndicator.getWidth();
862
- progressIndicator.setX(Math.max(leftBoundary, Math.min(rightBoundary, indicatorX)));
863
- } else {
864
- // Normal mode - adjust handle position
865
- view.setX(positionForTime(startTime));
866
- float indicatorX = view.getX() + view.getWidth();
867
- float leftBoundary = leadingHandle.getX() + leadingHandle.getWidth();
868
- float rightBoundary = trailingHandle.getX() - progressIndicator.getWidth();
869
- progressIndicator.setX(Math.max(leftBoundary, Math.min(rightBoundary, indicatorX)));
870
- }
871
- } else {
872
- didClamp = true;
873
- // If the duration is greater than the maximum, calculate minimum startTime to maintain maximum duration
874
- startTime = endTime - mMaxDuration;
875
-
876
- // In zoom mode, don't recalculate position - keep handle where it is but update times
877
- if (isZoomedIn) {
878
- // Keep handle at current position but clamp times
879
- float indicatorX = newX + view.getWidth();
880
- float leftBoundary = leadingHandle.getX() + leadingHandle.getWidth();
881
- float rightBoundary = trailingHandle.getX() - progressIndicator.getWidth();
882
- progressIndicator.setX(Math.max(leftBoundary, Math.min(rightBoundary, indicatorX)));
883
- } else {
884
- // Normal mode - adjust handle position
885
- view.setX(positionForTime(startTime));
886
- float indicatorX = view.getX() + view.getWidth();
887
- float leftBoundary = leadingHandle.getX() + leadingHandle.getWidth();
888
- float rightBoundary = trailingHandle.getX() - progressIndicator.getWidth();
889
- progressIndicator.setX(Math.max(leftBoundary, Math.min(rightBoundary, indicatorX)));
890
- }
891
- }
892
- } else {
893
- // Calculate the new endTime based on the handle's new position
894
- long newEndTime = timeForPosition(newX - view.getWidth());
895
- // Calculate the duration between the new endTime and the current startTime
896
- long duration = newEndTime - startTime;
897
- if (duration >= mMinDuration && duration <= mMaxDuration) {
898
- // If the duration is within the allowed range, update endTime and move the progress indicator
899
- endTime = newEndTime;
900
- float indicatorX = newX - progressIndicator.getWidth();
901
- // Ensure progress indicator stays within handle bounds
902
- float leftBoundary = leadingHandle.getX() + leadingHandle.getWidth();
903
- float rightBoundary = trailingHandle.getX() - progressIndicator.getWidth();
904
- progressIndicator.setX(Math.max(leftBoundary, Math.min(rightBoundary, indicatorX)));
905
- } else if (duration < mMinDuration) {
906
- didClamp = true;
907
- // If the duration is less than the minimum, calculate minimum endTime to maintain minimum duration
908
- endTime = startTime + mMinDuration;
909
-
910
- // In zoom mode, don't recalculate position - keep handle where it is but update times
911
- if (isZoomedIn) {
912
- // Keep handle at current position but clamp times
913
- float indicatorX = newX - progressIndicator.getWidth();
914
- float leftBoundary = leadingHandle.getX() + leadingHandle.getWidth();
915
- float rightBoundary = trailingHandle.getX() - progressIndicator.getWidth();
916
- progressIndicator.setX(Math.max(leftBoundary, Math.min(rightBoundary, indicatorX)));
917
- } else {
918
- // Normal mode - adjust handle position
919
- view.setX(positionForTime(endTime) + view.getWidth());
920
- float indicatorX = view.getX() - progressIndicator.getWidth();
921
- float leftBoundary = leadingHandle.getX() + leadingHandle.getWidth();
922
- float rightBoundary = trailingHandle.getX() - progressIndicator.getWidth();
923
- progressIndicator.setX(Math.max(leftBoundary, Math.min(rightBoundary, indicatorX)));
924
- }
925
- } else {
926
- didClamp = true;
927
- // If the duration is greater than the maximum, calculate maximum endTime to maintain maximum duration
928
- endTime = startTime + mMaxDuration;
929
-
930
- // In zoom mode, don't recalculate position - keep handle where it is but update times
931
- if (isZoomedIn) {
932
- // Keep handle at current position but clamp times
933
- float indicatorX = newX - progressIndicator.getWidth();
934
- float leftBoundary = leadingHandle.getX() + leadingHandle.getWidth();
935
- float rightBoundary = trailingHandle.getX() - progressIndicator.getWidth();
936
- progressIndicator.setX(Math.max(leftBoundary, Math.min(rightBoundary, indicatorX)));
937
- } else {
938
- // Normal mode - adjust handle position
939
- view.setX(positionForTime(endTime) + view.getWidth());
940
- float indicatorX = view.getX() - progressIndicator.getWidth();
941
- float leftBoundary = leadingHandle.getX() + leadingHandle.getWidth();
942
- float rightBoundary = trailingHandle.getX() - progressIndicator.getWidth();
943
- progressIndicator.setX(Math.max(leftBoundary, Math.min(rightBoundary, indicatorX)));
944
- }
945
- }
946
- }
947
-
948
- if (didClamp && !didClampWhilePanning) {
949
- playHapticFeedback(false);
950
- }
951
- didClampWhilePanning = didClamp;
952
-
953
- updateTrimmerContainerWidth();
954
- seekTo(isLeading ? startTime : endTime, false);
955
-
956
- // Start zoom wait timer when dragging handles
957
- startZoomWaitTimer();
958
- break;
959
- case MotionEvent.ACTION_UP:
960
- stopZoomIfNeeded();
961
- fadeInProgressIndicator();
962
- view.performClick();
963
- break;
964
- default:
965
- return false;
966
- }
967
- return true;
968
- });
969
- }
970
-
971
- private void fadeOutProgressIndicator() {
972
- progressIndicator.animate().alpha(0f).setDuration(250).withEndAction(() -> progressIndicator.setVisibility(View.INVISIBLE)).start();
973
- }
974
-
975
- private void fadeInProgressIndicator() {
976
- progressIndicator.setVisibility(View.VISIBLE);
977
- progressIndicator.animate().alpha(1f).setDuration(250).start();
978
- }
979
-
980
- private void updateTrimmerContainerWidth() {
981
- int left = (int) leadingHandle.getX() + leadingHandle.getWidth();
982
- int right = trimmerContainerBg.getWidth() - (int) trailingHandle.getX() + 2 * trailingHandle.getWidth();
983
-
984
- RelativeLayout.LayoutParams leadingOverlayParams = (RelativeLayout.LayoutParams) leadingOverlay.getLayoutParams();
985
- leadingOverlayParams.width = left;
986
- leadingOverlayParams.height = RelativeLayout.LayoutParams.MATCH_PARENT;
987
- leadingOverlayParams.addRule(RelativeLayout.ALIGN_PARENT_START);
988
- leadingOverlay.setLayoutParams(leadingOverlayParams);
989
-
990
- RelativeLayout.LayoutParams trailingOverlayParams = (RelativeLayout.LayoutParams) trailingOverlay.getLayoutParams();
991
- trailingOverlayParams.width = right;
992
- trailingOverlayParams.height = RelativeLayout.LayoutParams.MATCH_PARENT;
993
- trailingOverlayParams.addRule(RelativeLayout.ALIGN_PARENT_END);
994
- trailingOverlay.setLayoutParams(trailingOverlayParams);
995
- }
996
-
997
- private void playHapticFeedback(boolean isLight) {
998
- if (vibrator != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && enableHapticFeedback) {
999
- vibrator.vibrate(VibrationEffect.createOneShot(isLight ? 10 : 25, VibrationEffect.DEFAULT_AMPLITUDE)); // Light vibration
1000
- }
1001
- }
1002
-
1003
- private void startZoomWaitTimer() {
1004
- stopZoomWaitTimer();
1005
- if (isZoomedIn) {
1006
- return;
1007
- }
1008
-
1009
- zoomRunnable = () -> {
1010
- stopZoomWaitTimer();
1011
- zoomIfNeeded();
1012
- };
1013
-
1014
- zoomWaitTimer.postDelayed(zoomRunnable, 500);
1015
- }
1016
-
1017
- private void stopZoomWaitTimer() {
1018
- if (zoomRunnable != null) {
1019
- zoomWaitTimer.removeCallbacks(zoomRunnable);
1020
- }
1021
- }
1022
-
1023
- private void stopZoomIfNeeded() {
1024
- stopZoomWaitTimer();
1025
- if (isZoomedIn) {
1026
- // Stop any ongoing thumbnail generation immediately
1027
- isGeneratingThumbnails = false;
1028
-
1029
- // Cancel any ongoing background tasks for thumbnail generation
1030
- BackgroundExecutor.cancelAll("progressive_thumbs", true);
1031
-
1032
- isZoomedIn = false;
1033
-
1034
- // Immediately restore cached thumbnails without waiting for animation
1035
- restoreCachedThumbnails();
1036
-
1037
- // Then apply smooth transition animation
1038
- animateZoomTransition(() -> {
1039
- updateHandlePositions();
1040
- // Force update progress indicator position after exiting zoom
1041
- updateCurrentTime(true);
1042
- });
1043
- }
1044
- }
1045
-
1046
- private void zoomIfNeeded() {
1047
- if (isZoomedIn) {
1048
- return;
1049
- }
1050
-
1051
- // Store current handle positions to maintain visual continuity
1052
- float currentLeadingX = leadingHandle.getX();
1053
- float currentTrailingX = trailingHandle.getX();
1054
-
1055
- // Use configurable zoom duration, but ensure it's reasonable for the video
1056
- long newDuration = Math.min(zoomOnWaitingDuration, mDuration);
1057
-
1058
- // For very short videos, use a smaller zoom range
1059
- if (mDuration < 2000) {
1060
- newDuration = Math.max(500, mDuration / 2); // At least 0.5 seconds for very short videos
1061
- } else if (mDuration < zoomOnWaitingDuration) {
1062
- newDuration = Math.max(1000, mDuration / 2); // Use half duration for short videos
1063
- }
1064
-
1065
- // Ensure zoom duration doesn't exceed video duration
1066
- newDuration = Math.min(newDuration, mDuration);
1067
-
1068
- long rangeStart;
1069
- if (isTrimmingLeading) {
1070
- // Zoom around the start time, but ensure we don't go before video start
1071
- rangeStart = Math.max(0, startTime - (newDuration / 2));
1072
- // If range would extend past video end, adjust start
1073
- if (rangeStart + newDuration > mDuration) {
1074
- rangeStart = Math.max(0, mDuration - newDuration);
1075
- }
1076
- } else {
1077
- // Zoom around the end time
1078
- rangeStart = Math.max(0, endTime - (newDuration / 2));
1079
- // If range would extend past video end, adjust start
1080
- if (rangeStart + newDuration > mDuration) {
1081
- rangeStart = Math.max(0, mDuration - newDuration);
1082
- }
1083
- }
1084
-
1085
- // Final bounds check
1086
- zoomedInRangeStart = Math.max(0, rangeStart);
1087
- zoomedInRangeDuration = Math.min(newDuration, mDuration - zoomedInRangeStart);
1088
-
1089
- isZoomedIn = true;
1090
-
1091
- // Start progressive thumbnail generation immediately
1092
- startProgressiveThumbnailGeneration();
1093
-
1094
- // Update handle positions immediately without delay
1095
- updateHandlePositionsForZoom(currentLeadingX, currentTrailingX);
1096
-
1097
- // Provide haptic feedback
1098
- playHapticFeedback(true);
1099
- }
1100
-
1101
- private void updateHandlePositionsForZoom(float previousLeadingX, float previousTrailingX) {
1102
- // During zoom, we want to keep handles at their current visual positions
1103
- // Don't recalculate based on zoom range - this causes jumping
1104
-
1105
- Log.d(TAG, "Maintaining handle positions during zoom - Leading: " + previousLeadingX + ", Trailing: " + previousTrailingX);
1106
-
1107
- // Keep handles exactly where they were visually
1108
- leadingHandle.setX(previousLeadingX);
1109
- trailingHandle.setX(previousTrailingX);
1110
-
1111
- // Don't update times here - let the individual handle drag logic handle that
1112
- // This prevents unwanted changes to startTime/endTime during zoom transition
1113
-
1114
- // Update trimmer container width based on current handle positions
1115
- updateTrimmerContainerWidth();
1116
-
1117
- // Ensure progress indicator is positioned correctly within the handle bounds
1118
- float leftBoundary = leadingHandle.getX() + leadingHandle.getWidth();
1119
- float rightBoundary = trailingHandle.getX() - progressIndicator.getWidth();
1120
- float currentX = progressIndicator.getX();
1121
-
1122
- // If progress indicator is out of bounds, position it properly
1123
- if (currentX < leftBoundary || currentX > rightBoundary) {
1124
- // Position it based on the current media position
1125
- updateCurrentTime(true);
1126
- } else {
1127
- updateCurrentTime(false);
1128
- }
1129
-
1130
- trimmerContainerWrapper.setVisibility(View.VISIBLE);
1131
- if (trimmerContainerWrapper.getAlpha() == 0f) {
1132
- trimmerContainerWrapper.animate().alpha(1f).setDuration(250).start();
1133
- }
1134
- }
1135
-
1136
- private void startProgressiveThumbnailGeneration() {
1137
- if (isGeneratingThumbnails || mediaMetadataRetriever == null) {
1138
- return;
1139
- }
1140
-
1141
- isGeneratingThumbnails = true;
1142
-
1143
- // Immediately create placeholder thumbnails with subtle animation
1144
- UiThreadExecutor.runTask("", () -> {
1145
- mThumbnailContainer.removeAllViews();
1146
-
1147
- // Calculate proper number of thumbnails based on container width
1148
- final int thumbnailWidth = VideoTrimmerUtil.VIDEO_FRAMES_WIDTH / VideoTrimmerUtil.MAX_COUNT_RANGE;
1149
- final int numberOfThumbnails = Math.max(8, mThumbnailContainer.getWidth() / thumbnailWidth);
1150
-
1151
- // Create placeholder thumbnails first
1152
- for (int i = 0; i < numberOfThumbnails; i++) {
1153
- ImageView placeholder = new ImageView(getContext());
1154
- LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(thumbnailWidth, LinearLayout.LayoutParams.MATCH_PARENT);
1155
- placeholder.setLayoutParams(layoutParams);
1156
- placeholder.setBackgroundColor(Color.parseColor("#F0F0F0")); // Light gray placeholder
1157
- placeholder.setAlpha(0.2f);
1158
- mThumbnailContainer.addView(placeholder);
1159
- }
1160
- }, 0);
1161
-
1162
- // Start background thumbnail generation
1163
- BackgroundExecutor.execute(new BackgroundExecutor.Task("progressive_thumbs", 0L, "") {
1164
- @Override
1165
- public void execute() {
1166
- try {
1167
- final int thumbnailWidth = VideoTrimmerUtil.VIDEO_FRAMES_WIDTH / VideoTrimmerUtil.MAX_COUNT_RANGE;
1168
- final int numberOfThumbnails = Math.max(8, mThumbnailContainer.getWidth() / thumbnailWidth);
1169
- final long visibleDuration = isZoomedIn ? zoomedInRangeDuration : mDuration;
1170
- final long visibleStart = isZoomedIn ? zoomedInRangeStart : 0;
1171
- final long interval = visibleDuration > 0 ? visibleDuration / numberOfThumbnails : 0;
1172
-
1173
- // Generate thumbnails progressively
1174
- for (int i = 0; i < numberOfThumbnails && isGeneratingThumbnails && isZoomedIn; i++) {
1175
- // Check if we should continue generating
1176
- if (!isGeneratingThumbnails || !isZoomedIn) {
1177
- Log.d(TAG, "Thumbnail generation cancelled at index " + i);
1178
- return;
1179
- }
1180
-
1181
- final int index = i;
1182
- final long timeUs = (visibleStart + (i * interval)) * 1000; // Convert to microseconds
1183
- final long clampedTimeUs = Math.max(0, Math.min(timeUs, mDuration * 1000L));
1184
-
1185
- try {
1186
- Bitmap bitmap = mediaMetadataRetriever.getFrameAtTime(clampedTimeUs, MediaMetadataRetriever.OPTION_CLOSEST_SYNC);
1187
- if (bitmap != null && isGeneratingThumbnails && isZoomedIn) {
1188
- // Update UI immediately for each thumbnail
1189
- UiThreadExecutor.runTask("", () -> {
1190
- // Double-check zoom state before updating UI
1191
- if (isZoomedIn && index < mThumbnailContainer.getChildCount()) {
1192
- ImageView thumbnailView = (ImageView) mThumbnailContainer.getChildAt(index);
1193
- if (thumbnailView != null) {
1194
- thumbnailView.setImageBitmap(bitmap);
1195
- thumbnailView.setScaleType(ImageView.ScaleType.CENTER_CROP);
1196
- thumbnailView.setBackground(null); // Remove placeholder background
1197
-
1198
- // Smooth fade-in animation for each thumbnail
1199
- thumbnailView.animate()
1200
- .alpha(1.0f)
1201
- .setDuration(150)
1202
- .setStartDelay(index * 50L) // Stagger animations
1203
- .start();
1204
- }
1205
- }
1206
- }, 0);
1207
-
1208
- // Small delay between generations to avoid blocking
1209
- Thread.sleep(10);
1210
- }
1211
- } catch (Exception e) {
1212
- Log.w(TAG, "Error generating progressive thumbnail at " + clampedTimeUs, e);
1213
- }
1214
- }
1215
-
1216
- // Only finalize if we're still in zoom mode
1217
- if (isGeneratingThumbnails && isZoomedIn) {
1218
- isGeneratingThumbnails = false;
1219
- } else {
1220
- isGeneratingThumbnails = false;
1221
- }
1222
-
1223
- } catch (Exception e) {
1224
- Log.e(TAG, "Error in progressive thumbnail generation", e);
1225
- isGeneratingThumbnails = false;
1226
- }
1227
- }
1228
- });
1229
- }
1230
-
1231
- private void animateZoomTransition(Runnable onComplete) {
1232
- // Only animate if we're still transitioning
1233
- if (mThumbnailContainer != null) {
1234
- mThumbnailContainer.animate()
1235
- .alpha(0.7f)
1236
- .setDuration(200) // Shorter duration for better responsiveness
1237
- .withEndAction(() -> {
1238
- if (onComplete != null) {
1239
- onComplete.run();
1240
- }
1241
- if (mThumbnailContainer != null) {
1242
- mThumbnailContainer.animate()
1243
- .alpha(1.0f)
1244
- .setDuration(200)
1245
- .start();
1246
- }
1247
- })
1248
- .start();
1249
- } else if (onComplete != null) {
1250
- onComplete.run();
1251
- }
1252
- }
1253
-
1254
- private void ignoreSystemGestureForView(View v) {
1255
- // Android 10+
1256
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
1257
- // 1. setSystemGestureExclusionRects on a rect which is entire screen size
1258
- // 2. if we use the bound of the view, it's not smooth and still sometimes can trigger system gesture even our finger is within the view bound
1259
- // 3. even though here the bound is set to screen size, it doesn't mean the entire screen is excluded from system gesture, it only means the area within the view bound is excluded
1260
- v.setSystemGestureExclusionRects(
1261
- java.util.List.of(
1262
- new android.graphics.Rect(
1263
- 0,
1264
- 0,
1265
- DeviceUtil.getDeviceWidth(),
1266
- DeviceUtil.getDeviceHeight()
1267
- )
1268
- )
1269
- );
1270
- }
1271
- }
1272
-
1273
- // Helper methods for position/time conversion considering zoom state
1274
- private long timeForPosition(float position) {
1275
- if (trimmerContainerBg.getWidth() <= 0) return 0;
1276
-
1277
- if (isZoomedIn) {
1278
- // Convert position to time within zoomed range
1279
- float ratio = position / trimmerContainerBg.getWidth();
1280
- return zoomedInRangeStart + (long) (ratio * zoomedInRangeDuration);
1281
- } else {
1282
- // Convert position to time within full duration
1283
- return (long) ((position / trimmerContainerBg.getWidth()) * mDuration);
1284
- }
1285
- }
1286
-
1287
- private float positionForTime(long time) {
1288
- if (isZoomedIn) {
1289
- // Convert time to position within zoomed range
1290
- if (zoomedInRangeDuration <= 0) return 0;
1291
- float ratio = (float) (time - zoomedInRangeStart) / zoomedInRangeDuration;
1292
- return Math.max(0, Math.min(trimmerContainerBg.getWidth(), ratio * trimmerContainerBg.getWidth()));
1293
- } else {
1294
- // Convert time to position within full duration
1295
- if (mDuration <= 0) return 0;
1296
- return Math.max(0, Math.min(trimmerContainerBg.getWidth(), ((float) time / mDuration) * trimmerContainerBg.getWidth()));
1297
- }
1298
- }
1299
-
1300
- private long getVisibleRangeStart() {
1301
- return isZoomedIn ? zoomedInRangeStart : 0;
1302
- }
1303
-
1304
- private long getVisibleRangeDuration() {
1305
- return isZoomedIn ? zoomedInRangeDuration : mDuration;
1306
- }
1307
-
1308
- private void restoreCachedThumbnails() {
1309
- // Clear current thumbnails
1310
- mThumbnailContainer.removeAllViews();
1311
-
1312
- // Restore cached thumbnails efficiently
1313
- for (ImageView cachedThumbnail : cachedFullViewThumbnails) {
1314
- // Create a new ImageView with the same bitmap to avoid view reuse issues
1315
- ImageView restoredView = new ImageView(getContext());
1316
- restoredView.setImageBitmap(((android.graphics.drawable.BitmapDrawable) cachedThumbnail.getDrawable()).getBitmap());
1317
- restoredView.setScaleType(ImageView.ScaleType.CENTER_CROP);
1318
- restoredView.setLayoutParams(cachedThumbnail.getLayoutParams());
1319
- mThumbnailContainer.addView(restoredView);
1320
- }
1321
- }
1322
- }