/****************************************************************************** * Copyright (C) 2012, 2013, 2014, 2015, 2016 * Younghyung Cho. <yhcting77@gmail.com> * All rights reserved. * * This file is part of NetMBuddy * * This program is licensed under the FreeBSD license * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are * met: * * 1. Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright * notice, this list of conditions and the following disclaimer in the * documentation and/or other materials provided with the distribution. * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. * * The views and conclusions contained in the software and documentation * are those of the authors and should not be interpreted as representing * official policies, either expressed or implied, of the FreeBSD Project. *****************************************************************************/ package free.yhc.netmbuddy.core; import java.io.File; import java.io.IOException; import java.util.Arrays; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.Random; import android.app.Activity; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.SharedPreferences; import android.database.Cursor; import android.media.AudioManager; import android.media.MediaPlayer; import android.net.ConnectivityManager; import android.net.NetworkInfo; import android.net.wifi.WifiManager; import android.net.wifi.WifiManager.WifiLock; import android.os.PowerManager; import android.os.PowerManager.WakeLock; import android.preference.PreferenceManager; import android.speech.tts.TextToSpeech; import android.speech.tts.UtteranceProgressListener; import android.support.annotation.NonNull; import android.telephony.TelephonyManager; import android.view.SurfaceHolder; import android.view.SurfaceView; import android.view.View; import android.view.ViewGroup; import free.yhc.abaselib.AppEnv; import free.yhc.baselib.Logger; import free.yhc.baselib.exception.UnsupportedFormatException; import free.yhc.abaselib.util.AUtil; import free.yhc.baselib.util.FileUtil; import free.yhc.netmbuddy.Err; import free.yhc.netmbuddy.R; import free.yhc.netmbuddy.VideoPlayerActivity; import free.yhc.netmbuddy.db.ColVideo; import free.yhc.netmbuddy.db.DB; import free.yhc.netmbuddy.db.DMVideo; import free.yhc.netmbuddy.task.YTDownloadTask; import free.yhc.netmbuddy.task.YTHackTask; import free.yhc.netmbuddy.utils.Util; public class YTPlayer implements MediaPlayer.OnBufferingUpdateListener, MediaPlayer.OnCompletionListener, MediaPlayer.OnPreparedListener, MediaPlayer.OnErrorListener, MediaPlayer.OnInfoListener, MediaPlayer.OnVideoSizeChangedListener, MediaPlayer.OnSeekCompleteListener, // To support video SurfaceHolder.Callback, // To support title TTS TextToSpeech.OnInitListener, SharedPreferences.OnSharedPreferenceChangeListener, UnexpectedExceptionHandler.Evidence { private static final boolean DBG = Logger.DBG_DEFAULT; private static final Logger P = Logger.create(YTPlayer.class, Logger.LOGLV_DEFAULT); public static final ColVideo[] sVideoProjectionToPlay = new ColVideo[] { ColVideo.VIDEOID, ColVideo.TITLE, ColVideo.VOLUME }; private static final int COLI_VID_YTVID = 0; private static final int COLI_VID_TITLE = 1; private static final int COLI_VID_VOLUME = 2; // State Flags - Package private. static final int MPSTATE_FLAG_IDLE = 0x0; static final int MPSTATE_FLAG_SEEKING = 0x1; static final int MPSTATE_FLAG_BUFFERING = 0x2; private static final String WLTAG = "YTPlayer"; private static final int PLAYER_ERR_RETRY = PolicyConstant.YTPLAYER_RETRY_ON_ERROR; private static final Comparator<NrElem> sNrElemComparator = new Comparator<NrElem>() { @Override public int compare(NrElem o1, NrElem o2) { if (o1.n > o2.n) return 1; else if (o1.n < o2.n) return -1; else return 0; } }; private static final Comparator<Video> sVideoTitleComparator = new Comparator<Video>() { @Override public int compare(Video o1, Video o2) { return o1.v.title.compareTo(o2.v.title); } }; private static File sCacheDir = new File(PolicyConstant.APPDATA_CACHEDIR); private static YTPlayer sInstance = null; // ------------------------------------------------------------------------ // // ------------------------------------------------------------------------ private final DB mDb = DB.get(); private final TaskManager mTm = TaskManager.get(); private final YTPlayerUI mUi = new YTPlayerUI(this); // for UI control private final AutoStop mAutoStop = new AutoStop(); private final StartVideoRecovery mStartVideoRecovery = new StartVideoRecovery(); private final YTPlayerVideoListManager mVlm; // ------------------------------------------------------------------------ // // ------------------------------------------------------------------------ private WakeLock mWl = null; private WifiLock mWfl = null; private MediaPlayer mMp = null; // Video Player Session Id. // Whenever new video - even if it is same video with previous one - is started, // session id is increased. private long mMpSessId = 0; private MPState mMpS = MPState.INVALID; // state of mMp; private int mMpSFlag = MPSTATE_FLAG_IDLE; private boolean mMpSurfAttached = false; private SurfaceHolder mSurfHolder = null; // To support video private boolean mSurfReady = false; private boolean mVSzReady = false; private int mMpVol = PolicyConstant.DEFAULT_VIDEO_VOLUME; // Current volume of media player. // mYtHackTask and mYtCachingTask are set as invalid initial instance to avoid checking 'null' private YTHackTask mYtHackTask; private YTDownloadTask mYtCachingTask; private TextToSpeech mTts = null; private TTSState mTtsState = TTSState.NOTUSED; // ------------------------------------------------------------------------ // Runtime Status // ------------------------------------------------------------------------ private int mErrRetry = PLAYER_ERR_RETRY; private YTPState mYtpS = YTPState.IDLE; private PlayerState mStoredPState = null; // ------------------------------------------------------------------------ // Listeners // ------------------------------------------------------------------------ private LinkedHashSet<VideosStateListener> mVStateLsnrl = new LinkedHashSet<>(); private LinkedHashSet<PlayerStateListener> mPStateLsnrl = new LinkedHashSet<>(); private final YTHackTask.EventListener<YTHackTask, Void> mYtHackTaskListener = new YTHackTask.EventListener<YTHackTask, Void>() { @Override public void onPostRun(@NonNull YTHackTask task, Void result, Exception ex) { if (null == ex) { prepareVideoStreamingFromYtHack(task); } else if (ex instanceof IOException) { if (DBG) P.i("Fail YTHackTask! recovery!"); mStartVideoRecovery.executeRecoveryStart(mVlm.getActiveVideo()); } else { int errmsg; if (ex instanceof InterruptedException) errmsg = Err.INTERRUPTED.getMessage(); else if (ex instanceof UnsupportedFormatException) errmsg = Err.PARSER_UNKNOWN.getMessage(); else errmsg = Err.UNKNOWN.getMessage(); // NOTE : Dirty!!! // But, extremely exceptional case that model referencing UI code mUi.notifyToUser(AUtil.getResString(errmsg)); } startNext(); // Move to next video. } }; private enum TaskType { CACHING, HACK, } public interface VideosStateListener { /** * playing videos in the queue is started */ void onStarted(); /** * playing videos in the queue is stopped */ void onStopped(StopState state); /** * video-queue of the player is changed. */ void onPlayQChanged(); } public interface PlayerStateListener { void onStateChanged(MPState from, int fromFlag, MPState to, int toFlag); void onBufferingChanged(int percent); } public interface OnDBUpdatedListener { /** * When DB is changed by YTPlayer. * So, other UI module may need to update look and feel accordingly. */ void onDbUpdated(DBUpdateType type); } // see "http://developer.android.com/reference/android/media/MediaPlayer.html" public enum MPState { INVALID, IDLE, INITIALIZED, PREPARING, PREPARED_AUDIO, // This is same with MediaPlayer's PREPARED state PREPARED, // MediaPlayer is prepared + SurfaceHolder for video is prepared. STARTED, STOPPED, PAUSED, PLAYBACK_COMPLETED, END, ERROR } public enum StopState { DONE, FORCE_STOPPED, NETWORK_UNAVAILABLE, FAIL_PLAYING, UNKNOWN_ERROR } public enum DBUpdateType { VOLUME, PLAYLIST, } private enum YTPState { IDLE, SUSPENDED, } private enum TTSState { NOTUSED, PREPARING, READY, } public static class Video { public final DMVideo v; public final int startpos; // Starting position(milliseconds) of this video. public Video(String ytvid, String title, int volume, int startpos) { v = new DMVideo(); v.ytvid = ytvid; v.title = title; v.volume = volume; this.startpos = startpos; } public Video(@NonNull DMVideo v, int startpos) { this.v = v; this.startpos = startpos; } public String infoString() { P.bug(null != v.ytvid && null != v.title); return String.format("%s(%s)", v.ytvid, v.title); } } private static class NrElem { public int n; public Object tag; NrElem(int aN, Object aTag) { n = aN; tag = aTag; } } private static class PlayerState { MPState mpState = MPState.INVALID; Video vidobj = null; int pos = -1; int vol = -1; } public static class ToolButton { public int drawable = 0; public View.OnClickListener onClick = null; public ToolButton(int aDrawable, View.OnClickListener aOnClick) { drawable= aDrawable; onClick = aOnClick; } } public static class TelephonyMonitor extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { String action = intent.getAction(); if (!action.equals("android.intent.action.PHONE_STATE")) return; String exst = intent.getExtras().getString(TelephonyManager.EXTRA_STATE); if (null == exst) { if (DBG) P.w("Unexpected broadcast message"); return; } if (TelephonyManager.EXTRA_STATE_IDLE.equals(exst)) { YTPlayer.get().ytpResumePlaying(); } else if (TelephonyManager.EXTRA_STATE_RINGING.equals(exst) || TelephonyManager.EXTRA_STATE_OFFHOOK.equals(exst)) { if (!YTPlayer.get().ytpIsSuspended()) YTPlayer.get().ytpSuspendPlaying(); } else { if (DBG) P.w("Unexpected extra state : " + exst); } } } // This class also for future use. public static class NetworkMonitor extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { String action = intent.getAction(); if (!action.equals(ConnectivityManager.CONNECTIVITY_ACTION)) return; ConnectivityManager cm = (ConnectivityManager)context.getSystemService(Context.CONNECTIVITY_SERVICE); NetworkInfo ni = cm.getActiveNetworkInfo(); if (null != ni && ni.isConnected()) { if (DBG) P.v("Network connected : " + ni.getType()); switch (ni.getType()) { case ConnectivityManager.TYPE_WIFI: if (DBG) P.v("Network connected : WIFI"); break; case ConnectivityManager.TYPE_MOBILE: if (DBG) P.v("Network connected : MOBILE"); break; } } else if (DBG) P.v("Network lost"); } } public static class WiredHeadsetMonitor extends BroadcastReceiver { // See "http://developer.android.com/reference/android/content/Intent.html#ACTION_HEADSET_PLUG" private static final int WHSTATE_PLUG = 1; private static final int WHSTATE_UNPLUG = 0; @Override public void onReceive(Context context, Intent intent) { String action = intent.getAction(); if (!action.equals(Intent.ACTION_HEADSET_PLUG)) return; int state = intent.getIntExtra("state", -1); switch (state) { case WHSTATE_UNPLUG: case WHSTATE_PLUG: AppEnv.getUiHandler().post(new Runnable() { @Override public void run() { YTPlayer.get().playerPause(); } }); break; default: if (DBG) P.w("Unknown WiredHeadset State : " + state); break; } } } private class AutoStop implements Runnable { // ABSOLUTE time set when autoStop is triggered. // ex. 2012.Nov.11 10h 30m ... private long _mTm = 0; AutoStop() { } long getTime() { return _mTm; } /** * * @param millis <= 0 for unset autostop. */ void set(long millis) { unset(); if (mVlm.hasActiveVideo() && millis > 0) { _mTm = System.currentTimeMillis() + millis; mUi.updateStatusAutoStopSet(true, _mTm); AppEnv.getUiHandler().postDelayed(this, millis); } } void unset() { mUi.updateStatusAutoStopSet(false, 0); _mTm = 0; AppEnv.getUiHandler().removeCallbacks(this); } boolean isSet() { return _mTm > 0; } @Override public void run() { stopVideos(); _mTm = 0; } } private class StartVideoRecovery implements Runnable { private Video _mV = null; void cancel() { AppEnv.getUiHandler().removeCallbacks(this); } // USE THIS FUNCTION void executeRecoveryStart(Video v, long delays) { P.bug(AUtil.isUiThread()); cancel(); _mV = v; if (delays > 0) AppEnv.getUiHandler().postDelayed(this, delays); else AppEnv.getUiHandler().post(this); } void executeRecoveryStart(Video aV) { executeRecoveryStart(aV, 0); } // DO NOT run this explicitly. @Override public void run() { P.bug(AUtil.isUiThread()); if (null != _mV) startVideo(_mV, true); } } /////////////////////////////////////////////////////////////////////////// // // // /////////////////////////////////////////////////////////////////////////// static { IntentFilter receiverFilter = new IntentFilter(Intent.ACTION_HEADSET_PLUG); WiredHeadsetMonitor receiver = new WiredHeadsetMonitor(); AppEnv.getAppContext().registerReceiver(receiver, receiverFilter); } /////////////////////////////////////////////////////////////////////////// // // // /////////////////////////////////////////////////////////////////////////// private void acquireLocks() { if (null != mWl) return; // already locked nothing to do P.bug(null == mWfl); mWl = ((PowerManager)AppEnv.getAppContext().getSystemService(Context.POWER_SERVICE)) .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WLTAG); // Playing youtube requires high performance wifi for high quality media play. mWfl = ((WifiManager)AppEnv.getAppContext().getSystemService(Context.WIFI_SERVICE)) .createWifiLock(WifiManager.WIFI_MODE_FULL, WLTAG); mWl.acquire(); mWfl.acquire(); } private void releaseLocks() { if (null == mWl) return; P.bug(null != mWfl); mWl.release(); mWfl.release(); mWl = null; mWfl = null; } // ======================================================================== // // Media Player Interfaces // // ======================================================================== private void mpSetState(MPState newState) { if (DBG) P.v("State : " + mMpS.name() + " => " + newState.name()); // NOTE // DO NOT write CODE like "if (oldS == mMpS) return;" // most case, this is MediaPlay's state. // So, even if state is same, this might be the state of different MediaPlayer instance. // (Ex. videoA : IDLE -> videB : IDLE). MPState oldS = mMpS; mMpS = newState; // If main state is changed, sub-state should be reset to IDLE mMpSFlag = MPSTATE_FLAG_IDLE; onMpStateChanged(oldS, mMpSFlag, mMpS, mMpSFlag); } private MPState mpGetState() { return mMpS; } private void mpSetStateFlag(int newStateFlag) { if (DBG) P.v("StateFlag : " + mMpSFlag + " => " + newStateFlag); int old = mMpSFlag; mMpSFlag = newStateFlag; onMpStateChanged(mMpS, old, mMpS, mMpSFlag); } private int mpGetStateFlag() { return mMpSFlag; } private void mpSetStateFlagBit(int mask) { mpSetStateFlag(Util.bitSet(mMpSFlag, mask, mask)); } private void mpClearStateFlagBit(int mask) { mpSetStateFlag(Util.bitClear(mMpSFlag, mask)); } private void initMediaPlayer(MediaPlayer mp) { mp.setOnBufferingUpdateListener(this); mp.setOnCompletionListener(this); mp.setOnVideoSizeChangedListener(this); mp.setOnSeekCompleteListener(this); mp.setOnErrorListener(this); mp.setOnInfoListener(this); mp.setScreenOnWhilePlaying(false); mp.setAudioStreamType(AudioManager.STREAM_MUSIC); mp.setOnPreparedListener(this); } private void mpNewInstance() { P.bug(AUtil.isUiThread()); mMp = new MediaPlayer(); if (DBG) P.v("MP" + System.identityHashCode(mMp) + " newInstance"); mMpSessId++; mMpSurfAttached = false; mMpVol = PolicyConstant.DEFAULT_VIDEO_VOLUME; initMediaPlayer(mMp); mpSetState(MPState.IDLE); mpSetStateFlag(MPSTATE_FLAG_IDLE); } private MediaPlayer mpGet() { return mMp; } private void mpSetDataSource(String path) throws IOException { if (null == mMp) return; switch (mpGetState()) { case IDLE: if (DBG) P.v("MP" + System.identityHashCode(mMp) + " setDataSource"); mMp.setDataSource(path); mpSetState(MPState.INITIALIZED); return; default: // ignored } if (DBG) P.v("MP [" + mpGetState().name() + "] : setDataSource ignored : "); } private void mpPrepareAsync() { if (null == mMp) return; switch (mpGetState()) { case INITIALIZED: case STOPPED: mpSetState(MPState.PREPARING); if (DBG) P.v("MP" + System.identityHashCode(mMp) + " prepareAsync"); mMp.prepareAsync(); return; default: // ignored } if (DBG) P.v("MP [" + mpGetState().name() + "] : prepareAsync ignored : "); } private void mpRelease() { if (null == mMp || MPState.END == mpGetState()) return; if (MPState.ERROR != mMpS && mMp.isPlaying()) { if (DBG) P.v("MP" + System.identityHashCode(mMp) + " stop"); mMp.stop(); } // Why run at another thread? // Sometimes mMp.release takes too long time or may never return. // Even in this case, ANR is very annoying to user. // So, this is a kind of workaround for these cases. final MediaPlayer mp = mMp; new Thread(new Runnable() { @Override public void run() { mpUnsetVideoSurface(); if (DBG) P.v("MP" + System.identityHashCode(mp) + " release"); mp.release(); } }).start(); mMp = null; mpSetState(MPState.END); } private void mpReset() { if (DBG) P.v("MPlayer - reset"); if (null == mMp) return; switch (mpGetState()) { case IDLE: case INITIALIZED: case PREPARED_AUDIO: case PREPARED: case STARTED: case PAUSED: case STOPPED: case PLAYBACK_COMPLETED: case ERROR: if (DBG) P.v("MP" + System.identityHashCode(mMp) + " reset"); mMp.reset(); mpSetState(MPState.IDLE); return; default: // ignored } if (DBG) P.v("MP [" + mpGetState().name() + "] : reset ignored : "); } private void mpSetVideoSurface(SurfaceHolder sholder) { if (null == mMp) return; mMp.setDisplay(sholder); mMpSurfAttached = (null != sholder); } private void mpUnsetVideoSurface() { if (null == mMp) return; mMp.setDisplay(null); mMpSurfAttached = false; } private void mpSetVolume(int vol) { if (null == mMp) return; switch (mpGetState()) { case IDLE: case INITIALIZED: case PREPARED_AUDIO: case PREPARED: case STARTED: case PAUSED: case STOPPED: case PLAYBACK_COMPLETED: float volf = vol/100.0f; mMpVol = vol; mMp.setVolume(volf, volf); return; default: // ignored } if (DBG) P.v("MP [" + mpGetState().name() + "] : setVolume ignored : "); } private int mpGetVolume() { switch(mpGetState()) { case INVALID: case END: if (DBG) P.v("MP [" + mpGetState().name() + "] : mpGetVolume ignored : "); return PolicyConstant.DEFAULT_VIDEO_VOLUME; default: // ignored } return mMpVol; } private int mpGetCurrentPosition() { if (null == mMp) return 0; // NOTE : Android BUG // Android document says that 'getCurrentPosition()' can be called at 'idle' or 'initialized' state. // But, in ICS, experimentally, this leads MediaPlayer to 'error' state. // So, 'getCurrentPosition()' SHOULD NOT be called at 'idle' and 'initialized' state. switch (mpGetState()) { case PREPARED_AUDIO: case PREPARED: case STARTED: case PAUSED: case STOPPED: case PLAYBACK_COMPLETED: return mMp.getCurrentPosition(); default: // ignored } if (DBG) P.v("MP [" + mpGetState().name() + "] : getCurrentPosition ignored : "); return 0; } private int mpGetDuration() { if (null == mMp) return 0; switch (mpGetState()) { case PREPARED_AUDIO: case PREPARED: case STARTED: case PAUSED: case STOPPED: case PLAYBACK_COMPLETED: return mMp.getDuration(); default: // ignored } if (DBG) P.v("MP [" + mpGetState().name() + "] : getDuration ignored : "); return 0; } private int mpGetVideoWidth() { if (null == mMp) return 0; switch (mpGetState()) { case IDLE: case INITIALIZED: case PREPARED_AUDIO: case PREPARED: case STARTED: case PAUSED: case STOPPED: case PLAYBACK_COMPLETED: return mMp.getVideoWidth(); default: // ignored } if (DBG) P.v("MP [" + mpGetState().name() + "] : getVideoWidth ignored : "); return 0; } private int mpGetVideoHeight() { if (null == mMp) return 0; switch (mpGetState()) { case IDLE: case INITIALIZED: case PREPARED_AUDIO: case PREPARED: case STARTED: case PAUSED: case STOPPED: case PLAYBACK_COMPLETED: return mMp.getVideoHeight(); default: // ignored } if (DBG) P.v("MP [" + mpGetState().name() + "] : getVideoHeight ignored : "); return 0; } private boolean mpIsSurfaceAttached() { return mMpSurfAttached; } @SuppressWarnings("unused") private boolean mpIsPlaying() { return null != mMp && mMp.isPlaying(); } private void mpPause() { if (DBG) P.v("MPlayer - pause"); if (null == mMp) return; switch (mpGetState()) { case STARTED: case PAUSED: if (DBG) P.v("MP" + System.identityHashCode(mMp) + " pause"); mMp.pause(); mpSetState(MPState.PAUSED); return; default: // ignored } if (DBG) P.v("MP [" + mpGetState().name() + "] : pause ignored : "); } private void mpSeekTo(int pos) { if (DBG) P.v("MPlayer - seekTo : " + pos); if (null == mMp) return; switch (mpGetState()) { case PREPARED_AUDIO: case PREPARED: case STARTED: case PAUSED: case PLAYBACK_COMPLETED: mpSetStateFlagBit(MPSTATE_FLAG_SEEKING); mMp.seekTo(pos); return; default: // ignored } if (DBG) P.v("MP [" + mpGetState().name() + "] : seekTo ignored : "); } private void mpStart() { if (DBG) P.v("MPlayer - start"); if (null == mMp) return; if (ytpIsSuspended()) return; switch (mpGetState()) { case PREPARED: case STARTED: case PAUSED: case PLAYBACK_COMPLETED: if (DBG) P.v("MP" + System.identityHashCode(mMp) + " start"); mMp.start(); mpSetState(MPState.STARTED); return; default: // ignored } if (DBG) P.v("MP [" + mpGetState().name() + "] : start ignored : "); } private void mpStop() { if (DBG) P.v("MPlayer - stop"); if (null == mMp) return; switch (mpGetState()) { case PREPARING: case PREPARED_AUDIO: case PREPARED: case STARTED: case PAUSED: case STOPPED: case PLAYBACK_COMPLETED: if (DBG) P.v("MP" + System.identityHashCode(mMp) + " stop"); mMp.stop(); mpSetState(MPState.STOPPED); return; default: // ignored } if (DBG) P.v("MP [" + mpGetState().name() + "] : stop ignored : "); } // ======================================================================== // // Video Surface Control // // ======================================================================== private boolean isVideoMode() { return null != mSurfHolder; } private void setSurfaceReady(boolean ready) { mSurfReady = ready; } private void setVideoSizeReady(boolean ready) { mVSzReady = ready; } private boolean isSurfaceReady() { return mSurfReady; } private boolean isVideoSizeReady() { return mVSzReady; } // ======================================================================== // // Suspending/Resuming Control // // ======================================================================== private void ytpSuspendPlaying() { P.bug(AUtil.isUiThread()); playerPause(); mYtpS = YTPState.SUSPENDED; } private void ytpResumePlaying() { P.bug(AUtil.isUiThread()); mYtpS = YTPState.IDLE; } private boolean ytpIsSuspended() { P.bug(AUtil.isUiThread()); return YTPState.SUSPENDED == mYtpS; } // ======================================================================== // // TTS Control // // ======================================================================== private void ttsSetState(TTSState newState) { mTtsState = newState; } private TTSState ttsGetState() { return mTtsState; } private boolean ttsIsReady() { return TTSState.READY == ttsGetState(); } private void ttsOpen() { if (TTSState.NOTUSED != ttsGetState()) { if (DBG) P.i("TTS already opened"); return; } ttsSetState(TTSState.PREPARING); mTts = new TextToSpeech(AppEnv.getAppContext(), this); } private void ttsSpeak(final String text, final String ytvid, final Runnable followingAction) { if (!ttsIsReady()) return; mTts.setOnUtteranceProgressListener(new UtteranceProgressListener() { @Override public void onStart(String utteranceId) { } @Override public void onError(String utteranceId) { } @Override public void onDone(final String utteranceId) { AppEnv.getUiHandler().postDelayed(new Runnable() { @Override public void run() { Video v = mVlm.getActiveVideo(); // NOTE : IMPORTANT // ttsSpeak->ytvid is NOT available here! // This is doublely - 2nd level - nested function. if (null != v && utteranceId.equals(v.v.ytvid)) followingAction.run(); } }, PolicyConstant.YTPLAYER_TTS_MARGIN_TIME); } }); try { Thread.sleep(PolicyConstant.YTPLAYER_TTS_MARGIN_TIME); } catch (InterruptedException ignored) {} HashMap<String, String> param = new HashMap<>(); param.put(TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID, ytvid); mTts.speak(text, TextToSpeech.QUEUE_FLUSH, param); } private void ttsStop() { if (TTSState.READY == ttsGetState() && null != mTts) mTts.stop(); } private void ttsClose() { if (TTSState.NOTUSED == ttsGetState()) { if (DBG) P.i("TTS already closed"); return; } ttsSetState(TTSState.NOTUSED); mTts.shutdown(); mTts = null; } // Implements TextToSpeech.OnInitListener. @Override public void onInit(int status) { // status can be either TextToSpeech.SUCCESS or TextToSpeech.ERROR. if (status == TextToSpeech.SUCCESS) { // set to current locale. int result = mTts.setLanguage(AppEnv.getAppContext().getResources().getConfiguration().locale); if (result == TextToSpeech.LANG_MISSING_DATA || result == TextToSpeech.LANG_NOT_SUPPORTED) { // Language data is missing or the language is not supported. if (DBG) P.w("Language is not available."); // code to show toast here is... really acceptable in terms of software design? //UxUtil.showTextToast(AppEnv.getAppContext(), R.string.msg_couldnt_use_title_tts); ttsClose(); } else { // The TTS engine has been successfully initialized. // Allow the user to press the button for the app to speak again. // Read to use TTS ttsSetState(TTSState.READY); } } else { // Initialization failed. if (DBG) P.w("Could not initialize TextToSpeech."); // code to show toast here is... really acceptable in terms of software design? //UxUtil.showTextToast(AppEnv.getAppContext(), R.string.msg_couldnt_use_title_tts); ttsClose(); } } // ======================================================================== // // General Control // // ======================================================================== private void onMpStateChanged(MPState from, int fromFlag, MPState to, int toFlag) { for (PlayerStateListener l : mPStateLsnrl) l.onStateChanged(from, fromFlag, to, toFlag); switch (to) { case PAUSED: case INVALID: releaseLocks(); break; case STARTED: acquireLocks(); break; case STOPPED: mYtHackTask.removeEventListener(mYtHackTaskListener); mTm.cancelTask(mYtHackTask, null); break; default: // ignored } } private boolean isPreparedCompletely() { return (!isVideoMode() && MPState.PREPARED_AUDIO == mpGetState()) || (isVideoMode() && MPState.PREPARED_AUDIO == mpGetState() && isSurfaceReady() && isVideoSizeReady()); } private int getVideoQualityScore() { Util.PrefQuality pq = Util.getPrefQuality(); //Keep below code for future refactoring.(It is always 'false') //noinspection ConstantConditions if (null == pq) return YTHackTask.getQScorePreferLow(mapPrefToQScore(Util.PrefQuality.LOW)); int qscore = mapPrefToQScore(pq); switch (pq) { case LOW: case MIDLOW: return YTHackTask.getQScorePreferLow(qscore); case NORMAL: case HIGH: case VERYHIGH: return YTHackTask.getQScorePreferHigh(qscore); } P.bug(false); return YTHackTask.getQScorePreferLow(qscore); } private static String getCachedVideoFilePath(String ytvid, Util.PrefQuality quality) { // Only mp4 is supported by YTHackTask. // WebM and Flv is not supported directly in Android's MediaPlayer. // So, Mpeg is only option we can choose. return PolicyConstant.APPDATA_CACHEDIR + ytvid + "-" + quality.name() + ".mp4"; } @NonNull private static String getYtvidOfCachedFile(String path) { int idStartI = path.lastIndexOf('/') + 1; int idEndI = path.lastIndexOf('-'); P.bug(11 == path.substring(idStartI, idEndI).length()); return path.substring(idStartI, idEndI); } @NonNull static File getCachedVideo(String ytvid) { return new File(getCachedVideoFilePath(ytvid, Util.getPrefQuality())); } private void cachingVideo(final String ytvid, long delayms) { P.bug(AUtil.isUiThread()); File cacheFile = getCachedVideo(ytvid); if ((cacheFile.exists() && cacheFile.canRead()) // already cached or under downloading || (mYtCachingTask.tmId().equals(YTDownloadTask.tmId(cacheFile)) /* order is important. 'isReady' should be checked prior 'isRunning' * because thread state is changed from 'ready' to 'started'. */ && (mYtCachingTask.isReady() || mYtCachingTask.isRunning()))) return; mTm.cancelTask(mYtCachingTask, null); YTDownloadTask.Builder<YTDownloadTask.Builder> ytdnb = new YTDownloadTask.Builder<>(cacheFile, ytvid, getVideoQualityScore()); final YTDownloadTask ytdnTask = ytdnb.create(); AppEnv.getUiHandler().postDelayed(new Runnable() { public void run() { if (!mTm.addTask( ytdnTask, ytdnTask.tmId(), TaskType.CACHING, null)) // Same operation is already under running! // So, ignore current request. if (DBG) P.w("Caching request ignored(Already running): " + ytdnTask.getYtvid()); } }, delayms); /* Retry mechanism when caching operation fails, is not implemented. * (We may need to add listener and check where success or not and do something...) * TODO: Is this(retry) really usful? */ } private void stopCaching() { mTm.cancelTask(mYtCachingTask, null); } private void cleanCache(boolean allClear) { if (!mVlm.hasActiveVideo()) allClear = true; HashSet<String> skipSet = new HashSet<>(); if (!allClear) { // delete all cached videos except for // current and next video. for (Util.PrefQuality pq : Util.PrefQuality.values()) { skipSet.add(new File(getCachedVideoFilePath(mVlm.getActiveVideo().v.ytvid, pq)).getAbsolutePath()); Video nextVid = mVlm.getNextVideo(); if (null != nextVid) skipSet.add(new File(getCachedVideoFilePath(nextVid.v.ytvid, pq)).getAbsolutePath()); } } FileUtil.cleanDirectory(sCacheDir, skipSet); } private void prepareNext() { if (!mVlm.hasNextVideo()) { stopCaching(); return; } cachingVideo(mVlm.getNextVideo().v.ytvid, PolicyConstant.YTPLAYER_CACHING_DELAY); } private void preparePlayerAsync() { final MediaPlayer mp = mpGet(); AppEnv.getUiHandler().post(new Runnable() { private int retry = 20; @Override public void run() { if (mp != mpGet()) return; // ignore preparing for old media player. if (retry < 0) { if (DBG) P.w("YTPlayer : video surface is never created! Preparing will be stopped."); mpStop(); return; } if (!isVideoMode() || isSurfaceReady()) { mpSetVideoSurface(mSurfHolder); mpPrepareAsync(); } else { --retry; AppEnv.getUiHandler().postDelayed(this, 100); } } }); } private void prepareVideoStreamingFromYtHack(YTHackTask ythack) { YTHackTask.YtVideo ytv = ythack.getVideo(getVideoQualityScore(), false); if (null == ytv) { // Video format is not supported... // Just skip it with toast! mUi.notifyToUser(AUtil.getResString(R.string.err_ytnot_supported_vidformat)); startNext(); return; } try { mpSetDataSource(ytv.url); } catch (IOException e) { if (DBG) P.w("YTPlayer SetDataSource IOException : " + e.getMessage()); mStartVideoRecovery.executeRecoveryStart(mVlm.getActiveVideo(), 500); return; } preparePlayerAsync(); } private void prepareVideoStreaming(final String ytvid) { P.bug(AUtil.isUiThread()); if (DBG) P.v("ytid : " + ytvid); YTHackTask hack = RTState.get().getCachedYtHack(ytvid); if (null != hack && ytvid.equals(hack.getYtvid()) && (System.currentTimeMillis() - hack.getHackTimeStamp()) < PolicyConstant.YTHACK_REUSE_TIMEOUT) { P.bug(hack.hasHackedResult()); // Let's try to reuse it. prepareVideoStreamingFromYtHack(hack); return; } YTHackTask.Builder<YTHackTask.Builder> hb = new YTHackTask.Builder<>(ytvid); final YTHackTask hackTask = hb.create(); hackTask.addEventListener(AppEnv.getUiHandlerAdapter(), mYtHackTaskListener); if (mTm.addTask( hackTask, "YTPlayer.HACK:" + ytvid, TaskType.HACK, null)) mYtHackTask = hackTask; else P.w("YTHackTask already running. Request is ignored. ID:" + ytvid); } private void prepareCachedVideo(File cachedVid) { if (DBG) P.v("video file path: " + cachedVid.getAbsolutePath()); // We have cached one. // So play in local! try { mpSetDataSource(cachedVid.getAbsolutePath()); } catch (IOException e) { // Something wrong at cached file. // Clean cache and try again - next time as streaming! cleanCache(true); if (DBG) P.w("YTPlayer SetDataSource to Cached File IOException : " + e.getMessage()); mStartVideoRecovery.executeRecoveryStart(mVlm.getActiveVideo()); return; } preparePlayerAsync(); } private void startVideo(Video v, boolean recovery) { if (null != v) startVideo(v.v.ytvid, v.v.title, (int) v.v.volume, recovery); } private void startVideo(final String ytvid, final String title, final int volume, boolean recovery) { P.bug(0 <= volume && volume <= 100); // Reset flag regarding video size. setVideoSizeReady(false); // Clean recovery try mStartVideoRecovery.cancel(); // Whenever start videos, try to clean cache. cleanCache(false); if (recovery) { mErrRetry--; if (mErrRetry <= 0) { if (Util.isNetworkAvailable()) { mUi.notifyToUser(AUtil.getResString(R.string.err_play_video)); if (mVlm.hasNextVideo()) { if (mVlm.hasActiveVideo()) { Video v = mVlm.getActiveVideo(); if (DBG) P.w("YTPlayer: Recovery play fails: " + v.infoString()); } startNext(); // move to next video. } else stopPlay(StopState.FAIL_PLAYING); } else stopPlay(StopState.NETWORK_UNAVAILABLE); return; } } else mErrRetry = PLAYER_ERR_RETRY; mTm.cancelTask(mYtHackTask, null); // Stop if player is already running. mpStop(); mpRelease(); mpNewInstance(); mpReset(); mpSetVolume(volume); // Update DB at this moment. // It's not perfectly right moment but it's fair enough new Thread(new Runnable() { @Override public void run() { // Updating 'Recently played video' is NOT FATAL operation. // Updating not only seldom fails, but not fatal. // So, exception is ignored for this operation. try { mDb.updateVideoTimePlayed(ytvid, System.currentTimeMillis()); } catch (Exception ignored) { } } }).start(); // NOTE // With early-caching, in case of first video - actually not-cached video, // player tries to streaming main video and simultaneously it also tries to caching next video. // There are two concern points regarding early-caching. // // The 1st is "System performance drop" // The 2nd is "Network bandwidth burden" // // 1st one can be resolve by dropping caching thread's priority. // (Priority of YTDownloadTask thread, is already set as MIDLOW.) // // 2nd one is actually, main concern point. // But usually, in Wifi environment, network bandwidth is large enough versus video-bits-rate. // In mobile network environment, network condition is very unstable. // So, in general, user doens't try to high-quality-video. // // Above two reasons, caching is started as soon as video is started. prepareNext(); Runnable action = new Runnable() { @Override public void run() { File cachedVid = getCachedVideo(ytvid); if (cachedVid.exists() && cachedVid.canRead()) prepareCachedVideo(cachedVid); else { if (!Util.isNetworkAvailable()) mStartVideoRecovery.executeRecoveryStart(new Video(ytvid, title, volume, 0), 1000); else prepareVideoStreaming(ytvid); } } }; if (Util.isPrefHeadTts()) { String text = AUtil.getResString(R.string.tts_title_head_pre) + " " + title + " " + AUtil.getResString(R.string.tts_title_head_post); ttsSpeak(text, ytvid, action); } else action.run(); } private void startNext() { if (!mVlm.hasActiveVideo()) return; // do nothing if (mVlm.moveToNext()) startVideo(mVlm.getActiveVideo(), false); else stopPlay(StopState.DONE); } private void startPrev() { if (!mVlm.hasActiveVideo()) return; // do nothing if (mVlm.moveToPrev()) startVideo(mVlm.getActiveVideo(), false); else stopPlay(StopState.DONE); } private void startAt(int index) { if (!mVlm.hasActiveVideo()) return; // do nothing if (mVlm.moveTo(index)) startVideo(mVlm.getActiveVideo(), false); else stopPlay(StopState.DONE); } private void stopPlay(StopState st) { if (DBG) P.v("YTPlayer stopPlay : " + st.name()); mTm.cancelTask(mYtHackTask, null); ttsStop(); if (StopState.DONE == st && Util.isPrefRepeat()) { if (mVlm.moveToFist()) { startVideo(mVlm.getActiveVideo(), false); return; } } if (StopState.FORCE_STOPPED == st) mUi.setPlayerVisibility(View.GONE); // Play is already stopped. // So, auto stop should be inactive here. mAutoStop.unset(); mpStop(); mpRelease(); releaseLocks(); mVlm.reset(); stopCaching(); mErrRetry = PLAYER_ERR_RETRY; // This should be called before changing title because // title may be changed in onMpStateChanged(). // We need to overwrite title message. mpSetState(MPState.INVALID); for (VideosStateListener l : mVStateLsnrl) l.onStopped(st); } private void storePlayerState() { if (null == mpGet() && !mVlm.hasActiveVideo()) return; // nothing to store // NOTE // Even if last stored player state is not restored and used yet, // new store is requested. // So, play need to keep last state. int storedPos = 0; int storedVol = PolicyConstant.DEFAULT_VIDEO_VOLUME; if (mVlm.hasActiveVideo()) { Long vol = (Long)mDb.getVideoInfo(mVlm.getActiveVideo().v.ytvid, ColVideo.VOLUME); if (null != vol) storedVol = vol.intValue(); } if (haveStoredPlayerState()) { storedPos = mStoredPState.pos; storedVol = mStoredPState.vol; } clearStoredPlayerState(); mStoredPState = new PlayerState(); mStoredPState.mpState = mpGetState(); mStoredPState.vidobj = mVlm.getActiveVideo(); switch(mpGetState()) { case STARTED: case PAUSED: case PREPARED_AUDIO: case PREPARED: mStoredPState.pos = mpGetCurrentPosition(); mStoredPState.vol = mpGetVolume(); break; default: mStoredPState.pos = storedPos; mStoredPState.vol = storedVol; } } private void restorePlayerState() { if (!haveStoredPlayerState()) return; if (mVlm.getActiveVideo() == mStoredPState.vidobj) { mpSeekTo(mStoredPState.pos); mpSetVolume(mStoredPState.vol); } clearStoredPlayerState(); } private void clearStoredPlayerState() { mStoredPState = null; } private boolean haveStoredPlayerState() { return mStoredPState != null; } private boolean isStoredPlayerStatePaused() { switch (mStoredPState.mpState) { case PAUSED: case PREPARED_AUDIO: case PREPARED: return true; default: // ignored } return false; } // ============================================================================ // // Override for "SurfaceHolder.Callback" // // ============================================================================ @Override public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { if (holder != mSurfHolder) { if (DBG) P.w("MPlayer - surfaceCreated with invalid holder"); } if (DBG) P.v("MPlayer - surfaceChanged : " + format + ", " + width + ", " + height); } @Override public void surfaceCreated(SurfaceHolder holder) { if (holder != mSurfHolder) { if (DBG) P.w("MPlayer - surfaceCreated with invalid holder"); } if (DBG) P.v("MPlayer - surfaceCreated"); if (isSurfaceReady()) { if (DBG) P.w("MPlayer - surfaceCreated is called at [surfaceReady]"); } setSurfaceReady(true); if (isPreparedCompletely()) onPreparedCompletely(); } @Override public void surfaceDestroyed(SurfaceHolder holder) { if (holder != mSurfHolder) { if (DBG) P.w("MPlayer - surfaceCreated with invalid holder"); } if (DBG) P.v("MPlayer - surfaceDestroyed"); if (!isSurfaceReady()) { if (DBG) P.w("MPlayer - surfaceDestroyed is called at [NOT-surfaceReady]"); } setSurfaceReady(false); } // ============================================================================ // // Override for "MediaPlayer.*" // // ============================================================================ @Override public void onBufferingUpdate (MediaPlayer mp, int percent) { if (DBG) P.v("MPlayer - onBufferingUpdate : " + percent + " %"); // See comments around MEDIA_INFO_BUFFERING_START in onInfo() //mpSetState(MPState.BUFFERING); for (PlayerStateListener l : mPStateLsnrl) l.onBufferingChanged(percent); } @Override public void onCompletion(MediaPlayer mp) { if (DBG) P.v("MPlayer - onCompletion"); mpSetState(MPState.PLAYBACK_COMPLETED); Runnable action = new Runnable() { @Override public void run() { startNext(); } }; if (mVlm.hasActiveVideo() && Util.isPrefTailTts()) { Video v = mVlm.getActiveVideo(); String text = AUtil.getResString(R.string.tts_title_tail_pre) + " " + v.v.title + " " + AUtil.getResString(R.string.tts_title_tail_post); ttsSpeak(text, v.v.ytvid, action); } else action.run(); } private void onPreparedCompletely() { if (DBG) P.v("MPlayer - onPreparedInternal"); boolean autoStart = true; if (haveStoredPlayerState()) { autoStart = !isStoredPlayerStatePaused(); restorePlayerState(); } else if (mVlm.hasActiveVideo()) mpSeekTo(mVlm.getActiveVideo().startpos); clearStoredPlayerState(); mpSetState(MPState.PREPARED); if (autoStart) mpStart(); // auto start } @Override public void onPrepared(MediaPlayer mp) { // OnPreparedListener is very difficult to handle because of it's async-nature. // We may request some other works to media player even in "preparing" state // - ex. Stop and start new preparation after create new media player instance. // Especially, in this case, player should be able to tell that which callback is for which request. // To do this, ytplayer should compare that current media player is prepared one or not. if (mp != mpGet()) { // ignore. if (DBG) P.v("MPlayer - old invalid player is prepared."); return; } mpSetState(MPState.PREPARED_AUDIO); if (DBG) P.v("MPlayer - onPrepared - (PREPARED_AUDIO)"); if (isPreparedCompletely()) onPreparedCompletely(); } @Override public void onVideoSizeChanged(MediaPlayer mp, int width, int height) { if (DBG) P.v("MPlayer - onVideoSizeChanged"); setVideoSizeReady(true); if (isPreparedCompletely()) onPreparedCompletely(); } @Override public void onSeekComplete(MediaPlayer mp) { if (DBG) P.v("MPlayer - onSeekComplete"); if (mp != mpGet()) return; mpClearStateFlagBit(MPSTATE_FLAG_SEEKING); } @Override public boolean onError(MediaPlayer mp, int what, int extra) { boolean tryAgain = true; if (DBG) P.v("MP" + System.identityHashCode(mp) + " error"); MPState origState = mpGetState(); mpSetState(MPState.ERROR); switch (what) { case MediaPlayer.MEDIA_ERROR_NOT_VALID_FOR_PROGRESSIVE_PLAYBACK: if (DBG) P.v("MPlayer - onError : NOT_VALID_FOR_PROGRESSIVE_PLAYBACK"); tryAgain = false; break; case MediaPlayer.MEDIA_ERROR_SERVER_DIED: if (DBG) P.v("MPlayer - onError : MEDIA_ERROR_SERVER_DIED"); break; case MediaPlayer.MEDIA_ERROR_UNKNOWN: if (DBG) P.v("MPlayer - onError : UNKNOWN"); break; default: if (DBG) P.v("MPlayer - onError"); } if (tryAgain && mVlm.hasActiveVideo() && (MPState.INITIALIZED == origState || MPState.PREPARING == origState)) { Video v = mVlm.getActiveVideo(); if (DBG) P.i("MPlayer - Try to recover: " + v.infoString()); startVideo(v, true); } else { if (!haveStoredPlayerState()) { if (DBG) P.v("MPlayer - not-recoverable error : " + what + "/" + extra); stopPlay(StopState.UNKNOWN_ERROR); } } return true; // DO NOT call onComplete Listener. } @Override public boolean onInfo(MediaPlayer mp, int what, int extra) { if (DBG) P.v("MPlayer - onInfo : " + what); switch (what) { case MediaPlayer.MEDIA_INFO_VIDEO_TRACK_LAGGING: break; case MediaPlayer.MEDIA_INFO_NOT_SEEKABLE: break; case MediaPlayer.MEDIA_INFO_METADATA_UPDATE: break; case MediaPlayer.MEDIA_INFO_BUFFERING_START: mpSetStateFlagBit(MPSTATE_FLAG_BUFFERING); break; case MediaPlayer.MEDIA_INFO_BUFFERING_END: mpClearStateFlagBit(MPSTATE_FLAG_BUFFERING); break; case MediaPlayer.MEDIA_INFO_BAD_INTERLEAVING: break; case MediaPlayer.MEDIA_INFO_UNKNOWN: break; default: } return false; } // ============================================================================ // // Override for "SharedPreferences" // // ============================================================================ @Override public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { if (!AUtil.getResString(R.string.cstitle_tts).equals(key)) return; // don't care others if (Util.isPrefTtsEnabled()) ttsOpen(); else ttsClose(); } // ============================================================================ // // Package interfaces (Usually for YTPLayerUI) // // ============================================================================ boolean hasNextVideo() { return mVlm.hasNextVideo(); } void startNextVideo() { startNext(); } boolean hasPrevVideo() { return mVlm.hasPrevVideo(); } void startPrevVideo() { startPrev(); } void startVideoAt(int videoListIndex) { startAt(videoListIndex); } void removeVideo(String ytvid) { int avi = mVlm.getActiveVideoIndex(); avi = mVlm.findVideoExcept(avi, ytvid); if (mVlm.isValidVideoIndex(avi)) { startAt(avi); mVlm.removeVideo(ytvid); } else { // remove first, and then stop to avoid 'repeat' in case that 'repeat' preference is set. mVlm.removeVideo(ytvid); // There is no video to play after remove. // Just stop playing. stopPlay(StopState.DONE); } } Video getActiveVideo() { return mVlm.getActiveVideo(); } Video[] getVideoList() { return mVlm.getVideoList(); } int getActiveVideoIndex() { return mVlm.getActiveVideoIndex(); } MPState playerGetState() { return mpGetState(); } int playerGetStateFlag() { return mpGetStateFlag(); } void playerStart() { if (isVideoPlaying() && MPState.PREPARING != mpGetState()) mpStart(); } void playerPause() { if (isVideoPlaying() && MPState.PREPARING != mpGetState()) mpPause(); } void playerStop() { mTm.cancelTask(mYtHackTask, null); mpStop(); } /** * Get duration(milliseconds) of current active video */ int playerGetDuration() { return mpGetDuration(); } /** * Get current position(milliseconds) from start */ int playerGetPosition() { return mpGetCurrentPosition(); } int playerGetVolume() { return mpGetVolume(); } /** * Set volume of video-on-play */ void playerSetVolume(int vol) { P.bug(0 <= vol && vol <= 100); mpSetVolume(vol); } boolean isAutoStopSet() { return mAutoStop.isSet(); } /** * * @return * absolute time (NOT time gap since now.) */ long getAutoStopTime() { return mAutoStop.getTime(); } // ============================================================================ // // Public interfaces // // ============================================================================ private YTPlayer() { UnexpectedExceptionHandler.get().registerModule(this); // mYtHackTask and mYtCachingTask are set as invalid initial instance to avoid checking 'null' YTHackTask.Builder<YTHackTask.Builder> hb = new YTHackTask.Builder<>(""); mYtHackTask = hb.create(); YTDownloadTask.Builder<YTDownloadTask.Builder> dnb = new YTDownloadTask.Builder<>(new File(""), "", 0); mYtCachingTask = dnb.create(); mVlm = new YTPlayerVideoListManager(new YTPlayerVideoListManager.OnListChangedListener() { @Override public void onChanged(YTPlayerVideoListManager vm) { P.bug(AUtil.isUiThread()); mUi.updateLDrawerList(); for (VideosStateListener l : mVStateLsnrl) l.onPlayQChanged(); } }); // Check TTS usage if (Util.isPrefTtsEnabled()) ttsOpen(); PreferenceManager.getDefaultSharedPreferences(AppEnv.getAppContext()) .registerOnSharedPreferenceChangeListener(this); } @Override public String dump(UnexpectedExceptionHandler.DumpLevel lvl) { return this.getClass().getName(); } public static YTPlayer get() { if (null == sInstance) sInstance = new YTPlayer(); return sInstance; } public static int mapPrefToQScore(Util.PrefQuality prefq) { if (null == prefq) { P.bug(false); return YTHackTask.YTQUALITY_SCORE_LOWEST; } switch (prefq) { case LOW: return YTHackTask.YTQUALITY_SCORE_LOWEST; case MIDLOW: return YTHackTask.YTQUALITY_SCORE_LOW; case NORMAL: return YTHackTask.YTQUALITY_SCORE_MIDLOW; case HIGH: return YTHackTask.YTQUALITY_SCORE_HIGH; case VERYHIGH: return YTHackTask.YTQUALITY_SCORE_HIGHEST; } P.bug(false); return YTHackTask.YTQUALITY_SCORE_LOWEST; } /** * Closing cursor is caller's responsibility. * @param c Cursor that is created by using "sVideoProjectionToPlay" * Closing cursor is this function's responsibility. */ public static Video[] getVideos(Cursor c, boolean shuffle) { if (!c.moveToFirst()) return new Video[0]; Video[] vs = new Video[c.getCount()]; int i = 0; do { vs[i++] = new Video(c.getString(COLI_VID_YTVID), c.getString(COLI_VID_TITLE), c.getInt(COLI_VID_VOLUME), 0); } while (c.moveToNext()); if (!shuffle) Arrays.sort(vs, sVideoTitleComparator); else { // This is shuffled case! Random r = new Random(System.currentTimeMillis()); NrElem[] nes = new NrElem[vs.length]; for (i = 0; i < nes.length; i++) nes[i] = new NrElem(r.nextInt(), vs[i]); Arrays.sort(nes, sNrElemComparator); for (i = 0; i < nes.length; i++) vs[i] = (Video)nes[i].tag; } return vs; } public void addVideosStateListener(VideosStateListener listener) { P.bug(null != listener); mVStateLsnrl.add(listener); } public void removeVideosStateListener(VideosStateListener listener) { mVStateLsnrl.remove(listener); } public void addPlayerStateListener(PlayerStateListener listener) { P.bug(null != listener); mPStateLsnrl.add(listener); } public void removePlayerStateListener(PlayerStateListener listener) { mPStateLsnrl.remove(listener); } public void addOnDbUpdatedListener(YTPlayer.OnDBUpdatedListener listener) { mUi.addOnDbUpdatedListener(listener); } public void removeOnDbUpdatedListener(YTPlayer.OnDBUpdatedListener listener) { mUi.removeOnDbUpdatedListener(listener); } public void setSurfaceHolder(SurfaceHolder holder) { if (null != mSurfHolder && mSurfHolder != holder) unsetSurfaceHolder(mSurfHolder); mSurfHolder = holder; if (null != holder) holder.addCallback(this); } public void unsetSurfaceHolder(SurfaceHolder holder) { if (null != mSurfHolder && mSurfHolder == holder) { mSurfHolder.removeCallback(this); mSurfHolder = null; setSurfaceReady(false); } } public void detachVideoSurface(SurfaceHolder holder) { if (null != mSurfHolder && mSurfHolder == holder) { mpUnsetVideoSurface(); } } public ToolButton getVideoToolButton() { View.OnClickListener onClick = new View.OnClickListener() { @Override public void onClick(View v) { if (!hasActiveVideo() || null == mUi.getActivity()) return; backupPlayerState(); playerStop(); mUi.getActivity().startActivity(new Intent(mUi.getActivity(), VideoPlayerActivity.class)); } }; return new ToolButton(R.drawable.ic_media_video, onClick); } public void setController(Activity activity, ViewGroup playerv, ViewGroup playerLDrawer, SurfaceView surfacev, ToolButton toolBtn) { mUi.setController(activity, playerv, playerLDrawer, surfacev, toolBtn); if (!mVlm.hasActiveVideo()) return; // controller is set again. // Than new surface may have to be used. // This is completely DIRTY and RISKY... // Until to find any other better way... // (I HATE THIS KIND OF HACK!!! :-( ) if (!haveStoredPlayerState() && null != surfacev && null != mSurfHolder && surfacev.getHolder() == mSurfHolder && !mpIsSurfaceAttached()) { // Video surface should be set again. // This is Android MediaPlayer's constraints. // Video SHOULD BE re-started! backupPlayerState(); mpStop(); } if (haveStoredPlayerState()) startVideo(mVlm.getActiveVideo(), false); } public void unsetController(Activity activity) { mUi.unsetController(activity); } public void changeVideoVolume(final String title, final String ytvid) { mUi.changeVideoVolume(title, ytvid); } public boolean hasActiveVideo() { return mVlm.hasActiveVideo(); } public boolean isVideoPlaying() { return mVlm.hasActiveVideo() && MPState.ERROR != mMpS && MPState.END != mMpS; } public boolean isPlayerSeeking() { return Util.bitIsSet(mpGetStateFlag(), MPSTATE_FLAG_SEEKING); } public boolean isPlayerBuffering() { return Util.bitIsSet(mpGetStateFlag(), MPSTATE_FLAG_BUFFERING); } /** * Get volume of video-on-play * @return -1 : for error */ public int getVideoVolume() { if (isVideoPlaying()) return mpGetVolume(); return DB.INVALID_VOLUME; } /** * * @param pos milliseconds. */ public void playerSeekTo(int pos) { mpSeekTo(pos); } public void restartFromCurrentPosition() { if (!mVlm.hasActiveVideo()) return; backupPlayerState(); startVideo(mVlm.getActiveVideo(), false); } public void startVideos(final Video[] vs) { P.bug(AUtil.isUiThread()); if (null == vs || vs.length <= 0) return; acquireLocks(); clearStoredPlayerState(); // removes auto stop that is set before. mAutoStop.unset(); mVlm.setVideoList(vs); if (mVlm.moveToFist()) { startVideo(mVlm.getActiveVideo(), false); for (VideosStateListener l : mVStateLsnrl) l.onStarted(); mUi.setPlayerVisibility(View.VISIBLE); } } /** * @param c Cursor that is created by using "sVideoProjectionToPlay" * Closing cursor is this function's responsibility. */ public void startVideos(final Cursor c, final boolean shuffle) { P.bug(AUtil.isUiThread()); new Thread(new Runnable() { @Override public void run() { final Video[] vs = getVideos(c, shuffle); AppEnv.getUiHandler().post(new Runnable() { @Override public void run() { startVideos(vs); } }); c.close(); } }).start(); } public void backupPlayerState() { storePlayerState(); } public void appendToPlayQ(Video[] vids) { if (mVlm.hasActiveVideo()) mVlm.appendVideo(vids); else startVideos(vids); } /** * player will be stopped after 'millis' * @param millis 0 for unset autostop */ public void setAutoStop(long millis) { mAutoStop.set(millis); } public void unsetAutoStop() { mAutoStop.unset(); } public void stopVideos() { if (mVlm.hasActiveVideo()) stopPlay(StopState.FORCE_STOPPED); } /** * Player session id. * Even if same video is re-played, session id is different. */ public long getPlayerSessionId() { return mMpSessId; } public MPState getPlayerState() { return mMpS; } public String getActiveVideoYtId() { if (isVideoPlaying()) return mVlm.getActiveVideo().v.ytvid; return null; } public int getVideoWidth() { return mpGetVideoWidth(); } public int getVideoHeight() { return mpGetVideoHeight(); } @SuppressWarnings("unused") public int getProgressPercent() { int progPercent = 0; if (mVlm.hasActiveVideo() && mpGetDuration() > 0) progPercent = (int)(mpGetCurrentPosition() * 100L / mpGetDuration()); return progPercent; } }