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