package com.kaltura.playersdk.players;
import android.annotation.TargetApi;
import android.content.Context;
import android.media.MediaDrm;
import android.net.Uri;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.support.annotation.NonNull;
import android.view.Gravity;
import android.view.Surface;
import android.view.SurfaceHolder;
import android.view.accessibility.CaptioningManager;
import android.widget.FrameLayout;
import com.google.android.exoplayer.BehindLiveWindowException;
import com.google.android.exoplayer.ExoPlaybackException;
import com.google.android.exoplayer.ExoPlayer;
import com.google.android.exoplayer.MediaCodecTrackRenderer;
import com.google.android.exoplayer.MediaCodecUtil;
import com.google.android.exoplayer.drm.UnsupportedDrmException;
import com.google.android.exoplayer.metadata.id3.GeobFrame;
import com.google.android.exoplayer.metadata.id3.Id3Frame;
import com.google.android.exoplayer.metadata.id3.PrivFrame;
import com.google.android.exoplayer.metadata.id3.TxxxFrame;
import com.google.android.exoplayer.text.CaptionStyleCompat;
import com.google.android.exoplayer.text.Cue;
import com.google.android.exoplayer.text.SubtitleLayout;
import com.google.android.exoplayer.util.Util;
import com.google.android.libraries.mediaframework.exoplayerextensions.ExoplayerUtil;
import com.google.android.libraries.mediaframework.exoplayerextensions.ExoplayerWrapper;
import com.google.android.libraries.mediaframework.exoplayerextensions.RendererBuilderFactory;
import com.google.android.libraries.mediaframework.exoplayerextensions.Video;
import com.google.android.libraries.mediaframework.layeredvideo.VideoSurfaceView;
import com.kaltura.playersdk.PlayerViewController;
import com.kaltura.playersdk.tracks.TrackFormat;
import com.kaltura.playersdk.tracks.TrackType;
import java.io.FileNotFoundException;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import static com.kaltura.playersdk.utils.LogUtils.LOGD;
import static com.kaltura.playersdk.utils.LogUtils.LOGE;
/**
* Created by noamt on 18/01/2016.
*/
public class KExoPlayer extends FrameLayout implements KPlayer, ExoplayerWrapper.PlaybackListener ,ExoplayerWrapper.CaptionListener, ExoplayerWrapper.Id3MetadataListener {
private static final String TAG = "KExoPlayer";
private static final long PLAYHEAD_UPDATE_INTERVAL = 200;
@NonNull private KPlayerListener mPlayerListener = noopPlayerListener();
@NonNull private KPlayerCallback mPlayerCallback = noopEventListener();
@NonNull private PlayerState mSavedState = new PlayerState();
@NonNull private Handler mPlaybackTimeReporter = new Handler(Looper.getMainLooper());
private String mSourceURL;
private boolean mShouldCancelPlay;
private ExoplayerWrapper mExoPlayer;
private KState mReadiness = KState.IDLE;
private KPlayerExoDrmCallback mDrmCallback;
private VideoSurfaceView mSurfaceView;
private com.google.android.exoplayer.text.SubtitleLayout mSubtView;
private boolean mSeeking;
private boolean mBuffering = false;
private boolean mPassedPlay = false;
private boolean prepareWithConfigurationMode = false;
private boolean isFirstPlayback = true;
private SurfaceHolder.Callback mSurfaceCallback;
public static Set<KMediaFormat> supportedFormats(Context context) {
Set<KMediaFormat> set = new HashSet<>();
// Clear dash and mp4 are always supported by this player.
set.add(KMediaFormat.dash_clear);
set.add(KMediaFormat.mp4_clear);
set.add(KMediaFormat.hls_clear);
// Encrypted dash is only supported in Android v4.3 and up -- needs MediaDrm class.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) {
// Make sure Widevine is supported.
if (MediaDrm.isCryptoSchemeSupported(ExoplayerUtil.WIDEVINE_UUID)) {
set.add(KMediaFormat.dash_widevine);
}
}
return set;
}
public KExoPlayer(Context context) {
super(context);
}
private KPlayerListener noopPlayerListener() {
return new KPlayerListener() {
public void eventWithValue(KPlayer player, String eventName, String eventValue) {}
public void eventWithJSON(KPlayer player, String eventName, String jsonValue) {}
public void asyncEvaluate(String expression, String expressionID, PlayerViewController.EvaluateListener evaluateListener) {}
public void contentCompleted(KPlayer currentPlayer) {}
};
}
private KPlayerCallback noopEventListener() {
return new KPlayerCallback() {
public void playerStateChanged(int state) {}
};
}
// KPlayer implementation
@Override
public void setPlayerListener(@NonNull KPlayerListener listener) {
mPlayerListener = listener;
}
@Override
public void setPlayerCallback(@NonNull KPlayerCallback callback) {
mPlayerCallback = callback;
}
@Override
public void setPlayerSource(String playerSource) {
mSourceURL = playerSource;
mReadiness = KState.IDLE;
isFirstPlayback = true;
prepare();
}
private Video.VideoType getVideoType() {
String videoFileName = Uri.parse(mSourceURL).getLastPathSegment();
switch (videoFileName.substring(videoFileName.lastIndexOf('.')).toLowerCase()) {
case ".mpd":
return Video.VideoType.DASH;
case ".mp4":
return Video.VideoType.MP4;
case ".m3u8":
return Video.VideoType.HLS;
default:
return Video.VideoType.OTHER;
}
}
@Override
public boolean isPlaying() {
return mExoPlayer != null
&& mExoPlayer.getPlayWhenReady();
}
@Override
public void switchToLive() {
mExoPlayer.seekTo(0);
}
private void prepare() {
if (mReadiness != KState.IDLE) {
LOGD(TAG, "Already preparing");
return;
}
mReadiness = KState.PREPARING;
boolean offline = mSourceURL.startsWith("file://");
mDrmCallback = new KPlayerExoDrmCallback(getContext(), offline);
Video video = new Video(mSourceURL, getVideoType());
final ExoplayerWrapper.RendererBuilder rendererBuilder = RendererBuilderFactory
.createRendererBuilder(getContext(), video, mDrmCallback);
mSurfaceView = new VideoSurfaceView(getContext());
LayoutParams layoutParams = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT, Gravity.CENTER);
mExoPlayer = new ExoplayerWrapper(rendererBuilder);
Surface surface = mSurfaceView.getHolder().getSurface();
if (surface != null) {
mExoPlayer.setSurface(surface);
}
mExoPlayer.setCaptionListener(this);
mExoPlayer.setMetadataListener(this);
configureSubtitleView();
mExoPlayer.addListener(this);
mExoPlayer.prepare();
mSurfaceCallback = new SurfaceHolder.Callback() {
@Override
public void surfaceCreated(SurfaceHolder holder) {
if (mExoPlayer != null && mExoPlayer.getSurface() == null) {
mExoPlayer.setSurface(holder.getSurface());
mReadiness = KState.READY;
mExoPlayer.addListener(KExoPlayer.this);
}
}
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
LOGD(TAG, "surfaceChanged(" + format + "," + width + "," + height + ")");
}
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
LOGD(TAG, "surfaceDestroyed");
if (mExoPlayer != null) {
mExoPlayer.blockingClearSurface();
mExoPlayer.removeListener(KExoPlayer.this);
}
}
};
mSurfaceView.getHolder().addCallback(mSurfaceCallback);
LOGD(TAG, "KExoPlaer prepareWithConfigurationMode " + prepareWithConfigurationMode);
if(!prepareWithConfigurationMode) {
this.addView(mSurfaceView, layoutParams);
mSubtView = new com.google.android.exoplayer.text.SubtitleLayout(getContext());
this.addView(mSubtView, layoutParams);
}
}
@Override
public void setCurrentPlaybackTime(long time) {
mSeeking = true;
mReadiness = KState.SEEKING;
stopPlaybackTimeReporter();
if (mExoPlayer != null) {
mExoPlayer.seekTo(time);
}
}
@Override
public long getCurrentPlaybackTime() {
if (mExoPlayer != null) {
return mExoPlayer.getCurrentPosition();
}
return 0;
}
@Override
public long getDuration() {
if (mExoPlayer != null) {
return mExoPlayer.getDuration();
}
return 0;
}
@Override
public void play() {
if (isPlaying() || mReadiness == KState.PLAYING) {
mPassedPlay = true;
return;
}
LOGD(TAG, "action: play called");
if (mShouldCancelPlay) {
mShouldCancelPlay = false;
mReadiness = KState.IDLE;
return;
}
if (mReadiness == KState.IDLE && mReadiness != KState.ENDED) {
prepare();
mReadiness = KState.PLAYING;
return;
}
mPassedPlay = true;
mReadiness = KState.PLAYING;
setPlayWhenReady(true);
if (mSavedState.position != 0) {
setCurrentPlaybackTime(mSavedState.position);
mSavedState.position = 0;
}
startPlaybackTimeReporter();
}
@Override
public void pause() {
KState prevState = mReadiness;
if (mReadiness == KState.PAUSED) {
return;
}
LOGD(TAG, "action: pause called");
mReadiness = KState.PAUSED;
stopPlaybackTimeReporter();
if (isPlaying()) {
setPlayWhenReady(false);
}
if (prevState == KState.IDLE) {
setPlayWhenReady(true);
}
}
private void startPlaybackTimeReporter() {
mPlaybackTimeReporter.removeMessages(0); // Stop reporter if already running
mPlaybackTimeReporter.post(new Runnable() {
@Override
public void run() {
if (mExoPlayer != null) {
maybeReportPlaybackTime();
mPlaybackTimeReporter.postDelayed(this, PLAYHEAD_UPDATE_INTERVAL);
}
}
});
}
private void stopPlaybackTimeReporter() {
LOGD(TAG, "remove handler callbacks");
mPlaybackTimeReporter.removeMessages(0);
}
private void maybeReportPlaybackTime() {
long position = getCurrentPlaybackTime();
if (position != 0 && position < getDuration() && isPlaying()) {
mPlayerListener.eventWithValue(KExoPlayer.this, KPlayerListener.TimeUpdateKey, Float.toString(position / 1000f));
}
}
private void saveState() {
if (mExoPlayer != null) {
mSavedState.set(isPlaying(), getCurrentPlaybackTime());
} else {
mSavedState.set(false, 0);
}
}
@Override
public void freezePlayer() {
if (mExoPlayer != null && mExoPlayer.getSurface() == null) {
mExoPlayer.setBackgrounded(true);
}
}
@Override
public void removePlayer() {
stopPlaybackTimeReporter();
pause();
if (mExoPlayer != null) {
mExoPlayer.release();
mExoPlayer = null;
}
mReadiness = KState.IDLE;
}
@Override
public void recoverPlayer(boolean isPlaying) {
if (mExoPlayer != null && mExoPlayer.getSurface() == null) {
mExoPlayer.setBackgrounded(false);
if (isPlaying) {
mPlayerListener.eventWithValue(this, KPlayerListener.PlayKey, null);
}
}
}
@Override
public void setShouldCancelPlay(boolean shouldCancelPlay) {
mShouldCancelPlay = shouldCancelPlay;
// TODO
}
@Override
public void setLicenseUri(final String licenseUri) {
mDrmCallback.setLicenseUri(licenseUri);
}
@Override
public void attachSurfaceViewToPlayer() {
if (prepareWithConfigurationMode) {
LOGD(TAG, "KExoPlayer attachSurfaceViewToPlayer " + prepareWithConfigurationMode);
LayoutParams layoutParams = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT, Gravity.CENTER);
this.addView(mSurfaceView, layoutParams);
mSubtView = new com.google.android.exoplayer.text.SubtitleLayout(getContext());
this.addView(mSubtView, layoutParams);
}
}
@Override
public void detachSurfaceViewFromPlayer() {
if (prepareWithConfigurationMode) {
this.removeView(mSubtView);
this.removeView(mSurfaceView);
}
}
@Override
public void setPrepareWithConfigurationMode() {
prepareWithConfigurationMode = true;
}
@Override
public void setPrepareWithConfigurationModeOff() {
prepareWithConfigurationMode = false;
}
// private void savePlayerState() {
// saveState();
// pause();
// }
//
// private void recoverPlayerState() {
// setCurrentPlaybackTime(mSavedState.position);
// if (mSavedState.playing) {
// play();
// }
// }
// PlaybackListener
@Override
public void onStateChanged(boolean playWhenReady, int playbackState) {
LOGD(TAG, "PlayerStateChanged: " + playbackState + " playWhenReady: " + playWhenReady + " mPassedPlay: " + mPassedPlay + " mReadiness: " + mReadiness + " mSeeking: " + mSeeking);
switch (playbackState) {
case ExoPlayer.STATE_IDLE:
if (mSeeking) {
mSeeking = false;
}
break;
case ExoPlayer.STATE_PREPARING:
break;
case ExoPlayer.STATE_BUFFERING:
LOGD(TAG, "STATE_BUFFERING mReadiness: " + mReadiness + " playWhenReady: " + playWhenReady);
if (mPlayerListener != null) {
mPlayerListener.eventWithValue(this, KPlayerListener.BufferingChangeKey, "true");
mBuffering = true;
}
break;
case ExoPlayer.STATE_READY:
LOGD(TAG, "STATE_READY mReadiness: " + mReadiness + " mSeeking: " + mSeeking + " mBuffering: " + mBuffering + " playWhenReady: " + playWhenReady);
if (mPlayerListener != null && mPlayerCallback != null) {
if (mBuffering) {
mPlayerListener.eventWithValue(this, KPlayerListener.BufferingChangeKey, "false");
mBuffering = false;
}
if ((mReadiness == KState.READY || mReadiness == KState.PAUSED) && !playWhenReady) {
LOGD(TAG, "Change to PauseKey");
mPlayerListener.eventWithValue(this, KPlayerListener.PauseKey, null);
}
// ExoPlayer is ready.
if (isFirstPlayback) {
isFirstPlayback = false;
mReadiness = KState.READY;
// TODO what about mShouldResumePlayback?
mPlayerListener.eventWithValue(this, KPlayerListener.DurationChangedKey, Float.toString(this.getDuration() / 1000f));
mPlayerListener.eventWithValue(this, KPlayerListener.LoadedMetaDataKey, "");
mPlayerListener.eventWithValue(this, KPlayerListener.CanPlayKey, null);
mPlayerCallback.playerStateChanged(KPlayerCallback.CAN_PLAY);
}
if (mSeeking) {
// ready after seeking
mReadiness = KState.READY;
mPlayerListener.eventWithValue(this, KPlayerListener.SeekedKey, null);
mPlayerCallback.playerStateChanged(KPlayerCallback.SEEKED);
mSeeking = false;
startPlaybackTimeReporter();
}
if (mPassedPlay && playWhenReady) {
mPassedPlay = false;
LOGD(TAG, "Change to PlayKey");
mPlayerListener.eventWithValue(this, KPlayerListener.PlayKey, null);
}
}
break;
case ExoPlayer.STATE_ENDED:
LOGD(TAG, "STATE_ENDED mReadiness: " + mReadiness + " playWhenReady: " + playWhenReady + " mBuffering: " + mBuffering);
if (mReadiness == KState.IDLE || mReadiness == KState.PAUSED) {
return;
}
if (mReadiness == KState.SEEKING && playWhenReady) {
mPlayerListener.eventWithValue(this, KPlayerListener.SeekedKey, null);
}
if (playWhenReady) {
mPlayerCallback.playerStateChanged(KPlayerCallback.ENDED);
mReadiness = KState.IDLE;
}
else {
if (mBuffering) {
mPlayerListener.eventWithValue(this, KPlayerListener.BufferingChangeKey, "false");
mPlayerListener.eventWithValue(this, KPlayerListener.SeekedKey, null);
}
}
stopPlaybackTimeReporter();
break;
}
}
private void setPlayWhenReady(boolean shouldPlay) {
if (mExoPlayer != null) {
mExoPlayer.setPlayWhenReady(shouldPlay);
}
setKeepScreenOn(shouldPlay);
}
@Override
public void onError(Exception e) {
String errMsg = "Player Error";
LOGE(TAG, errMsg, e);
String errorString = "";
if (e instanceof UnsupportedDrmException) {
// Special case DRM failures.
UnsupportedDrmException unsupportedDrmException = (UnsupportedDrmException) e;
errorString = (Util.SDK_INT < 18) ? "error_drm_not_supported"
: unsupportedDrmException.reason == UnsupportedDrmException.REASON_UNSUPPORTED_SCHEME
? "error_drm_unsupported_scheme" : "error_drm_unknown";
} else if (e instanceof ExoPlaybackException && e.getCause() instanceof FileNotFoundException) {
errorString = "DRM License Unavailable"; // probably license issue
} else if (e instanceof ExoPlaybackException && e.getCause() instanceof BehindLiveWindowException) {
LOGE(TAG, "Recovering BehindLiveWindowException"); // happens if network is bad and no more chunk in hte buffer
mExoPlayer.prepare();
return;
} else if (e instanceof ExoPlaybackException && e.getCause() instanceof android.media.MediaCodec.CryptoException) {
errorString = "DRM Error. Trying to recover"; // probably license issue
mExoPlayer.prepare();
return;
} else if (e instanceof ExoPlaybackException
&& e.getCause() instanceof MediaCodecTrackRenderer.DecoderInitializationException) {
// Special case for decoder initialization failures.
MediaCodecTrackRenderer.DecoderInitializationException decoderInitializationException =
(MediaCodecTrackRenderer.DecoderInitializationException) e.getCause();
if (decoderInitializationException.decoderName == null) {
if (decoderInitializationException.getCause() instanceof MediaCodecUtil.DecoderQueryException) {
errorString = "error_querying_decoders ";
} else if (decoderInitializationException.secureDecoderRequired) {
errorString = "error_no_secure_decoder " +
decoderInitializationException.mimeType;
} else {
errorString = "error_no_decoder " +
decoderInitializationException.mimeType;
}
} else {
errorString = "error_instantiating_decoder " +
decoderInitializationException.decoderName;
}
}
else if (e.getCause() instanceof com.google.android.exoplayer.upstream.HttpDataSource.HttpDataSourceException) {
mExoPlayer.prepare();
errorString = "HttpDataSourceException . Trying to recover";
LOGE(TAG, errorString);
return;
} else if (e.getCause() instanceof java.net.UnknownHostException) {
mExoPlayer.prepare();
errorString = "UnknownHostException . Trying to recover";
LOGE(TAG, errorString);
return;
} else if (e.getCause() instanceof java.net.ConnectException) {
mExoPlayer.prepare();
errorString = "ConnectException . Trying to recover";
LOGE(TAG, errorString);
return;
}
else if (e.getCause() instanceof java.lang.IllegalStateException) {
mExoPlayer.prepare();
errorString = "IllegalStateException . Trying to recover";
LOGE(TAG, errorString);
return;
}
if (!"".equals(errorString)) {
LOGE(TAG, errorString);
errorString += "-";
}
mPlayerListener.eventWithValue(KExoPlayer.this, KPlayerListener.ErrorKey, TAG + "-" + errMsg + "-" + errorString + e.getMessage());
}
@Override
public void onVideoSizeChanged(int width, int height, int unappliedRotationDegrees, float pixelWidthHeightRatio) {
mSurfaceView.setVideoWidthHeightRatio((float) width / height);
}
@Override
public TrackFormat getTrackFormat(TrackType trackType, int index) {
com.google.android.exoplayer.MediaFormat mediaFormat = mExoPlayer.getTrackFormat(getExoTrackType(trackType), index);
return new TrackFormat(trackType, index, mediaFormat);
}
@Override
public int getTrackCount(TrackType trackType) {
if (mExoPlayer != null) {
return mExoPlayer.getTrackCount(getExoTrackType(trackType));
} else {
LOGE(TAG, "getTrackCount mExoPlayer = null");
return 0;
}
}
@Override
public int getCurrentTrackIndex(TrackType trackType) {
if (mExoPlayer != null) {
return mExoPlayer.getSelectedTrack(getExoTrackType(trackType));
} else {
LOGE(TAG, "getCurrentTrackIndex mExoPlayer = null");
return -1;
}
}
@Override
public void switchTrack(TrackType trackType, int newIndex) {
int exoTrackType = ExoplayerWrapper.TRACK_DISABLED;
if (trackType == null){
return;
}
if (mExoPlayer != null) {
mExoPlayer.setSelectedTrack(getExoTrackType(trackType), newIndex);
} else {
LOGE(TAG, "switchTrack mExoPlayer = null");
}
}
private int getExoTrackType(TrackType trackType) {
int exoTrackType = ExoplayerWrapper.TRACK_DISABLED;
switch (trackType){
case AUDIO:
exoTrackType = ExoplayerWrapper.TYPE_AUDIO;
break;
case VIDEO:
exoTrackType = ExoplayerWrapper.TYPE_VIDEO;
break;
case TEXT:
exoTrackType = ExoplayerWrapper.TYPE_TEXT;
break;
}
return exoTrackType;
}
@Override
public void onCues(List<Cue> cues) {
StringBuilder sb = new StringBuilder();
for (Cue cue : cues){
sb.append(cue.text);
}
LOGD(TAG, "subTitle = " + sb.toString());
if (mSubtView != null) {
mSubtView.setCues(cues);
}
}
@Override
public void onId3Metadata(List<Id3Frame> id3Frames) {
for (Id3Frame id3Frame : id3Frames) {
if (id3Frame instanceof TxxxFrame) {
TxxxFrame txxxFrame = (TxxxFrame) id3Frame;
LOGD(TAG, String.format("ID3 TimedMetadata %s: description=%s, value=%s", txxxFrame.id,
txxxFrame.description, txxxFrame.value));
} else if (id3Frame instanceof PrivFrame) {
PrivFrame privFrame = (PrivFrame) id3Frame;
LOGD(TAG, String.format("ID3 TimedMetadata %s: owner=%s", privFrame.id, privFrame.owner));
} else if (id3Frame instanceof GeobFrame) {
GeobFrame geobFrame = (GeobFrame) id3Frame;
LOGD(TAG, String.format("ID3 TimedMetadata %s: mimeType=%s, filename=%s, description=%s",
geobFrame.id, geobFrame.mimeType, geobFrame.filename, geobFrame.description));
} else {
LOGD(TAG, String.format("ID3 TimedMetadata %s", id3Frame.id));
}
}
}
private void configureSubtitleView() {
CaptionStyleCompat style;
float fontScale;
if (Util.SDK_INT >= 19) {
style = getUserCaptionStyleV19();
fontScale = getUserCaptionFontScaleV19();
} else {
style = CaptionStyleCompat.DEFAULT;
fontScale = 1.0f;
}
if (mSubtView != null) {
mSubtView.setStyle(style);
mSubtView.setFractionalTextSize(SubtitleLayout.DEFAULT_TEXT_SIZE_FRACTION * fontScale);
}
}
@TargetApi(19)
private float getUserCaptionFontScaleV19() {
CaptioningManager captioningManager =
(CaptioningManager) getContext().getSystemService(Context.CAPTIONING_SERVICE);
return captioningManager.getFontScale();
}
@TargetApi(19)
private CaptionStyleCompat getUserCaptionStyleV19() {
CaptioningManager captioningManager =
(CaptioningManager) getContext().getSystemService(Context.CAPTIONING_SERVICE);
return CaptionStyleCompat.createFromCaptionStyle(captioningManager.getUserStyle());
}
private class PlayerState {
boolean playing;
long position;
void set(boolean playing, long position) {
this.playing = playing;
this.position = position;
}
}
}