1 /* 2 * Copyright (C) 2017 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.google.android.setupdesign.view; 18 19 import android.annotation.TargetApi; 20 import android.content.Context; 21 import android.content.res.TypedArray; 22 import android.graphics.SurfaceTexture; 23 import android.graphics.drawable.Animatable; 24 import android.media.MediaPlayer; 25 import android.media.MediaPlayer.OnErrorListener; 26 import android.media.MediaPlayer.OnInfoListener; 27 import android.media.MediaPlayer.OnPreparedListener; 28 import android.media.MediaPlayer.OnSeekCompleteListener; 29 import android.net.Uri; 30 import android.os.Build.VERSION_CODES; 31 import android.util.AttributeSet; 32 import android.util.Log; 33 import android.view.Surface; 34 import android.view.TextureView; 35 import android.view.TextureView.SurfaceTextureListener; 36 import android.view.View; 37 import androidx.annotation.Nullable; 38 import androidx.annotation.RawRes; 39 import androidx.annotation.VisibleForTesting; 40 import com.google.android.setupcompat.util.BuildCompatUtils; 41 import com.google.android.setupdesign.R; 42 import java.io.IOException; 43 44 /** 45 * A view for displaying videos in a continuous loop (without audio). This is typically used for 46 * animated illustrations. 47 * 48 * <p>The video can be specified using {@code app:sudVideo}, specifying the raw resource to the mp4 49 * video. Optionally, {@code app:sudLoopStartMs} can be used to specify which part of the video it 50 * should loop back to 51 * 52 * <p>For optimal file size, use avconv or other video compression tool to remove the unused audio 53 * track and reduce the size of your video asset: avconv -i [input file] -vcodec h264 -crf 20 -an 54 * [output_file] 55 */ 56 @TargetApi(VERSION_CODES.ICE_CREAM_SANDWICH) 57 public class IllustrationVideoView extends TextureView 58 implements Animatable, 59 SurfaceTextureListener, 60 OnPreparedListener, 61 OnSeekCompleteListener, 62 OnInfoListener, 63 OnErrorListener { 64 65 private static final String TAG = "IllustrationVideoView"; 66 67 private float aspectRatio = 1.0f; // initial guess until we know 68 69 @Nullable // Can be null when media player fails to initialize 70 protected MediaPlayer mediaPlayer; 71 72 private @RawRes int videoResId = 0; 73 74 private String videoResPackageName; 75 76 @VisibleForTesting Surface surface; 77 78 private boolean prepared; 79 80 private boolean shouldPauseVideoWhenFinished = true; 81 82 /** 83 * The visibility of this view as set by the user. This view combines this with {@link 84 * #isMediaPlayerLoading} to determine the final visibility. 85 */ 86 private int visibility = View.VISIBLE; 87 88 /** 89 * Whether the media player is loading. This is used to hide this view to avoid a flash with a 90 * color different from the background while the media player is trying to render the first frame. 91 * Note: if this TextureView is not visible, it will never load the surface texture, and never 92 * play the video. 93 */ 94 private boolean isMediaPlayerLoading = false; 95 IllustrationVideoView(Context context, AttributeSet attrs)96 public IllustrationVideoView(Context context, AttributeSet attrs) { 97 super(context, attrs); 98 if (!isInEditMode()) { 99 init(context, attrs); 100 } 101 } 102 init(Context context, AttributeSet attrs)103 private void init(Context context, AttributeSet attrs) { 104 final TypedArray a = 105 context.obtainStyledAttributes(attrs, R.styleable.SudIllustrationVideoView); 106 final int videoResId = a.getResourceId(R.styleable.SudIllustrationVideoView_sudVideo, 0); 107 108 // TODO: remove the usage of BuildCompatUtils#isAtLeatestS if VERSION_CODE.S is 109 // support by system. 110 if (BuildCompatUtils.isAtLeastS()) { 111 boolean shouldPauseVideo = 112 a.getBoolean(R.styleable.SudIllustrationVideoView_sudPauseVideoWhenFinished, true); 113 setPauseVideoWhenFinished(shouldPauseVideo); 114 } 115 116 a.recycle(); 117 setVideoResource(videoResId); 118 119 // By default the video scales without interpolation, resulting in jagged edges in the 120 // video. This works around it by making the view go through scaling, which will apply 121 // anti-aliasing effects. 122 setScaleX(0.9999999f); 123 setScaleX(0.9999999f); 124 125 setSurfaceTextureListener(this); 126 } 127 128 @Override onMeasure(int widthMeasureSpec, int heightMeasureSpec)129 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 130 int width = MeasureSpec.getSize(widthMeasureSpec); 131 int height = MeasureSpec.getSize(heightMeasureSpec); 132 133 if (height < width * aspectRatio) { 134 // Height constraint is tighter. Need to scale down the width to fit aspect ratio. 135 width = (int) (height / aspectRatio); 136 } else { 137 // Width constraint is tighter. Need to scale down the height to fit aspect ratio. 138 height = (int) (width * aspectRatio); 139 } 140 141 super.onMeasure( 142 MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), 143 MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)); 144 } 145 146 /** 147 * Set the video and video package name to be played by this view. 148 * 149 * @param videoResId Resource ID of the video, typically an MP4 under res/raw. 150 * @param videoResPackageName The package name of videoResId. 151 */ setVideoResource(@awRes int videoResId, String videoResPackageName)152 public void setVideoResource(@RawRes int videoResId, String videoResPackageName) { 153 if (videoResId != this.videoResId 154 || (videoResPackageName != null && !videoResPackageName.equals(this.videoResPackageName))) { 155 this.videoResId = videoResId; 156 this.videoResPackageName = videoResPackageName; 157 createMediaPlayer(); 158 } 159 } 160 161 /** 162 * Set the video to be played by this view. 163 * 164 * @param resourceEntry the {@link com.google.android.setupdesign.util.Partner.ResourceEntry} of 165 * the video, typically an MP4 under res/raw. 166 */ setVideoResourceEntry( com.google.android.setupdesign.util.Partner.ResourceEntry resourceEntry)167 public void setVideoResourceEntry( 168 com.google.android.setupdesign.util.Partner.ResourceEntry resourceEntry) { 169 setVideoResource(resourceEntry.id, resourceEntry.packageName); 170 } 171 172 /** 173 * Set the video to be played by this view. 174 * 175 * @param resourceEntry the {@link com.google.android.setupcompat.partnerconfig.ResourceEntry} of 176 * the video, typically an MP4 under res/raw. 177 */ setVideoResourceEntry( com.google.android.setupcompat.partnerconfig.ResourceEntry resourceEntry)178 public void setVideoResourceEntry( 179 com.google.android.setupcompat.partnerconfig.ResourceEntry resourceEntry) { 180 setVideoResource(resourceEntry.getResourceId(), resourceEntry.getPackageName()); 181 } 182 183 /** 184 * Set the video to be played by this view. 185 * 186 * @param resId Resource ID of the video, typically an MP4 under res/raw. 187 */ setVideoResource(@awRes int resId)188 public void setVideoResource(@RawRes int resId) { 189 setVideoResource(resId, getContext().getPackageName()); 190 } 191 192 /** 193 * Sets whether the video pauses during the screen transition. 194 * 195 * @param paused Whether the video pauses. 196 */ setPauseVideoWhenFinished(boolean paused)197 public void setPauseVideoWhenFinished(boolean paused) { 198 shouldPauseVideoWhenFinished = paused; 199 } 200 201 @Override onWindowFocusChanged(boolean hasWindowFocus)202 public void onWindowFocusChanged(boolean hasWindowFocus) { 203 super.onWindowFocusChanged(hasWindowFocus); 204 if (hasWindowFocus) { 205 start(); 206 } else { 207 stop(); 208 } 209 } 210 211 /** 212 * Creates a media player for the current URI. The media player will be started immediately if the 213 * view's window is visible. If there is an existing media player, it will be released. 214 */ createMediaPlayer()215 protected void createMediaPlayer() { 216 if (mediaPlayer != null) { 217 mediaPlayer.release(); 218 } 219 if (surface == null || videoResId == 0) { 220 return; 221 } 222 223 mediaPlayer = new MediaPlayer(); 224 225 mediaPlayer.setSurface(surface); 226 mediaPlayer.setOnPreparedListener(this); 227 mediaPlayer.setOnSeekCompleteListener(this); 228 mediaPlayer.setOnInfoListener(this); 229 mediaPlayer.setOnErrorListener(this); 230 231 setVideoResourceInternal(videoResId, videoResPackageName); 232 } 233 setVideoResourceInternal(@awRes int videoRes, String videoResPackageName)234 private void setVideoResourceInternal(@RawRes int videoRes, String videoResPackageName) { 235 Uri uri = Uri.parse("android.resource://" + videoResPackageName + "/" + videoRes); 236 try { 237 mediaPlayer.setDataSource(getContext(), uri, null); 238 mediaPlayer.prepareAsync(); 239 } catch (IOException e) { 240 Log.e(TAG, "Unable to set video data source: " + videoRes, e); 241 } 242 } 243 createSurface()244 protected void createSurface() { 245 if (surface != null) { 246 surface.release(); 247 surface = null; 248 } 249 // Reattach only if it has been previously released 250 SurfaceTexture surfaceTexture = getSurfaceTexture(); 251 if (surfaceTexture != null) { 252 setIsMediaPlayerLoading(true); 253 surface = new Surface(surfaceTexture); 254 } 255 } 256 257 @Override onWindowVisibilityChanged(int visibility)258 protected void onWindowVisibilityChanged(int visibility) { 259 super.onWindowVisibilityChanged(visibility); 260 if (visibility == View.VISIBLE) { 261 reattach(); 262 } else { 263 release(); 264 } 265 } 266 267 @Override setVisibility(int visibility)268 public void setVisibility(int visibility) { 269 this.visibility = visibility; 270 if (isMediaPlayerLoading && visibility == View.VISIBLE) { 271 visibility = View.INVISIBLE; 272 } 273 super.setVisibility(visibility); 274 } 275 setIsMediaPlayerLoading(boolean isMediaPlayerLoading)276 private void setIsMediaPlayerLoading(boolean isMediaPlayerLoading) { 277 this.isMediaPlayerLoading = isMediaPlayerLoading; 278 setVisibility(this.visibility); 279 } 280 281 /** 282 * Whether the media player should play the video in a continuous loop. The default value is true. 283 */ shouldLoop()284 protected boolean shouldLoop() { 285 return true; 286 } 287 288 /** 289 * Release any resources used by this view. This is automatically called in 290 * onSurfaceTextureDestroyed so in most cases you don't have to call this. 291 */ release()292 public void release() { 293 if (mediaPlayer != null) { 294 mediaPlayer.release(); 295 mediaPlayer = null; 296 prepared = false; 297 } 298 if (surface != null) { 299 surface.release(); 300 surface = null; 301 } 302 } 303 reattach()304 private void reattach() { 305 if (surface == null) { 306 initVideo(); 307 } 308 } 309 initVideo()310 private void initVideo() { 311 if (getWindowVisibility() != View.VISIBLE) { 312 return; 313 } 314 createSurface(); 315 if (surface != null) { 316 createMediaPlayer(); 317 } else { 318 // This can happen if this view hasn't been drawn yet 319 Log.i(TAG, "Surface is null"); 320 } 321 } 322 onRenderingStart()323 protected void onRenderingStart() {} 324 325 /* SurfaceTextureListener methods */ 326 327 @Override onSurfaceTextureAvailable(SurfaceTexture surfaceTexture, int width, int height)328 public void onSurfaceTextureAvailable(SurfaceTexture surfaceTexture, int width, int height) { 329 setIsMediaPlayerLoading(true); 330 initVideo(); 331 } 332 333 @Override onSurfaceTextureSizeChanged(SurfaceTexture surfaceTexture, int width, int height)334 public void onSurfaceTextureSizeChanged(SurfaceTexture surfaceTexture, int width, int height) {} 335 336 @Override onSurfaceTextureDestroyed(SurfaceTexture surfaceTexture)337 public boolean onSurfaceTextureDestroyed(SurfaceTexture surfaceTexture) { 338 release(); 339 return true; 340 } 341 342 @Override onSurfaceTextureUpdated(SurfaceTexture surfaceTexture)343 public void onSurfaceTextureUpdated(SurfaceTexture surfaceTexture) {} 344 345 /* Animatable methods */ 346 347 @Override start()348 public void start() { 349 if (prepared && mediaPlayer != null && !mediaPlayer.isPlaying()) { 350 mediaPlayer.start(); 351 } 352 } 353 354 @Override stop()355 public void stop() { 356 if (shouldPauseVideoWhenFinished) { 357 if (prepared && mediaPlayer != null) { 358 mediaPlayer.pause(); 359 } 360 } else { 361 // do not pause the media player. 362 } 363 } 364 365 @Override isRunning()366 public boolean isRunning() { 367 return mediaPlayer != null && mediaPlayer.isPlaying(); 368 } 369 370 /* MediaPlayer callbacks */ 371 372 @Override onInfo(MediaPlayer mp, int what, int extra)373 public boolean onInfo(MediaPlayer mp, int what, int extra) { 374 if (what == MediaPlayer.MEDIA_INFO_VIDEO_RENDERING_START) { 375 setIsMediaPlayerLoading(false); 376 onRenderingStart(); 377 } 378 return false; 379 } 380 381 @Override onPrepared(MediaPlayer mp)382 public void onPrepared(MediaPlayer mp) { 383 prepared = true; 384 mp.setLooping(shouldLoop()); 385 386 float aspectRatio = 0.0f; 387 if (mp.getVideoWidth() > 0 && mp.getVideoHeight() > 0) { 388 aspectRatio = (float) mp.getVideoHeight() / mp.getVideoWidth(); 389 } else { 390 Log.w(TAG, "Unexpected video size=" + mp.getVideoWidth() + "x" + mp.getVideoHeight()); 391 } 392 if (Float.compare(this.aspectRatio, aspectRatio) != 0) { 393 this.aspectRatio = aspectRatio; 394 requestLayout(); 395 } 396 if (getWindowVisibility() == View.VISIBLE) { 397 start(); 398 } 399 } 400 401 @Override onSeekComplete(MediaPlayer mp)402 public void onSeekComplete(MediaPlayer mp) { 403 if (isPrepared()) { 404 mp.start(); 405 } else { 406 Log.e(TAG, "Seek complete but media player not prepared"); 407 } 408 } 409 getCurrentPosition()410 public int getCurrentPosition() { 411 return mediaPlayer == null ? 0 : mediaPlayer.getCurrentPosition(); 412 } 413 isPrepared()414 protected boolean isPrepared() { 415 return prepared; 416 } 417 418 @Override onError(MediaPlayer mediaPlayer, int what, int extra)419 public boolean onError(MediaPlayer mediaPlayer, int what, int extra) { 420 Log.w(TAG, "MediaPlayer error. what=" + what + " extra=" + extra); 421 return false; 422 } 423 424 /** 425 * Seeks to specified time position. 426 * 427 * @param milliseconds the offset in milliseconds from the start to seek to 428 * @throws IllegalStateException if the internal player engine has not been initialized 429 */ seekTo(int milliseconds)430 public void seekTo(int milliseconds) { 431 if (mediaPlayer != null) { 432 mediaPlayer.seekTo(milliseconds); 433 } 434 } 435 436 @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) getMediaPlayer()437 public MediaPlayer getMediaPlayer() { 438 return mediaPlayer; 439 } 440 getAspectRatio()441 protected float getAspectRatio() { 442 return aspectRatio; 443 } 444 } 445