1 /* 2 3 * Copyright (C) 2011 The Android Open Source Project 4 * 5 * Licensed under the Apache License, Version 2.0 (the "License"); 6 * you may not use this file except in compliance with the License. 7 * You may obtain a copy of the License at 8 * 9 * http://www.apache.org/licenses/LICENSE-2.0 10 * 11 * Unless required by applicable law or agreed to in writing, software 12 * distributed under the License is distributed on an "AS IS" BASIS, 13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 * See the License for the specific language governing permissions and 15 * limitations under the License. 16 */ 17 18 package com.android.videoeditor.widgets; 19 20 import com.android.videoeditor.service.ApiService; 21 import com.android.videoeditor.service.MovieMediaItem; 22 import com.android.videoeditor.R; 23 24 import android.content.Context; 25 import android.graphics.Bitmap; 26 import android.graphics.Canvas; 27 import android.graphics.Color; 28 import android.graphics.Paint; 29 import android.graphics.Point; 30 import android.graphics.Rect; 31 import android.graphics.drawable.Drawable; 32 import android.util.AttributeSet; 33 import android.util.DisplayMetrics; 34 import android.util.Log; 35 import android.util.LruCache; 36 import android.view.Display; 37 import android.view.GestureDetector; 38 import android.view.MotionEvent; 39 import android.view.View; 40 import android.view.WindowManager; 41 42 import java.util.ArrayList; 43 import java.util.HashSet; 44 import java.util.Map; 45 46 /** 47 * Media item preview view on the timeline. This class assumes the media item is always put on a 48 * MediaLinearLayout and is wrapped with a timeline scroll view. 49 */ 50 public class MediaItemView extends View { 51 private static final String TAG = "MediaItemView"; 52 53 // Static variables 54 private static Drawable sAddTransitionDrawable; 55 private static Drawable sEmptyFrameDrawable; 56 private static ThumbnailCache sThumbnailCache; 57 58 // Because MediaItemView may be recreated for the same MediaItem (it happens 59 // when the device orientation is changed), we use a globally unique 60 // generation counter to reject thumbnail results (passed to setBitmap()) 61 // requested by a previous incarnation of MediaItemView. 62 private static int sGenerationCounter; 63 64 // Instance variables 65 private final GestureDetector mGestureDetector; 66 private final ScrollViewListener mScrollListener; 67 private final Rect mGeneratingEffectProgressDestRect; 68 69 private boolean mIsScrolling; 70 private boolean mIsPlaying; 71 72 // Progress of generation of the effect applied on this media item view. 73 // -1 indicates the generation is not in progress. 0-100 indicates the 74 // generation is in progress. Currently only Ken Burns effect is used with 75 // the progress bar. 76 private int mGeneratingEffectProgress; 77 78 // The scrolled left pixels of this view. 79 private int mScrollX; 80 81 private String mProjectPath; 82 private MovieMediaItem mMediaItem; 83 // Convenient handle to the parent timeline scroll view. 84 private TimelineHorizontalScrollView mScrollView; 85 // Convenient handle to the parent timeline linear layout. 86 private MediaLinearLayout mTimeline; 87 private ItemSimpleGestureListener mGestureListener; 88 private int[] mLeftState, mRightState; 89 90 private int mScreenWidth; 91 private int mThumbnailWidth, mThumbnailHeight; 92 private int mNumberOfThumbnails; 93 private long mBeginTimeMs, mEndTimeMs; 94 95 private int mGeneration; 96 private HashSet<Integer> mPending; 97 private ArrayList<Integer> mWantThumbnails; 98 99 public MediaItemView(Context context, AttributeSet attrs) { 100 this(context, attrs, 0); 101 } 102 103 public MediaItemView(Context context) { 104 this(context, null, 0); 105 } 106 107 public MediaItemView(Context context, AttributeSet attrs, int defStyle) { 108 super(context, attrs, defStyle); 109 110 // Initialize static data 111 if (sAddTransitionDrawable == null) { 112 sAddTransitionDrawable = getResources().getDrawable( 113 R.drawable.add_transition_selector); 114 sEmptyFrameDrawable = getResources().getDrawable( 115 R.drawable.timeline_loading); 116 117 // Initialize the thumbnail cache, limit the memory usage to 3MB 118 sThumbnailCache = new ThumbnailCache(3*1024*1024); 119 } 120 121 // Get the screen width 122 final Display display = ((WindowManager)context.getSystemService( 123 Context.WINDOW_SERVICE)).getDefaultDisplay(); 124 final DisplayMetrics metrics = new DisplayMetrics(); 125 display.getMetrics(metrics); 126 mScreenWidth = metrics.widthPixels; 127 128 // Setup our gesture detector and scroll listener 129 mGestureDetector = new GestureDetector(context, new MyGestureListener()); 130 mScrollListener = new MyScrollViewListener(); 131 132 // Prepare the progress bar rectangles 133 final ProgressBar progressBar = ProgressBar.getProgressBar(context); 134 final int layoutHeight = (int)( 135 getResources().getDimension(R.dimen.media_layout_height) - 136 getResources().getDimension(R.dimen.media_layout_padding)); 137 mGeneratingEffectProgressDestRect = new Rect(getPaddingLeft(), 138 layoutHeight - progressBar.getHeight() - getPaddingBottom(), 0, 139 layoutHeight - getPaddingBottom()); 140 141 // Initialize the progress value 142 mGeneratingEffectProgress = -1; 143 144 // Initialize the "Add transition" indicators state 145 mLeftState = View.EMPTY_STATE_SET; 146 mRightState = View.EMPTY_STATE_SET; 147 148 // Initialize the thumbnail indices we want to request 149 mWantThumbnails = new ArrayList<Integer>(); 150 151 // Initialize the set of indices we are waiting 152 mPending = new HashSet<Integer>(); 153 154 // Initialize the generation number 155 mGeneration = sGenerationCounter++; 156 } 157 158 private class MyGestureListener extends GestureDetector.SimpleOnGestureListener { 159 @Override 160 public boolean onSingleTapConfirmed(MotionEvent e) { 161 if (mGestureListener == null) { 162 return false; 163 } 164 165 int tappedArea = ItemSimpleGestureListener.CENTER_AREA; 166 167 if (hasSpaceForAddTransitionIcons()) { 168 if (mMediaItem.getBeginTransition() == null && 169 e.getX() < sAddTransitionDrawable.getIntrinsicWidth() + 170 getPaddingLeft()) { 171 tappedArea = ItemSimpleGestureListener.LEFT_AREA; 172 } else if (mMediaItem.getEndTransition() == null && 173 e.getX() >= getWidth() - getPaddingRight() - 174 sAddTransitionDrawable.getIntrinsicWidth()) { 175 tappedArea = ItemSimpleGestureListener.RIGHT_AREA; 176 } 177 } 178 return mGestureListener.onSingleTapConfirmed( 179 MediaItemView.this, tappedArea, e); 180 } 181 182 @Override 183 public void onLongPress(MotionEvent e) { 184 if (mGestureListener != null) { 185 mGestureListener.onLongPress(MediaItemView.this, e); 186 } 187 } 188 } 189 190 private class MyScrollViewListener implements ScrollViewListener { 191 @Override 192 public void onScrollBegin(View view, int scrollX, int scrollY, boolean appScroll) { 193 mIsScrolling = true; 194 } 195 196 @Override 197 public void onScrollProgress(View view, int scrollX, int scrollY, boolean appScroll) { 198 mScrollX = scrollX; 199 invalidate(); 200 } 201 202 @Override 203 public void onScrollEnd(View view, int scrollX, int scrollY, boolean appScroll) { 204 mIsScrolling = false; 205 mScrollX = scrollX; 206 invalidate(); 207 } 208 } 209 210 @Override 211 protected void onAttachedToWindow() { 212 mMediaItem = (MovieMediaItem) getTag(); 213 214 mScrollView = (TimelineHorizontalScrollView) getRootView().findViewById( 215 R.id.timeline_scroller); 216 mScrollView.addScrollListener(mScrollListener); 217 // Add the horizontal scroll view listener 218 mScrollX = mScrollView.getScrollX(); 219 220 mTimeline = (MediaLinearLayout) getRootView().findViewById(R.id.timeline_media); 221 } 222 223 @Override 224 protected void onDetachedFromWindow() { 225 mScrollView.removeScrollListener(mScrollListener); 226 // Release the cached bitmaps 227 releaseBitmapsAndClear(); 228 } 229 230 /** 231 * @return The shadow builder 232 */ 233 public DragShadowBuilder getShadowBuilder() { 234 return new MediaItemShadowBuilder(this); 235 } 236 237 /** 238 * Shadow builder for the media item 239 */ 240 private class MediaItemShadowBuilder extends DragShadowBuilder { 241 private final Drawable mFrame; 242 243 public MediaItemShadowBuilder(View view) { 244 super(view); 245 mFrame = view.getContext().getResources().getDrawable( 246 R.drawable.timeline_item_pressed); 247 } 248 249 @Override 250 public void onProvideShadowMetrics(Point shadowSize, Point shadowTouchPoint) { 251 shadowSize.set(getShadowWidth(), getShadowHeight()); 252 shadowTouchPoint.set(shadowSize.x / 2, shadowSize.y); 253 } 254 255 @Override 256 public void onDrawShadow(Canvas canvas) { 257 mFrame.setBounds(0, 0, getShadowWidth(), getShadowHeight()); 258 mFrame.draw(canvas); 259 260 Bitmap bitmap = getOneThumbnail(); 261 if (bitmap != null) { 262 final View view = getView(); 263 canvas.drawBitmap(bitmap, view.getPaddingLeft(), 264 view.getPaddingTop(), null); 265 } 266 } 267 } 268 269 /** 270 * @return The shadow width 271 */ 272 private int getShadowWidth() { 273 final int thumbnailHeight = getHeight() - getPaddingTop() - getPaddingBottom(); 274 final int thumbnailWidth = (thumbnailHeight * mMediaItem.getWidth()) / 275 mMediaItem.getHeight(); 276 return thumbnailWidth + getPaddingLeft() + getPaddingRight(); 277 } 278 279 /** 280 * @return The shadow height 281 */ 282 private int getShadowHeight() { 283 return getHeight(); 284 } 285 286 private Bitmap getOneThumbnail() { 287 ThumbnailKey key = new ThumbnailKey(); 288 key.mediaItemId = mMediaItem.getId(); 289 290 // Find any one cached thumbnail 291 for (int i = 0; i < mNumberOfThumbnails; i++) { 292 key.index = i; 293 Bitmap bitmap = sThumbnailCache.get(key); 294 if (bitmap != null) { 295 return bitmap; 296 } 297 } 298 299 return null; 300 } 301 302 /** 303 * @param projectPath The project path 304 */ 305 public void setProjectPath(String projectPath) { 306 mProjectPath = projectPath; 307 } 308 309 /** 310 * @param listener The gesture listener 311 */ 312 public void setGestureListener(ItemSimpleGestureListener listener) { 313 mGestureListener = listener; 314 } 315 316 /** 317 * A view enters or exits the playback mode 318 * 319 * @param playback true if playback is in progress 320 */ 321 public void setPlaybackMode(boolean playback) { 322 mIsPlaying = playback; 323 invalidate(); 324 } 325 326 /** 327 * Resets the effect generation progress status. 328 */ 329 public void resetGeneratingEffectProgress() { 330 setGeneratingEffectProgress(-1); 331 } 332 333 /** 334 * Sets the effect generation progress of this view. 335 */ 336 public void setGeneratingEffectProgress(int progress) { 337 if (progress == 0) { 338 mGeneratingEffectProgress = progress; 339 // Release the current set of bitmaps. New content is being generated. 340 releaseBitmapsAndClear(); 341 } else if (progress == 100) { 342 mGeneratingEffectProgress = -1; 343 } else { 344 mGeneratingEffectProgress = progress; 345 } 346 347 invalidate(); 348 } 349 350 /** 351 * The view has been layout out. 352 * 353 * @param oldLeft The old left position 354 * @param oldRight The old right position 355 */ 356 public void onLayoutPerformed(int oldLeft, int oldRight) { 357 // Compute the thumbnail width and height 358 mThumbnailHeight = getHeight() - getPaddingTop() - getPaddingBottom(); 359 mThumbnailWidth = (mThumbnailHeight * mMediaItem.getWidth()) / mMediaItem.getHeight(); 360 361 // We are not able to display a bitmap with width or height > 2048. 362 while (mThumbnailWidth > 2048 || mThumbnailHeight > 2048) { 363 mThumbnailHeight /= 2; 364 mThumbnailWidth /= 2; 365 } 366 367 int usableWidth = getWidth() - getPaddingLeft() - getPaddingRight(); 368 // Compute the ceiling of (usableWidth / mThumbnailWidth). 369 mNumberOfThumbnails = (usableWidth + mThumbnailWidth - 1) / mThumbnailWidth; 370 mBeginTimeMs = mMediaItem.getAppBoundaryBeginTime(); 371 mEndTimeMs = mMediaItem.getAppBoundaryEndTime(); 372 373 releaseBitmapsAndClear(); 374 invalidate(); 375 } 376 377 /** 378 * @return True if the effect generation is in progress 379 */ 380 public boolean isGeneratingEffect() { 381 return (mGeneratingEffectProgress >= 0); 382 } 383 384 public boolean setBitmap(Bitmap bitmap, int index, int token) { 385 // Ignore results from previous requests 386 if (token != mGeneration) { 387 return false; 388 } 389 if (!mPending.contains(index)) { 390 Log.e(TAG, "received unasked bitmap, index = " + index); 391 return false; 392 } 393 if (bitmap == null) { 394 Log.w(TAG, "receive null bitmap for index = " + index); 395 // We keep this request in mPending, so we won't request it again. 396 return false; 397 } 398 mPending.remove(index); 399 ThumbnailKey key = new ThumbnailKey(mMediaItem.getId(), index); 400 sThumbnailCache.put(key, bitmap); 401 402 invalidate(); 403 return true; 404 } 405 406 @Override 407 protected void onDraw(Canvas canvas) { 408 super.onDraw(canvas); 409 if (mGeneratingEffectProgress >= 0) { 410 ProgressBar.getProgressBar(getContext()).draw( 411 canvas, mGeneratingEffectProgress, mGeneratingEffectProgressDestRect, 412 getPaddingLeft(), getWidth() - getPaddingRight()); 413 } else { 414 // Do not draw in the padding area 415 canvas.clipRect(getPaddingLeft(), getPaddingTop(), 416 getWidth() - getPaddingRight(), 417 getHeight() - getPaddingBottom()); 418 419 // Draw thumbnails 420 drawThumbnails(canvas); 421 422 // Draw the "Add transition" indicators 423 if (isSelected()) { 424 drawAddTransitionIcons(canvas); 425 } else if (mTimeline.hasItemSelected()) { 426 // Dim myself if some view on the timeline is selected but not me 427 // by drawing a transparent black overlay. 428 final Paint paint = new Paint(); 429 paint.setColor(Color.BLACK); 430 paint.setAlpha(192); 431 canvas.drawPaint(paint); 432 } 433 434 // Request thumbnails if things are not moving 435 boolean isBusy = mIsPlaying || mTimeline.isTrimming() || mIsScrolling; 436 if (!isBusy && !mWantThumbnails.isEmpty()) { 437 requestThumbnails(); 438 } 439 } 440 } 441 442 // Draws the thumbnails, also put unavailable thumbnail indices in 443 // mWantThumbnails. 444 private void drawThumbnails(Canvas canvas) { 445 mWantThumbnails.clear(); 446 447 // The screen coordinate of the left edge of the usable area. 448 int left = getLeft() + getPaddingLeft() - mScrollX; 449 // The screen coordinate of the right edge of the usable area. 450 int right = getRight() - getPaddingRight() - mScrollX; 451 // Return if the usable area is not on screen. 452 if (left >= mScreenWidth || right <= 0 || left >= right) { 453 return; 454 } 455 456 // Map [0, mScreenWidth - 1] to the indices of the thumbnail. 457 int startIdx = (0 - left) / mThumbnailWidth; 458 int endIdx = (mScreenWidth - 1 - left) / mThumbnailWidth; 459 460 startIdx = clamp(startIdx, 0, mNumberOfThumbnails - 1); 461 endIdx = clamp(endIdx, 0, mNumberOfThumbnails - 1); 462 463 // Prepare variables used in the loop 464 ThumbnailKey key = new ThumbnailKey(); 465 key.mediaItemId = mMediaItem.getId(); 466 int x = getPaddingLeft() + startIdx * mThumbnailWidth; 467 int y = getPaddingTop(); 468 469 // Center the thumbnail vertically 470 int spacing = (getHeight() - getPaddingTop() - getPaddingBottom() - 471 mThumbnailHeight) / 2; 472 y += spacing; 473 474 // Loop through the thumbnails on screen and draw it 475 for (int i = startIdx; i <= endIdx; i++) { 476 key.index = i; 477 Bitmap bitmap = sThumbnailCache.get(key); 478 if (bitmap == null) { 479 // Draw a frame placeholder 480 sEmptyFrameDrawable.setBounds( 481 x, y, x + mThumbnailWidth, y + mThumbnailHeight); 482 sEmptyFrameDrawable.draw(canvas); 483 if (!mPending.contains(i)) { 484 mWantThumbnails.add(Integer.valueOf(i)); 485 } 486 } else { 487 canvas.drawBitmap(bitmap, x, y, null); 488 } 489 x += mThumbnailWidth; 490 } 491 } 492 493 /** 494 * Draws the "Add transition" icons at the beginning and end of the media item. 495 * 496 * @param canvas Canvas to be drawn 497 */ 498 private void drawAddTransitionIcons(Canvas canvas) { 499 if (hasSpaceForAddTransitionIcons()) { 500 if (mMediaItem.getBeginTransition() == null) { 501 sAddTransitionDrawable.setState(mLeftState); 502 sAddTransitionDrawable.setBounds(getPaddingLeft(), getPaddingTop(), 503 sAddTransitionDrawable.getIntrinsicWidth() + getPaddingLeft(), 504 getPaddingTop() + sAddTransitionDrawable.getIntrinsicHeight()); 505 sAddTransitionDrawable.draw(canvas); 506 } 507 508 if (mMediaItem.getEndTransition() == null) { 509 sAddTransitionDrawable.setState(mRightState); 510 sAddTransitionDrawable.setBounds( 511 getWidth() - getPaddingRight() - 512 sAddTransitionDrawable.getIntrinsicWidth(), 513 getPaddingTop(), getWidth() - getPaddingRight(), 514 getPaddingTop() + sAddTransitionDrawable.getIntrinsicHeight()); 515 sAddTransitionDrawable.draw(canvas); 516 } 517 } 518 } 519 520 /** 521 * @return true if the visible area of this view is big enough to display 522 * "add transition" icons on both sides; false otherwise. 523 */ 524 private boolean hasSpaceForAddTransitionIcons() { 525 if (mTimeline.isTrimming()) { 526 return false; 527 } 528 529 return (getWidth() - getPaddingLeft() - getPaddingRight() >= 530 2 * sAddTransitionDrawable.getIntrinsicWidth()); 531 } 532 533 /** 534 * Clamps the input value v to the range [low, high]. 535 */ 536 private static int clamp(int v, int low, int high) { 537 return Math.min(Math.max(v, low), high); 538 } 539 540 /** 541 * Requests the thumbnails in mWantThumbnails (which is filled by onDraw). 542 */ 543 private void requestThumbnails() { 544 // Copy mWantThumbnails to an array 545 int indices[] = new int[mWantThumbnails.size()]; 546 for (int i = 0; i < mWantThumbnails.size(); i++) { 547 indices[i] = mWantThumbnails.get(i); 548 } 549 550 // Put them in the pending set 551 mPending.addAll(mWantThumbnails); 552 553 ApiService.getMediaItemThumbnails(getContext(), mProjectPath, 554 mMediaItem.getId(), mThumbnailWidth, mThumbnailHeight, 555 mBeginTimeMs, mEndTimeMs, mNumberOfThumbnails, mGeneration, 556 indices); 557 } 558 559 @Override 560 public boolean onTouchEvent(MotionEvent ev) { 561 // Let the gesture detector inspect all events. 562 mGestureDetector.onTouchEvent(ev); 563 super.onTouchEvent(ev); 564 565 switch (ev.getAction()) { 566 case MotionEvent.ACTION_DOWN: { 567 mLeftState = View.EMPTY_STATE_SET; 568 mRightState = View.EMPTY_STATE_SET; 569 if (isSelected() && hasSpaceForAddTransitionIcons()) { 570 if (ev.getX() < sAddTransitionDrawable.getIntrinsicWidth() + 571 getPaddingLeft()) { 572 if (mMediaItem.getBeginTransition() == null) { 573 mLeftState = View.PRESSED_WINDOW_FOCUSED_STATE_SET; 574 } 575 } else if (ev.getX() >= getWidth() - getPaddingRight() - 576 sAddTransitionDrawable.getIntrinsicWidth()) { 577 if (mMediaItem.getEndTransition() == null) { 578 mRightState = View.PRESSED_WINDOW_FOCUSED_STATE_SET; 579 } 580 } 581 } 582 invalidate(); 583 break; 584 } 585 586 case MotionEvent.ACTION_UP: 587 case MotionEvent.ACTION_CANCEL: { 588 mRightState = View.EMPTY_STATE_SET; 589 mLeftState = View.EMPTY_STATE_SET; 590 invalidate(); 591 break; 592 } 593 594 default: { 595 break; 596 } 597 } 598 599 return true; 600 } 601 602 private void releaseBitmapsAndClear() { 603 sThumbnailCache.clearForMediaItemId(mMediaItem.getId()); 604 mPending.clear(); 605 mGeneration = sGenerationCounter++; 606 } 607 } 608 609 class ThumbnailKey { 610 public String mediaItemId; 611 public int index; 612 613 public ThumbnailKey() { 614 } 615 616 public ThumbnailKey(String id, int idx) { 617 mediaItemId = id; 618 index = idx; 619 } 620 621 @Override 622 public boolean equals(Object o) { 623 if (!(o instanceof ThumbnailKey)) { 624 return false; 625 } 626 ThumbnailKey key = (ThumbnailKey) o; 627 return index == key.index && mediaItemId.equals(key.mediaItemId); 628 } 629 630 @Override 631 public int hashCode() { 632 return mediaItemId.hashCode() ^ index; 633 } 634 } 635 636 class ThumbnailCache { 637 private LruCache<ThumbnailKey, Bitmap> mCache; 638 639 public ThumbnailCache(int size) { 640 mCache = new LruCache<ThumbnailKey, Bitmap>(size) { 641 @Override 642 protected int sizeOf(ThumbnailKey key, Bitmap value) { 643 return value.getByteCount(); 644 } 645 }; 646 } 647 648 void put(ThumbnailKey key, Bitmap value) { 649 mCache.put(key, value); 650 } 651 652 Bitmap get(ThumbnailKey key) { 653 return mCache.get(key); 654 } 655 656 void clearForMediaItemId(String id) { 657 Map<ThumbnailKey, Bitmap> map = mCache.snapshot(); 658 for (ThumbnailKey key : map.keySet()) { 659 if (key.mediaItemId.equals(id)) { 660 mCache.remove(key); 661 } 662 } 663 } 664 } 665