/**
* The MIT License (MIT)
* Copyright (c) 2012 David Carver
* Permission is hereby granted, free of charge, to any person obtaining
* a copy of this software and associated documentation files (the
* "Software"), to deal in the Software without restriction, including
* without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to
* permit persons to whom the Software is furnished to do so, subject to
* the following conditions:
*
* The above copyright notice and this permission notice shall be included
* in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
* OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
* OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
* OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package us.nineworlds.serenity.ui.video.player;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.net.URLDecoder;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.Charset;
import java.util.Collection;
import java.util.LinkedList;
import javax.inject.Inject;
import org.mozilla.universalchardet.UniversalDetector;
import us.nineworlds.plex.rest.PlexappFactory;
import us.nineworlds.serenity.R;
import us.nineworlds.serenity.core.SerenityConstants;
import us.nineworlds.serenity.core.model.VideoContentInfo;
import us.nineworlds.serenity.core.model.impl.EpisodePosterInfo;
import us.nineworlds.serenity.core.services.CompletedVideoRequest;
import us.nineworlds.serenity.core.services.WatchedVideoAsyncTask;
import us.nineworlds.serenity.core.subtitles.formats.Caption;
import us.nineworlds.serenity.core.subtitles.formats.FormatASS;
import us.nineworlds.serenity.core.subtitles.formats.FormatSRT;
import us.nineworlds.serenity.core.subtitles.formats.TimedTextObject;
import us.nineworlds.serenity.injection.ForVideoQueue;
import us.nineworlds.serenity.ui.activity.SerenityActivity;
import us.nineworlds.serenity.ui.util.DisplayUtils;
import android.app.Activity;
import android.content.Intent;
import android.content.SharedPreferences;
import android.media.MediaPlayer;
import android.media.MediaPlayer.OnCompletionListener;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.text.Html;
import android.util.Log;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.view.View;
import android.widget.TextView;
import android.widget.Toast;
/**
* A view that handles the internal video playback and representation of a movie
* or tv show.
*
* @author dcarver
*
*/
public class SerenitySurfaceViewVideoActivity extends SerenityActivity
implements SurfaceHolder.Callback {
@Inject
@ForVideoQueue
protected LinkedList<VideoContentInfo> videoQueue;
@Inject
protected PlexappFactory plexFactory;
@Inject
protected SharedPreferences prefs;
@Inject
protected MediaPlayer mediaPlayer;
private final Handler subtitleDisplayHandler = new Handler();
private final Runnable subtitle = new SubtitleRunnable();
private final Handler progressReportinghandler = new Handler();
private final Runnable progressRunnable = new ProgressRunnable();
static final int PROGRESS_UPDATE_DELAY = 5000;
static final int SUBTITLE_DISPLAY_CHECK = 100;
int playbackPos = 0;
static final String TAG = "SerenitySurfaceViewVideoActivity";
private int osdDelayTime = 5000;
private VideoPlayerKeyCodeHandler videoPlayerKeyCodeHandler;
private String videoURL;
private SurfaceView surfaceView;
private View videoActivityView;
private MediaController mediaController;
private View timeOfDayView;
private String aspectRatio;
private VideoContentInfo video;
private String videoId;
private int resumeOffset;
private final boolean mediaplayer_error_state = false;
private boolean mediaplayer_released = false;
private String subtitleURL;
private String subtitleType;
private String mediaTagIdentifier;
private TimedTextObject subtitleTimedText;
private boolean subtitlesPlaybackEnabled = true;
private String subtitleInputEncoding = null;
private boolean autoResume;
public boolean isMediaplayerReleased() {
return mediaplayer_released;
}
public void setMediaplayerReleased(boolean mediaplayer_released) {
this.mediaplayer_released = mediaplayer_released;
}
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width,
int height) {
}
@Override
public void surfaceCreated(SurfaceHolder holder) {
try {
mediaPlayer.setDisplay(holder);
mediaPlayer.setDataSource(videoURL);
mediaPlayer.setOnPreparedListener(new VideoPlayerPrepareListener(
mediaController, surfaceView, resumeOffset, autoResume,
aspectRatio, progressReportinghandler, progressRunnable));
mediaPlayer
.setOnCompletionListener(new VideoPlayerOnCompletionListener());
mediaPlayer.prepareAsync();
} catch (Exception ex) {
Log.e(TAG, "Video Playback Error. ", ex);
}
}
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
if (!mediaplayer_released) {
mediaPlayer.release();
mediaplayer_released = true;
}
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.video_playback);
DisplayUtils.overscanCompensation(this, getWindow().getDecorView());
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
getSupportActionBar().hide();
}
init();
}
/**
* Initialize the mediaplayer and mediacontroller.
*/
protected void init() {
osdDelayTime = Integer.parseInt(prefs.getString("osd_display_time",
"5000"));
mediaPlayer.setOnErrorListener(new SerenityOnErrorListener());
surfaceView = (SurfaceView) findViewById(R.id.surfaceView);
videoActivityView = findViewById(R.id.video_playeback);
timeOfDayView = findViewById(R.id.time_of_day);
surfaceView.setKeepScreenOn(true);
SurfaceHolder holder = surfaceView.getHolder();
holder.addCallback(this);
holder.setSizeFromLayout();
final boolean showTimeOfDay = prefs.getBoolean("showTimeOfDay", false);
timeOfDayView.setVisibility(showTimeOfDay ? View.VISIBLE : View.GONE);
retrieveIntentExtras();
videoPlayerKeyCodeHandler = createVideoPlayerKeyCodeHandler();
}
protected VideoPlayerKeyCodeHandler createVideoPlayerKeyCodeHandler() {
return new VideoPlayerKeyCodeHandler(mediaPlayer, mediaController,
osdDelayTime, progressReportinghandler, progressRunnable,
timeOfDayView, this);
}
protected void retrieveIntentExtras() {
Bundle extras = getIntent().getExtras();
if (extras != null) {
autoResume = extras.getBoolean("autoResume", false);
extras.remove("autoResume");
}
if (extras == null || extras.isEmpty()) {
playBackFromVideoQueue();
} else {
playbackFromIntent(extras);
}
new SubtitleAsyncTask().execute();
}
@Deprecated
protected void playbackFromIntent(Bundle extras) {
videoURL = extras.getString("videoUrl");
if (videoURL == null) {
videoURL = extras.getString("encodedvideoUrl");
if (videoURL != null) {
videoURL = URLDecoder.decode(videoURL);
}
}
video = null;
videoId = extras.getString("id");
String summary = extras.getString("summary");
String title = extras.getString("title");
String posterURL = extras.getString("posterUrl");
aspectRatio = extras.getString("aspectRatio");
String videoFormat = extras.getString("videoFormat");
String videoResolution = extras.getString("videoResolution");
String audioFormat = extras.getString("audioFormat");
String audioChannels = extras.getString("audioChannels");
resumeOffset = extras.getInt("resumeOffset");
subtitleURL = extras.getString("subtitleURL");
subtitleType = extras.getString("subtitleFormat");
mediaTagIdentifier = extras.getString("mediaTagId");
MediaControllerDataObject mediaMetaData = initMetaData(summary, title,
posterURL, videoFormat, videoResolution, audioFormat,
audioChannels);
initMediaController(mediaMetaData);
}
protected void playBackFromVideoQueue() {
if (videoQueue.isEmpty()) {
return;
}
VideoContentInfo video = videoQueue.poll();
videoURL = video.getDirectPlayUrl();
this.video = video;
videoId = video.id();
String summary = video.getSummary();
String title = video.getTitle();
String posterURL = video.getImageURL();
;
if (video instanceof EpisodePosterInfo) {
if (video.getParentPosterURL() != null) {
posterURL = video.getParentPosterURL();
}
}
aspectRatio = video.getAspectRatio();
String videoFormat = video.getVideoCodec();
String videoResolution = video.getVideoResolution();
String audioFormat = video.getAudioCodec();
String audioChannels = video.getAudioChannels();
resumeOffset = video.getResumeOffset();
if (video.getSubtitle() != null
&& !"none".equals(video.getSubtitle().getFormat())) {
subtitleURL = video.getSubtitle().getKey();
subtitleType = video.getSubtitle().getFormat();
}
mediaTagIdentifier = video.getMediaTagIdentifier();
MediaControllerDataObject mediaMetaData = initMetaData(summary, title,
posterURL, videoFormat, videoResolution, audioFormat,
audioChannels);
initMediaController(mediaMetaData);
}
protected void initMediaController(MediaControllerDataObject mediaMetaData) {
mediaController = new MediaController(mediaMetaData);
mediaController.setAnchorView(videoActivityView);
mediaController.setMediaPlayer(new SerenityMediaPlayerControl(
mediaPlayer));
mediaController.setOSDDelayTime(osdDelayTime);
}
private MediaControllerDataObject initMetaData(String summary,
String title, String posterURL, String videoFormat,
String videoResolution, String audioFormat, String audioChannels) {
MediaControllerDataObject mediaMetaData = new MediaControllerDataObject();
mediaMetaData.setContext(this);
mediaMetaData.setSummary(summary);
mediaMetaData.setTitle(title);
mediaMetaData.setPosterURL(posterURL);
mediaMetaData.setResolution(videoResolution);
mediaMetaData.setVideoFormat(videoFormat);
mediaMetaData.setAudioFormat(audioFormat);
mediaMetaData.setAudioChannels(audioChannels);
return mediaMetaData;
}
@Override
public void finish() {
subtitleDisplayHandler.removeCallbacks(subtitle);
progressReportinghandler.removeCallbacks(progressRunnable);
super.finish();
}
protected void setExitResultCode() {
Intent returnIntent = new Intent();
returnIntent.putExtra("position", playbackPos);
if (getParent() == null) {
setResult(SerenityConstants.EXIT_PLAYBACK_IMMEDIATELY, returnIntent);
} else {
getParent().setResult(SerenityConstants.EXIT_PLAYBACK_IMMEDIATELY,
returnIntent);
}
}
protected void setExitResultCodeFinished() {
Intent returnIntent = new Intent();
returnIntent.putExtra("position", playbackPos);
if (getParent() == null) {
setResult(Activity.RESULT_OK, returnIntent);
} else {
getParent().setResult(Activity.RESULT_OK, returnIntent);
}
}
@Override
public void onBackPressed() {
if (mediaController.isShowing()) {
mediaController.hide();
}
if (isMediaPlayerStateValid() && mediaPlayer.isPlaying()) {
mediaPlayer.stop();
}
setExitResultCode();
finish();
super.onBackPressed();
}
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
if (keyCode == KeyEvent.KEYCODE_BACK) {
onBackPressed();
return true;
}
if (videoPlayerKeyCodeHandler.onKeyDown(keyCode, event,
isMediaPlayerStateValid())) {
return true;
}
return super.onKeyDown(keyCode, event);
}
protected boolean isMediaPlayerStateValid() {
if (mediaPlayer != null && mediaplayer_error_state == false
&& mediaplayer_released == false) {
return true;
}
return false;
}
@Override
protected void createSideMenu() {
}
@Override
protected void onResume() {
super.onResume();
visibleInBackground();
}
@Override
public void onVisibleBehindCanceled() {
super.onVisibleBehindCanceled();
if (isMediaPlayerStateValid()) {
if (mediaPlayer.isPlaying()) {
mediaPlayer.pause();
}
}
}
protected void visibleInBackground() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
boolean success = requestVisibleBehind(true);
}
}
@Override
protected void onPause() {
super.onPause();
visibleInBackground();
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_DOWN) {
if (mediaController.isShowing()) {
mediaController.hide();
} else {
mediaController.show();
}
return true;
}
return super.onTouchEvent(event);
}
protected class VideoPlayerOnCompletionListener implements
OnCompletionListener {
@Override
public void onCompletion(MediaPlayer mp) {
new CompletedVideoRequest(videoId).execute();
if (!mediaplayer_released) {
if (isMediaPlayerStateValid()) {
if (mediaController.isShowing()) {
mediaController.hide();
}
}
mp.release();
mediaplayer_released = true;
}
setExitResultCodeFinished();
finish();
}
}
public void onTimedText(Caption text) {
TextView subtitles = (TextView) findViewById(R.id.txtSubtitles);
if (text == null) {
subtitles.setVisibility(View.INVISIBLE);
return;
}
String subtitleText = convertCharSet(text.content);
subtitles.setText(Html.fromHtml(subtitleText));
subtitles.setVisibility(View.VISIBLE);
}
private String convertCharSet(String textToConvert) {
String outputEncoding = "UTF-8";
if (outputEncoding.equalsIgnoreCase(subtitleInputEncoding)) {
return textToConvert;
}
Charset charsetOutput = Charset.forName(outputEncoding);
Charset charsetInput = Charset.forName(subtitleInputEncoding);
CharBuffer inputEncoded = charsetInput.decode(ByteBuffer
.wrap(textToConvert.getBytes(Charset
.forName(subtitleInputEncoding))));
byte[] utfEncoded = charsetOutput.encode(inputEncoded).array();
return new String(utfEncoded, Charset.forName("UTF-8"));
}
public class SubtitleAsyncTask extends AsyncTask<Void, Void, Void> {
@Override
protected Void doInBackground(Void... params) {
if (subtitleURL != null) {
try {
URL url = new URL(subtitleURL);
getInputEncoding(url);
if ("srt".equals(subtitleType)) {
FormatSRT formatSRT = new FormatSRT();
subtitleTimedText = formatSRT.parseFile(url
.openStream());
} else if ("ass".equals(subtitleType)) {
FormatASS formatASS = new FormatASS();
subtitleTimedText = formatASS.parseFile(url
.openStream());
}
subtitleDisplayHandler.post(subtitle);
} catch (Exception e) {
Log.e(getClass().getName(), e.getMessage(), e);
}
}
return null;
}
private void getInputEncoding(URL url) {
InputStream is = null;
try {
byte[] buf = new byte[4096];
is = url.openStream();
UniversalDetector detector = new UniversalDetector(null);
int nread;
while ((nread = is.read(buf)) > 0 && !detector.isDone()) {
detector.handleData(buf, 0, nread);
}
detector.dataEnd();
subtitleInputEncoding = detector.getDetectedCharset();
if (subtitleInputEncoding != null) {
Log.d(getClass().getName(), "Detected encoding = "
+ subtitleInputEncoding);
}
detector.reset();
} catch (IOException ex) {
} finally {
if (is != null) {
try {
is.close();
} catch (IOException e) {
}
}
}
}
}
protected class SubtitleRunnable implements Runnable {
@Override
public void run() {
if (isMediaPlayerStateValid() && mediaPlayer.isPlaying()) {
if (hasSubtitles()) {
int currentPos = mediaPlayer.getCurrentPosition();
Collection<Caption> subtitles = subtitleTimedText.captions
.values();
for (Caption caption : subtitles) {
if (currentPos >= caption.start.getMilliseconds()
&& currentPos <= caption.end.getMilliseconds()) {
onTimedText(caption);
break;
} else if (currentPos > caption.end.getMilliseconds()) {
onTimedText(null);
}
}
} else {
subtitlesPlaybackEnabled = false;
Toast.makeText(
getApplicationContext(),
"Invalid or Missing Subtitle. Subtitle playback disabled.",
Toast.LENGTH_LONG).show();
}
}
if (subtitlesPlaybackEnabled) {
subtitleDisplayHandler
.postDelayed(this, SUBTITLE_DISPLAY_CHECK);
}
}
protected boolean hasSubtitles() {
return subtitleTimedText != null
&& subtitleTimedText.captions != null;
}
}
protected class ProgressRunnable implements Runnable {
@Override
public void run() {
try {
if (isMediaPlayerStateValid() && mediaPlayer.isPlaying()) {
float percentage = Float.valueOf(mediaPlayer
.getCurrentPosition())
/ Float.valueOf(mediaPlayer.getDuration());
playbackPos = mediaPlayer.getCurrentPosition();
if (percentage <= 90.f) {
new UpdateProgressRequest().execute();
progressReportinghandler.postDelayed(this,
PROGRESS_UPDATE_DELAY); // Update progress every
// 5
// seconds
} else {
new WatchedVideoAsyncTask().execute(videoId);
}
}
} catch (IllegalStateException ex) {
Log.w(getClass().getName(),
"Illegalstate exception occurred durring progress update. No further updates will occur.",
ex);
}
}
}
/**
* A task that updates the progress position of a video while it is being
* played.
*
* @author dcarver
*
*/
protected class UpdateProgressRequest extends AsyncTask<Void, Void, Void> {
@Override
protected Void doInBackground(Void... params) {
if (isMediaPlayerStateValid() && mediaPlayer.isPlaying()) {
String offset = Integer.valueOf(
mediaPlayer.getCurrentPosition()).toString();
if (video != null) {
if (video.isWatched()) {
plexFactory.setWatched(videoId);
plexFactory.setProgress(videoId, "0");
} else {
plexFactory.setProgress(videoId, offset);
}
video.setResumeOffset(Integer.valueOf(offset));
} else {
plexFactory.setProgress(videoId, offset);
}
}
return null;
}
}
public void setVideoPlayerKeyCodeHandler(
VideoPlayerKeyCodeHandler videoPlayerKeyCodeHandler) {
this.videoPlayerKeyCodeHandler = videoPlayerKeyCodeHandler;
}
protected void setSerenityMediaController(MediaController mediaController) {
this.mediaController = mediaController;
}
protected void setPlaybackPos(int playbackPos) {
this.playbackPos = playbackPos;
}
}