1/*
2 * Copyright (C) 2009 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
17package com.android.gallery3d.app;
18
19import android.annotation.TargetApi;
20import android.app.AlertDialog;
21import android.content.BroadcastReceiver;
22import android.content.Context;
23import android.content.DialogInterface;
24import android.content.DialogInterface.OnCancelListener;
25import android.content.DialogInterface.OnClickListener;
26import android.content.Intent;
27import android.content.IntentFilter;
28import android.media.AudioManager;
29import android.media.MediaPlayer;
30import android.media.audiofx.AudioEffect;
31import android.media.audiofx.Virtualizer;
32import android.net.Uri;
33import android.os.Build;
34import android.os.Bundle;
35import android.os.Handler;
36import android.view.KeyEvent;
37import android.view.MotionEvent;
38import android.view.View;
39import android.view.ViewGroup;
40import android.widget.VideoView;
41
42import com.android.gallery3d.R;
43import com.android.gallery3d.common.ApiHelper;
44import com.android.gallery3d.common.BlobCache;
45import com.android.gallery3d.util.CacheManager;
46import com.android.gallery3d.util.GalleryUtils;
47
48import java.io.ByteArrayInputStream;
49import java.io.ByteArrayOutputStream;
50import java.io.DataInputStream;
51import java.io.DataOutputStream;
52
53public class MoviePlayer implements
54        MediaPlayer.OnErrorListener, MediaPlayer.OnCompletionListener,
55        ControllerOverlay.Listener {
56    @SuppressWarnings("unused")
57    private static final String TAG = "MoviePlayer";
58
59    private static final String KEY_VIDEO_POSITION = "video-position";
60    private static final String KEY_RESUMEABLE_TIME = "resumeable-timeout";
61
62    // These are constants in KeyEvent, appearing on API level 11.
63    private static final int KEYCODE_MEDIA_PLAY = 126;
64    private static final int KEYCODE_MEDIA_PAUSE = 127;
65
66    // Copied from MediaPlaybackService in the Music Player app.
67    private static final String SERVICECMD = "com.android.music.musicservicecommand";
68    private static final String CMDNAME = "command";
69    private static final String CMDPAUSE = "pause";
70
71    private static final String VIRTUALIZE_EXTRA = "virtualize";
72    private static final long BLACK_TIMEOUT = 500;
73
74    // If we resume the acitivty with in RESUMEABLE_TIMEOUT, we will keep playing.
75    // Otherwise, we pause the player.
76    private static final long RESUMEABLE_TIMEOUT = 3 * 60 * 1000; // 3 mins
77
78    private Context mContext;
79    private final VideoView mVideoView;
80    private final View mRootView;
81    private final Bookmarker mBookmarker;
82    private final Uri mUri;
83    private final Handler mHandler = new Handler();
84    private final AudioBecomingNoisyReceiver mAudioBecomingNoisyReceiver;
85    private final MovieControllerOverlay mController;
86
87    private long mResumeableTime = Long.MAX_VALUE;
88    private int mVideoPosition = 0;
89    private boolean mHasPaused = false;
90    private int mLastSystemUiVis = 0;
91
92    // If the time bar is being dragged.
93    private boolean mDragging;
94
95    // If the time bar is visible.
96    private boolean mShowing;
97
98    private Virtualizer mVirtualizer;
99
100    private final Runnable mPlayingChecker = new Runnable() {
101        @Override
102        public void run() {
103            if (mVideoView.isPlaying()) {
104                mController.showPlaying();
105            } else {
106                mHandler.postDelayed(mPlayingChecker, 250);
107            }
108        }
109    };
110
111    private final Runnable mProgressChecker = new Runnable() {
112        @Override
113        public void run() {
114            int pos = setProgress();
115            mHandler.postDelayed(mProgressChecker, 1000 - (pos % 1000));
116        }
117    };
118
119    public MoviePlayer(View rootView, final MovieActivity movieActivity,
120            Uri videoUri, Bundle savedInstance, boolean canReplay) {
121        mContext = movieActivity.getApplicationContext();
122        mRootView = rootView;
123        mVideoView = (VideoView) rootView.findViewById(R.id.surface_view);
124        mBookmarker = new Bookmarker(movieActivity);
125        mUri = videoUri;
126
127        mController = new MovieControllerOverlay(mContext);
128        ((ViewGroup)rootView).addView(mController.getView());
129        mController.setListener(this);
130        mController.setCanReplay(canReplay);
131
132        mVideoView.setOnErrorListener(this);
133        mVideoView.setOnCompletionListener(this);
134        mVideoView.setVideoURI(mUri);
135
136        Intent ai = movieActivity.getIntent();
137        boolean virtualize = ai.getBooleanExtra(VIRTUALIZE_EXTRA, false);
138        if (virtualize) {
139            int session = mVideoView.getAudioSessionId();
140            if (session != 0) {
141                mVirtualizer = new Virtualizer(0, session);
142                mVirtualizer.setEnabled(true);
143            } else {
144                Log.w(TAG, "no audio session to virtualize");
145            }
146        }
147        mVideoView.setOnTouchListener(new View.OnTouchListener() {
148            @Override
149            public boolean onTouch(View v, MotionEvent event) {
150                mController.show();
151                return true;
152            }
153        });
154        mVideoView.setOnPreparedListener(new MediaPlayer.OnPreparedListener() {
155            @Override
156            public void onPrepared(MediaPlayer player) {
157                if (!mVideoView.canSeekForward() || !mVideoView.canSeekBackward()) {
158                    mController.setSeekable(false);
159                } else {
160                    mController.setSeekable(true);
161                }
162                setProgress();
163            }
164        });
165
166        // The SurfaceView is transparent before drawing the first frame.
167        // This makes the UI flashing when open a video. (black -> old screen
168        // -> video) However, we have no way to know the timing of the first
169        // frame. So, we hide the VideoView for a while to make sure the
170        // video has been drawn on it.
171        mVideoView.postDelayed(new Runnable() {
172            @Override
173            public void run() {
174                mVideoView.setVisibility(View.VISIBLE);
175            }
176        }, BLACK_TIMEOUT);
177
178        setOnSystemUiVisibilityChangeListener();
179        // Hide system UI by default
180        showSystemUi(false);
181
182        mAudioBecomingNoisyReceiver = new AudioBecomingNoisyReceiver();
183        mAudioBecomingNoisyReceiver.register();
184
185        Intent i = new Intent(SERVICECMD);
186        i.putExtra(CMDNAME, CMDPAUSE);
187        movieActivity.sendBroadcast(i);
188
189        if (savedInstance != null) { // this is a resumed activity
190            mVideoPosition = savedInstance.getInt(KEY_VIDEO_POSITION, 0);
191            mResumeableTime = savedInstance.getLong(KEY_RESUMEABLE_TIME, Long.MAX_VALUE);
192            mVideoView.start();
193            mVideoView.suspend();
194            mHasPaused = true;
195        } else {
196            final Integer bookmark = mBookmarker.getBookmark(mUri);
197            if (bookmark != null) {
198                showResumeDialog(movieActivity, bookmark);
199            } else {
200                startVideo();
201            }
202        }
203    }
204
205    @TargetApi(Build.VERSION_CODES.JELLY_BEAN)
206    private void setOnSystemUiVisibilityChangeListener() {
207        if (!ApiHelper.HAS_VIEW_SYSTEM_UI_FLAG_HIDE_NAVIGATION) return;
208
209        // When the user touches the screen or uses some hard key, the framework
210        // will change system ui visibility from invisible to visible. We show
211        // the media control and enable system UI (e.g. ActionBar) to be visible at this point
212        mVideoView.setOnSystemUiVisibilityChangeListener(
213                new View.OnSystemUiVisibilityChangeListener() {
214            @Override
215            public void onSystemUiVisibilityChange(int visibility) {
216                int diff = mLastSystemUiVis ^ visibility;
217                mLastSystemUiVis = visibility;
218                if ((diff & View.SYSTEM_UI_FLAG_HIDE_NAVIGATION) != 0
219                        && (visibility & View.SYSTEM_UI_FLAG_HIDE_NAVIGATION) == 0) {
220                    mController.show();
221                }
222            }
223        });
224    }
225
226    @SuppressWarnings("deprecation")
227    @TargetApi(Build.VERSION_CODES.JELLY_BEAN)
228    private void showSystemUi(boolean visible) {
229        if (!ApiHelper.HAS_VIEW_SYSTEM_UI_FLAG_LAYOUT_STABLE) return;
230
231        int flag = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
232                | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
233                | View.SYSTEM_UI_FLAG_LAYOUT_STABLE;
234        if (!visible) {
235            // We used the deprecated "STATUS_BAR_HIDDEN" for unbundling
236            flag |= View.STATUS_BAR_HIDDEN | View.SYSTEM_UI_FLAG_FULLSCREEN
237                    | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION;
238        }
239        mVideoView.setSystemUiVisibility(flag);
240    }
241
242    public void onSaveInstanceState(Bundle outState) {
243        outState.putInt(KEY_VIDEO_POSITION, mVideoPosition);
244        outState.putLong(KEY_RESUMEABLE_TIME, mResumeableTime);
245    }
246
247    private void showResumeDialog(Context context, final int bookmark) {
248        AlertDialog.Builder builder = new AlertDialog.Builder(context);
249        builder.setTitle(R.string.resume_playing_title);
250        builder.setMessage(String.format(
251                context.getString(R.string.resume_playing_message),
252                GalleryUtils.formatDuration(context, bookmark / 1000)));
253        builder.setOnCancelListener(new OnCancelListener() {
254            @Override
255            public void onCancel(DialogInterface dialog) {
256                onCompletion();
257            }
258        });
259        builder.setPositiveButton(
260                R.string.resume_playing_resume, new OnClickListener() {
261            @Override
262            public void onClick(DialogInterface dialog, int which) {
263                mVideoView.seekTo(bookmark);
264                startVideo();
265            }
266        });
267        builder.setNegativeButton(
268                R.string.resume_playing_restart, new OnClickListener() {
269            @Override
270            public void onClick(DialogInterface dialog, int which) {
271                startVideo();
272            }
273        });
274        builder.show();
275    }
276
277    public void onPause() {
278        mHasPaused = true;
279        mHandler.removeCallbacksAndMessages(null);
280        mVideoPosition = mVideoView.getCurrentPosition();
281        mBookmarker.setBookmark(mUri, mVideoPosition, mVideoView.getDuration());
282        mVideoView.suspend();
283        mResumeableTime = System.currentTimeMillis() + RESUMEABLE_TIMEOUT;
284    }
285
286    public void onResume() {
287        if (mHasPaused) {
288            mVideoView.seekTo(mVideoPosition);
289            mVideoView.resume();
290
291            // If we have slept for too long, pause the play
292            if (System.currentTimeMillis() > mResumeableTime) {
293                pauseVideo();
294            }
295        }
296        mHandler.post(mProgressChecker);
297    }
298
299    public void onDestroy() {
300        if (mVirtualizer != null) {
301            mVirtualizer.release();
302            mVirtualizer = null;
303        }
304        mVideoView.stopPlayback();
305        mAudioBecomingNoisyReceiver.unregister();
306    }
307
308    // This updates the time bar display (if necessary). It is called every
309    // second by mProgressChecker and also from places where the time bar needs
310    // to be updated immediately.
311    private int setProgress() {
312        if (mDragging || !mShowing) {
313            return 0;
314        }
315        int position = mVideoView.getCurrentPosition();
316        int duration = mVideoView.getDuration();
317        mController.setTimes(position, duration, 0, 0);
318        return position;
319    }
320
321    private void startVideo() {
322        // For streams that we expect to be slow to start up, show a
323        // progress spinner until playback starts.
324        String scheme = mUri.getScheme();
325        if ("http".equalsIgnoreCase(scheme) || "rtsp".equalsIgnoreCase(scheme)) {
326            mController.showLoading();
327            mHandler.removeCallbacks(mPlayingChecker);
328            mHandler.postDelayed(mPlayingChecker, 250);
329        } else {
330            mController.showPlaying();
331            mController.hide();
332        }
333
334        mVideoView.start();
335        setProgress();
336    }
337
338    private void playVideo() {
339        mVideoView.start();
340        mController.showPlaying();
341        setProgress();
342    }
343
344    private void pauseVideo() {
345        mVideoView.pause();
346        mController.showPaused();
347    }
348
349    // Below are notifications from VideoView
350    @Override
351    public boolean onError(MediaPlayer player, int arg1, int arg2) {
352        mHandler.removeCallbacksAndMessages(null);
353        // VideoView will show an error dialog if we return false, so no need
354        // to show more message.
355        mController.showErrorMessage("");
356        return false;
357    }
358
359    @Override
360    public void onCompletion(MediaPlayer mp) {
361        mController.showEnded();
362        onCompletion();
363    }
364
365    public void onCompletion() {
366    }
367
368    // Below are notifications from ControllerOverlay
369    @Override
370    public void onPlayPause() {
371        if (mVideoView.isPlaying()) {
372            pauseVideo();
373        } else {
374            playVideo();
375        }
376    }
377
378    @Override
379    public void onSeekStart() {
380        mDragging = true;
381    }
382
383    @Override
384    public void onSeekMove(int time) {
385        mVideoView.seekTo(time);
386    }
387
388    @Override
389    public void onSeekEnd(int time, int start, int end) {
390        mDragging = false;
391        mVideoView.seekTo(time);
392        setProgress();
393    }
394
395    @Override
396    public void onShown() {
397        mShowing = true;
398        setProgress();
399        showSystemUi(true);
400    }
401
402    @Override
403    public void onHidden() {
404        mShowing = false;
405        showSystemUi(false);
406    }
407
408    @Override
409    public void onReplay() {
410        startVideo();
411    }
412
413    // Below are key events passed from MovieActivity.
414    public boolean onKeyDown(int keyCode, KeyEvent event) {
415
416        // Some headsets will fire off 7-10 events on a single click
417        if (event.getRepeatCount() > 0) {
418            return isMediaKey(keyCode);
419        }
420
421        switch (keyCode) {
422            case KeyEvent.KEYCODE_HEADSETHOOK:
423            case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE:
424                if (mVideoView.isPlaying()) {
425                    pauseVideo();
426                } else {
427                    playVideo();
428                }
429                return true;
430            case KEYCODE_MEDIA_PAUSE:
431                if (mVideoView.isPlaying()) {
432                    pauseVideo();
433                }
434                return true;
435            case KEYCODE_MEDIA_PLAY:
436                if (!mVideoView.isPlaying()) {
437                    playVideo();
438                }
439                return true;
440            case KeyEvent.KEYCODE_MEDIA_PREVIOUS:
441            case KeyEvent.KEYCODE_MEDIA_NEXT:
442                // TODO: Handle next / previous accordingly, for now we're
443                // just consuming the events.
444                return true;
445        }
446        return false;
447    }
448
449    public boolean onKeyUp(int keyCode, KeyEvent event) {
450        return isMediaKey(keyCode);
451    }
452
453    private static boolean isMediaKey(int keyCode) {
454        return keyCode == KeyEvent.KEYCODE_HEADSETHOOK
455                || keyCode == KeyEvent.KEYCODE_MEDIA_PREVIOUS
456                || keyCode == KeyEvent.KEYCODE_MEDIA_NEXT
457                || keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE
458                || keyCode == KeyEvent.KEYCODE_MEDIA_PLAY
459                || keyCode == KeyEvent.KEYCODE_MEDIA_PAUSE;
460    }
461
462    // We want to pause when the headset is unplugged.
463    private class AudioBecomingNoisyReceiver extends BroadcastReceiver {
464
465        public void register() {
466            mContext.registerReceiver(this,
467                    new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY));
468        }
469
470        public void unregister() {
471            mContext.unregisterReceiver(this);
472        }
473
474        @Override
475        public void onReceive(Context context, Intent intent) {
476            if (mVideoView.isPlaying()) pauseVideo();
477        }
478    }
479}
480
481class Bookmarker {
482    private static final String TAG = "Bookmarker";
483
484    private static final String BOOKMARK_CACHE_FILE = "bookmark";
485    private static final int BOOKMARK_CACHE_MAX_ENTRIES = 100;
486    private static final int BOOKMARK_CACHE_MAX_BYTES = 10 * 1024;
487    private static final int BOOKMARK_CACHE_VERSION = 1;
488
489    private static final int HALF_MINUTE = 30 * 1000;
490    private static final int TWO_MINUTES = 4 * HALF_MINUTE;
491
492    private final Context mContext;
493
494    public Bookmarker(Context context) {
495        mContext = context;
496    }
497
498    public void setBookmark(Uri uri, int bookmark, int duration) {
499        try {
500            BlobCache cache = CacheManager.getCache(mContext,
501                    BOOKMARK_CACHE_FILE, BOOKMARK_CACHE_MAX_ENTRIES,
502                    BOOKMARK_CACHE_MAX_BYTES, BOOKMARK_CACHE_VERSION);
503
504            ByteArrayOutputStream bos = new ByteArrayOutputStream();
505            DataOutputStream dos = new DataOutputStream(bos);
506            dos.writeUTF(uri.toString());
507            dos.writeInt(bookmark);
508            dos.writeInt(duration);
509            dos.flush();
510            cache.insert(uri.hashCode(), bos.toByteArray());
511        } catch (Throwable t) {
512            Log.w(TAG, "setBookmark failed", t);
513        }
514    }
515
516    public Integer getBookmark(Uri uri) {
517        try {
518            BlobCache cache = CacheManager.getCache(mContext,
519                    BOOKMARK_CACHE_FILE, BOOKMARK_CACHE_MAX_ENTRIES,
520                    BOOKMARK_CACHE_MAX_BYTES, BOOKMARK_CACHE_VERSION);
521
522            byte[] data = cache.lookup(uri.hashCode());
523            if (data == null) return null;
524
525            DataInputStream dis = new DataInputStream(
526                    new ByteArrayInputStream(data));
527
528            String uriString = DataInputStream.readUTF(dis);
529            int bookmark = dis.readInt();
530            int duration = dis.readInt();
531
532            if (!uriString.equals(uri.toString())) {
533                return null;
534            }
535
536            if ((bookmark < HALF_MINUTE) || (duration < TWO_MINUTES)
537                    || (bookmark > (duration - HALF_MINUTE))) {
538                return null;
539            }
540            return Integer.valueOf(bookmark);
541        } catch (Throwable t) {
542            Log.w(TAG, "getBookmark failed", t);
543        }
544        return null;
545    }
546}
547