diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/build.gradle b/android-studio-project/ExoPlayer-AirPlay-Receiver/build.gradle index 3ec513e..94d7c55 100644 --- a/android-studio-project/ExoPlayer-AirPlay-Receiver/build.gradle +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/build.gradle @@ -44,6 +44,10 @@ dependencies { compileOnly 'androidx.annotation:annotation:' + project.ext.libVersionAndroidX // ( 27 KB) https://mvnrepository.com/artifact/androidx.annotation/annotation annotationProcessor 'androidx.annotation:annotation:' + project.ext.libVersionAndroidX + implementation 'androidx.appcompat:appcompat:' + project.ext.libVersionAndroidX // (1.0 MB) https://mvnrepository.com/artifact/androidx.appcompat/appcompat + + implementation 'com.google.android.material:material:' + project.ext.libVersionMaterial // (567 KB) https://mvnrepository.com/artifact/com.google.android.material/material + implementation 'com.google.android.exoplayer:exoplayer-core:' + project.ext.libVersionExoPlayer // (1.3 MB) https://mvnrepository.com/artifact/com.google.android.exoplayer/exoplayer-core?repo=bt-google-exoplayer implementation 'com.google.android.exoplayer:exoplayer-dash:' + project.ext.libVersionExoPlayer // (107 KB) https://mvnrepository.com/artifact/com.google.android.exoplayer/exoplayer-dash?repo=bt-google-exoplayer implementation 'com.google.android.exoplayer:exoplayer-hls:' + project.ext.libVersionExoPlayer // ( 98 KB) https://mvnrepository.com/artifact/com.google.android.exoplayer/exoplayer-hls?repo=bt-google-exoplayer diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/proguard-rules.txt b/android-studio-project/ExoPlayer-AirPlay-Receiver/proguard-rules.txt index d1674ff..5bdc889 100644 --- a/android-studio-project/ExoPlayer-AirPlay-Receiver/proguard-rules.txt +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/proguard-rules.txt @@ -1,10 +1,8 @@ -keep class com.github.warren_bank.exoplayer_airplay_receiver.** { *; } - --keep class com.google.android.exoplayer.** { *; } -keep class com.google.android.exoplayer2.** { *; } - -keep class javax.jmdns.** { *; } +-dontwarn com.google.android.exoplayer2.** -dontwarn javax.jmdns.test.** -dontwarn org.apache.** -dontwarn org.slf4j.** diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/AndroidManifest.xml b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/AndroidManifest.xml index c17a9be..4dea5c1 100644 --- a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/AndroidManifest.xml +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/AndroidManifest.xml @@ -44,7 +44,7 @@ diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/constant/Constant.java b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/constant/Constant.java index b1b8f85..c12d09c 100644 --- a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/constant/Constant.java +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/constant/Constant.java @@ -7,6 +7,7 @@ public class Constant { public static final String Need_sendReverse = "SendReverse"; public static final String ReverseMsg = "ReverseMsg"; public static final String PlayURL = "playUrl"; + public static final String CaptionURL = "textUrl"; public static final String RefererURL = "referUrl"; public static final String Start_Pos = "startPos"; @@ -160,39 +161,41 @@ public interface Register { } public interface Msg { - public static final int Msg_Photo = 1; - public static final int Msg_Stop = 2; - public static final int Msg_Video_Play = 3; - public static final int Msg_Video_Seek = 4; - public static final int Msg_Video_Rate = 5; - - public static final int Msg_Video_Queue = 6; - public static final int Msg_Video_Next = 7; - public static final int Msg_Video_Prev = 8; - public static final int Msg_Audio_Volume = 9; + public static final int Msg_Photo = 1; + public static final int Msg_Stop = 2; + public static final int Msg_Video_Play = 3; + public static final int Msg_Video_Seek = 4; + public static final int Msg_Video_Rate = 5; + + public static final int Msg_Video_Queue = 6; + public static final int Msg_Video_Next = 7; + public static final int Msg_Video_Prev = 8; + public static final int Msg_Audio_Volume = 9; + public static final int Msg_Text_Captions = 10; } public interface Target { - public static final String REVERSE = "/reverse"; - public static final String PHOTO = "/photo"; - public static final String SERVER_INFO = "/server-info"; - public static final String STOP = "/stop"; - public static final String PLAY = "/play"; - public static final String SCRUB = "/scrub"; - public static final String RATE = "/rate"; - public static final String PLAYBACK_INFO = "/playback-info"; - - public static final String QUEUE = "/queue"; - public static final String NEXT = "/next"; - public static final String PREVIOUS = "/previous"; - public static final String VOLUME = "/volume"; + public static final String REVERSE = "/reverse"; + public static final String PHOTO = "/photo"; + public static final String SERVER_INFO = "/server-info"; + public static final String STOP = "/stop"; + public static final String PLAY = "/play"; + public static final String SCRUB = "/scrub"; + public static final String RATE = "/rate"; + public static final String PLAYBACK_INFO = "/playback-info"; + + public static final String QUEUE = "/queue"; + public static final String NEXT = "/next"; + public static final String PREVIOUS = "/previous"; + public static final String VOLUME = "/volume"; + public static final String CAPTIONS = "/captions"; } public interface Status { - public static final String Status_play = "playing"; - public static final String Status_stop = "stopped"; - public static final String Status_pause = "paused"; - public static final String Status_load = "loading"; + public static final String Status_play = "playing"; + public static final String Status_stop = "stopped"; + public static final String Status_pause = "paused"; + public static final String Status_load = "loading"; } } diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/httpcore/RequestListenerThread.java b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/httpcore/RequestListenerThread.java index 31270ea..9cbf885 100644 --- a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/httpcore/RequestListenerThread.java +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/httpcore/RequestListenerThread.java @@ -411,59 +411,25 @@ else if ("displayCached".equals(assetAction)) { } } } - else if (target.equals(Constant.Target.PLAY) && (entityContent != null)) { //Pushed videos - String playUrl = ""; - Double startPos = 0.0; - - requestBody = new String(entityContent); - requestBody = StringUtils.convertEscapedLinefeeds(requestBody); //Not necessary; courtesy to curl users. - Log.d(tag, " airplay play action request content = " + requestBody); - //a video from iPhone - if (contentType.equalsIgnoreCase("application/x-apple-binary-plist")) { - // - HashMap map = BplistParser.parse(entityContent); - playUrl = (String) map.get("Content-Location"); - startPos = (Double) map.get("Start-Position"); - } - else { - //iTunes pushed videos or Youku - playUrl = StringUtils.getRequestBodyValue(requestBody, "Content-Location:"); - String v = StringUtils.getRequestBodyValue(requestBody, "Start-Position:"); - startPos = (v.isEmpty()) ? 0.0 : Double.valueOf(v); - } - - if (playUrl.isEmpty()) { - Log.d(tag, "airplay video URL missing"); - - setCommonHeaders(httpResponse, HttpStatus.SC_BAD_REQUEST); - } - else { - Log.d(tag, "airplay playUrl = " + playUrl + "; start Pos = " + startPos); - - Message msg = Message.obtain(); - HashMap map = new HashMap(); - map.put(Constant.PlayURL, playUrl); - map.put(Constant.Start_Pos, Double.toString(startPos)); - msg.what = Constant.Msg.Msg_Video_Play; - msg.obj = map; - MainApp.broadcastMessage(msg); - - setCommonHeaders(httpResponse, HttpStatus.SC_OK); - } - } else if (target.startsWith(Constant.Target.SCRUB)) { //POST is the seek operation. GET returns the position and duration of the play. StringEntity returnBody = new StringEntity(""); String position = StringUtils.getQueryStringValue(target, "?position="); if (!position.isEmpty()) { //post method - float pos = new Float(position); - Log.d(tag, "airplay seek position = " + pos); //unit is seconds - - Message msg = Message.obtain(); - msg.what = Constant.Msg.Msg_Video_Seek; - msg.obj = pos; - MainApp.broadcastMessage(msg); + try { + float pos = Float.parseFloat(position); + Log.d(tag, "airplay seek position = " + pos); //unit is seconds + + Message msg = Message.obtain(); + msg.what = Constant.Msg.Msg_Video_Seek; + msg.obj = pos; + MainApp.broadcastMessage(msg); + } + catch (NumberFormatException e) { + setCommonHeaders(httpResponse, HttpStatus.SC_BAD_REQUEST); + return; + } } else { //get method to get the duration and position of the playback @@ -500,13 +466,19 @@ else if (target.startsWith(Constant.Target.SCRUB)) { //POST is the seek operatio else if (target.startsWith(Constant.Target.RATE)) { //Set playback rate (special case: 0 is pause) String value = StringUtils.getQueryStringValue(target, "?value="); if (!value.isEmpty()) { - float rate = new Float(value); - Log.d(tag, "airplay rate = " + rate); - - Message msg = Message.obtain(); - msg.what = Constant.Msg.Msg_Video_Rate; - msg.obj = rate; - MainApp.broadcastMessage(msg); + try { + float rate = Float.parseFloat(value); + Log.d(tag, "airplay rate = " + rate); + + Message msg = Message.obtain(); + msg.what = Constant.Msg.Msg_Video_Rate; + msg.obj = rate; + MainApp.broadcastMessage(msg); + } + catch (NumberFormatException e) { + setCommonHeaders(httpResponse, HttpStatus.SC_BAD_REQUEST); + return; + } } setCommonHeaders(httpResponse, HttpStatus.SC_OK); @@ -557,8 +529,14 @@ else if (target.equals("/fp-setup")) { // ======================================================================= // non-standard extended API methods: // ======================================================================= - else if (target.equals(Constant.Target.QUEUE) && (entityContent != null)) { //Add video to end of queue + else if ( + (entityContent != null) && ( + target.equals(Constant.Target.PLAY) || //Clear queue and add new video + target.equals(Constant.Target.QUEUE) //Add video to end of queue + ) + ) { String playUrl = ""; + String textUrl = ""; String referUrl = ""; Double startPos = 0.0; @@ -570,12 +548,14 @@ else if (target.equals(Constant.Target.QUEUE) && (entityContent != null)) { //Ad // HashMap map = BplistParser.parse(entityContent); playUrl = (String) map.get("Content-Location"); + textUrl = (String) map.get("Caption-Location"); referUrl = (String) map.get("Referer"); startPos = (Double) map.get("Start-Position"); } else { //iTunes pushed videos or Youku playUrl = StringUtils.getRequestBodyValue(requestBody, "Content-Location:"); + textUrl = StringUtils.getRequestBodyValue(requestBody, "Caption-Location:"); referUrl = StringUtils.getRequestBodyValue(requestBody, "Referer:"); String v = StringUtils.getRequestBodyValue(requestBody, "Start-Position:"); startPos = (v.isEmpty()) ? 0.0 : Double.valueOf(v); @@ -587,14 +567,17 @@ else if (target.equals(Constant.Target.QUEUE) && (entityContent != null)) { //Ad setCommonHeaders(httpResponse, HttpStatus.SC_BAD_REQUEST); } else { - Log.d(tag, "airplay playUrl = " + playUrl + "; start Pos = " + startPos + "; referer = " + referUrl); + Log.d(tag, "airplay playUrl = " + playUrl + "; start Pos = " + startPos + "; captions = " + textUrl + "; referer = " + referUrl); Message msg = Message.obtain(); HashMap map = new HashMap(); map.put(Constant.PlayURL, playUrl); + map.put(Constant.CaptionURL, textUrl); map.put(Constant.RefererURL, referUrl); map.put(Constant.Start_Pos, Double.toString(startPos)); - msg.what = Constant.Msg.Msg_Video_Queue; + msg.what = target.equals(Constant.Target.PLAY) + ? Constant.Msg.Msg_Video_Play + : Constant.Msg.Msg_Video_Queue; msg.obj = map; MainApp.broadcastMessage(msg); @@ -618,17 +601,42 @@ else if (target.equals(Constant.Target.PREVIOUS)) { //skip backward to previous else if (target.startsWith(Constant.Target.VOLUME)) { //set audio volume (special case: 0 is mute) String value = StringUtils.getQueryStringValue(target, "?value="); if (!value.isEmpty()) { - float audioVolume = new Float(value); - Log.d(tag, "airplay volume = " + audioVolume); - - Message msg = Message.obtain(); - msg.what = Constant.Msg.Msg_Audio_Volume; - msg.obj = audioVolume; - MainApp.broadcastMessage(msg); + try { + float audioVolume = Float.parseFloat(value); + Log.d(tag, "airplay volume = " + audioVolume); + + Message msg = Message.obtain(); + msg.what = Constant.Msg.Msg_Audio_Volume; + msg.obj = audioVolume; + MainApp.broadcastMessage(msg); + } + catch (NumberFormatException e) { + setCommonHeaders(httpResponse, HttpStatus.SC_BAD_REQUEST); + return; + } } - setCommonHeaders(httpResponse, HttpStatus.SC_OK); } + else if (target.startsWith(Constant.Target.CAPTIONS)) { //toggle text captions on/off + String value = StringUtils.getQueryStringValue(target, "?toggle="); + if (!value.isEmpty()) { + try { + int toggleValue = Integer.parseInt(value, 10); + Log.d(tag, "airplay captions = " + toggleValue); + + Message msg = Message.obtain(); + msg.what = Constant.Msg.Msg_Text_Captions; + msg.obj = (toggleValue != 0); //boolean whether to "show" + MainApp.broadcastMessage(msg); + } + catch (NumberFormatException e) { + setCommonHeaders(httpResponse, HttpStatus.SC_BAD_REQUEST); + return; + } + } + setCommonHeaders(httpResponse, HttpStatus.SC_OK); + } + // ======================================================================= else { Log.d(tag, "airplay default not process"); diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/service/NetworkingService.java b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/service/NetworkingService.java index 8921d13..1b5db95 100644 --- a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/service/NetworkingService.java +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/service/NetworkingService.java @@ -368,28 +368,18 @@ public void handleMessage(Message msg) { service.startActivity(intent); break; } - case Constant.Msg.Msg_Video_Play : { - HashMap map = (HashMap) msg.obj; - String playUrl = map.get(Constant.PlayURL); - String startPos = map.get(Constant.Start_Pos); - - Intent intent = new Intent(service, VideoPlayerActivity.class); - intent.putExtra("mode", "play"); - intent.putExtra("uri", playUrl); - intent.putExtra("startPosition", Double.valueOf(startPos)); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - service.startActivity(intent); - break; - } + case Constant.Msg.Msg_Video_Play : case Constant.Msg.Msg_Video_Queue : { HashMap map = (HashMap) msg.obj; String playUrl = map.get(Constant.PlayURL); + String textUrl = map.get(Constant.CaptionURL); String referUrl = map.get(Constant.RefererURL); String startPos = map.get(Constant.Start_Pos); Intent intent = new Intent(service, VideoPlayerActivity.class); - intent.putExtra("mode", "queue"); + intent.putExtra("mode", ((msg.what == Constant.Msg.Msg_Video_Play) ? "play" : "queue")); intent.putExtra("uri", playUrl); + intent.putExtra("caption", textUrl); intent.putExtra("referer", referUrl); intent.putExtra("startPosition", Double.valueOf(startPos)); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/VideoPlayerActivity.java b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/VideoPlayerActivity.java index 31dda19..1f8e7c9 100644 --- a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/VideoPlayerActivity.java +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/VideoPlayerActivity.java @@ -123,6 +123,10 @@ public void handleMessage(Message msg) { float audioVolume = (float) msg.obj; activity.playerManager.AirPlay_volume(audioVolume); break; + case Constant.Msg.Msg_Text_Captions : + boolean showCaptions = (boolean) msg.obj; + activity.playerManager.AirPlay_captions(showCaptions); + break; } } diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/exoplayer2/PlayerManager.java b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/exoplayer2/PlayerManager.java index cb4907d..4517fe4 100644 --- a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/exoplayer2/PlayerManager.java +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/exoplayer2/PlayerManager.java @@ -12,6 +12,7 @@ import com.google.android.exoplayer2.DefaultRenderersFactory; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.ExoPlayerFactory; +import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Player.DiscontinuityReason; @@ -23,10 +24,13 @@ import com.google.android.exoplayer2.source.ConcatenatingMediaSource; import com.google.android.exoplayer2.source.ExtractorMediaSource; import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.MergingMediaSource; +import com.google.android.exoplayer2.source.SingleSampleMediaSource; import com.google.android.exoplayer2.source.dash.DashMediaSource; import com.google.android.exoplayer2.source.hls.HlsMediaSource; import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.trackselection.MappingTrackSelector; import com.google.android.exoplayer2.ui.PlayerView; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; @@ -57,6 +61,8 @@ public void retainLast() { private Handler handler; private Runnable retainLast; + public DefaultTrackSelector trackSelector; + /** * @param context A {@link Context}. * @param playerView The {@link PlayerView} for local playback. @@ -77,8 +83,7 @@ private PlayerManager( this.playerView = playerView; this.mediaQueue = new MyArrayList<>(); this.concatenatingMediaSource = new ConcatenatingMediaSource(); - - DefaultTrackSelector trackSelector = new DefaultTrackSelector(); + this.trackSelector = new DefaultTrackSelector(context); RenderersFactory renderersFactory = new DefaultRenderersFactory(context); this.exoPlayer = ExoPlayerFactory.newSimpleInstance(context, renderersFactory, trackSelector); this.exoPlayer.addListener(this); @@ -166,24 +171,24 @@ public int getCurrentItemIndex() { * Appends {@link VideoSource} to the media queue. * * @param uri The URL to a video file or stream. - * @param mimeType The mime-type of the video file or stream. + * @param caption The URL to a file containing text captions (srt or vtt). * @param referer The URL to include in the 'Referer' HTTP header of requests to retrieve the video file or stream. * @param startPosition The position at which to start playback within the video file or (non-live) stream. When value < 1.0, it is interpreted to mean a percentage of the total video length. When value >= 1.0, it is interpreted to mean a fixed offset in seconds. */ public void addItem( String uri, - String mimeType, + String caption, String referer, float startPosition ) { - addItem(uri, mimeType, referer, startPosition, (Handler) null, (Runnable) null); + addItem(uri, caption, referer, startPosition, (Handler) null, (Runnable) null); } /** * Appends {@link VideoSource} to the media queue. * * @param uri The URL to a video file or stream. - * @param mimeType The mime-type of the video file or stream. + * @param caption The URL to a file containing text captions (srt or vtt). * @param referer The URL to include in the 'Referer' HTTP header of requests to retrieve the video file or stream. * @param startPosition The position at which to start playback within the video file or (non-live) stream. When value < 1.0, it is interpreted to mean a percentage of the total video length. When value >= 1.0, it is interpreted to mean a fixed offset in seconds. * @param handler @@ -191,13 +196,13 @@ public void addItem( */ public void addItem( String uri, - String mimeType, + String caption, String referer, float startPosition, Handler handler, Runnable onCompletionAction ) { - VideoSource sample = VideoSource.createVideoSource(uri, mimeType, referer, startPosition); + VideoSource sample = VideoSource.createVideoSource(uri, caption, referer, startPosition); addItem(sample, handler, onCompletionAction); } @@ -222,7 +227,8 @@ public void addItem( Handler handler, Runnable onCompletionAction ) { - if ((sample.uri == null) || sample.uri.isEmpty()) { + // this can't happen: Intents without a uri are ignored + if (sample.uri == null) { if ((handler != null) && (onCompletionAction != null)) handler.post(onCompletionAction); return; @@ -306,8 +312,13 @@ public boolean moveItem(int fromIndex, int toIndex) { * @param uri The URL to a video file or stream. * @param startPosition The position at which to start playback within the video file or (non-live) stream. When value < 1.0, it is interpreted to mean a percentage of the total video length. When value >= 1.0, it is interpreted to mean a fixed offset in seconds. */ - public void AirPlay_play(String uri, float startPosition) { - addItem(uri, (String) null, (String) null, startPosition, handler, retainLast); + public void AirPlay_play( + String uri, + String caption, + String referer, + float startPosition + ) { + addItem(uri, caption, referer, startPosition, handler, retainLast); } /** @@ -370,10 +381,11 @@ public void AirPlay_stop() { */ public void AirPlay_queue( String uri, + String caption, String referer, float startPosition ) { - addItem(uri, (String) null, referer, startPosition); + addItem(uri, caption, referer, startPosition); } /** @@ -403,6 +415,33 @@ public void AirPlay_volume(float audioVolume) { exoPlayer.setVolume(audioVolume); // range of values: 0.0 (mute) - 1.0 (unity gain) } + /** + * Change visibility of text captions. + * + * @param showCaptions + */ + public void AirPlay_captions(boolean showCaptions) { + boolean isDisabled = !showCaptions; + + DefaultTrackSelector.ParametersBuilder builder = trackSelector.getParameters().buildUpon(); + + MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo(); + if (info == null) return; + + int renderer_count = info.getRendererCount(); + int modified_count = 0; + for (int i = 0; i < renderer_count; i++) { + if (exoPlayer.getRendererType(i) == C.TRACK_TYPE_TEXT) { + builder.clearSelectionOverrides(/* rendererIndex= */ i); + builder.setRendererDisabled(/* rendererIndex= */ i, isDisabled); + modified_count++; + } + } + + if (modified_count > 0) + trackSelector.setParameters(builder.build()); + } + // Miscellaneous methods. /** @@ -548,7 +587,7 @@ private void setHttpRequestHeaders(int currentItemIndex) { VideoSource sample = getItem(currentItemIndex); if (sample == null) return; - if ((sample.referer != null) && !sample.referer.isEmpty()) { + if (sample.referer != null) { Uri referer = Uri.parse(sample.referer); String origin = referer.getScheme() + "://" + referer.getAuthority(); @@ -562,9 +601,18 @@ private void setHttpRequestHeader(String name, String value) { } private MediaSource buildMediaSource(VideoSource sample) { + MediaSource video = buildUriMediaSource(sample); + MediaSource caption = buildCaptionMediaSource(sample); + + return (caption == null) + ? video + : new MergingMediaSource(video, caption); + } + + private MediaSource buildUriMediaSource(VideoSource sample) { Uri uri = Uri.parse(sample.uri); - switch (sample.mimeType) { + switch (sample.uri_mimeType) { case MimeTypes.APPLICATION_M3U8: return new HlsMediaSource.Factory(httpDataSourceFactory).createMediaSource(uri); case MimeTypes.APPLICATION_MPD: @@ -576,6 +624,16 @@ private MediaSource buildMediaSource(VideoSource sample) { } } + private MediaSource buildCaptionMediaSource(VideoSource sample) { + if ((sample.caption == null) || (sample.caption_mimeType == null)) + return null; + + Uri uri = Uri.parse(sample.caption); + Format format = Format.createTextSampleFormat(/* id= */ null, sample.caption_mimeType, /* selectionFlags= */ C.SELECTION_FLAG_DEFAULT, /* language= */ "en"); + + return new SingleSampleMediaSource.Factory(httpDataSourceFactory).createMediaSource(uri, format, C.TIME_UNSET); + } + private MediaSource buildRawVideoMediaSource(int rawResourceId) { Uri uri = RawResourceDataSource.buildRawResourceUri(rawResourceId); @@ -591,7 +649,7 @@ private void addRawVideoItem( Handler handler, Runnable onCompletionAction ) { - VideoSource sample = VideoSource.createVideoSource("raw", "raw", (String) null, 0f); + VideoSource sample = VideoSource.createVideoSource("raw", /* caption= */ (String) null, /* referer= */ (String) null, /* startPosition= */ 0f); mediaQueue.add(sample); concatenatingMediaSource.addMediaSource( diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/exoplayer2/TrackSelectionDialog.java b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/exoplayer2/TrackSelectionDialog.java new file mode 100644 index 0000000..e141745 --- /dev/null +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/exoplayer2/TrackSelectionDialog.java @@ -0,0 +1,355 @@ +package com.github.warren_bank.exoplayer_airplay_receiver.ui.exoplayer2; + +import com.github.warren_bank.exoplayer_airplay_receiver.R; + +import android.app.Dialog; +import android.content.DialogInterface; +import android.content.res.Resources; +import android.os.Bundle; +import android.util.SparseArray; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatDialog; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentPagerAdapter; +import androidx.viewpager.widget.ViewPager; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector.SelectionOverride; +import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo; +import com.google.android.exoplayer2.ui.TrackSelectionView; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.material.tabs.TabLayout; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** Dialog to select tracks. */ +public final class TrackSelectionDialog extends DialogFragment { + + private final SparseArray tabFragments; + private final ArrayList tabTrackTypes; + + private int titleId; + private DialogInterface.OnClickListener onClickListener; + private DialogInterface.OnDismissListener onDismissListener; + + /** + * Returns whether a track selection dialog will have content to display if initialized with the + * specified {@link DefaultTrackSelector} in its current state. + */ + public static boolean willHaveContent(DefaultTrackSelector trackSelector) { + MappedTrackInfo mappedTrackInfo = trackSelector.getCurrentMappedTrackInfo(); + return mappedTrackInfo != null && willHaveContent(mappedTrackInfo); + } + + /** + * Returns whether a track selection dialog will have content to display if initialized with the + * specified {@link MappedTrackInfo}. + */ + public static boolean willHaveContent(MappedTrackInfo mappedTrackInfo) { + for (int i = 0; i < mappedTrackInfo.getRendererCount(); i++) { + if (showTabForRenderer(mappedTrackInfo, i)) { + return true; + } + } + return false; + } + + /** + * Creates a dialog for a given {@link DefaultTrackSelector}, whose parameters will be + * automatically updated when tracks are selected. + * + * @param trackSelector The {@link DefaultTrackSelector}. + * @param onDismissListener A {@link DialogInterface.OnDismissListener} to call when the dialog is + * dismissed. + */ + public static TrackSelectionDialog createForTrackSelector( + DefaultTrackSelector trackSelector, DialogInterface.OnDismissListener onDismissListener) { + MappedTrackInfo mappedTrackInfo = + Assertions.checkNotNull(trackSelector.getCurrentMappedTrackInfo()); + TrackSelectionDialog trackSelectionDialog = new TrackSelectionDialog(); + DefaultTrackSelector.Parameters parameters = trackSelector.getParameters(); + trackSelectionDialog.init( + /* titleId= */ R.string.track_selection_title, + mappedTrackInfo, + /* initialParameters = */ parameters, + /* allowAdaptiveSelections =*/ true, + /* allowMultipleOverrides= */ false, + /* onClickListener= */ (dialog, which) -> { + DefaultTrackSelector.ParametersBuilder builder = parameters.buildUpon(); + for (int i = 0; i < mappedTrackInfo.getRendererCount(); i++) { + builder + .clearSelectionOverrides(/* rendererIndex= */ i) + .setRendererDisabled( + /* rendererIndex= */ i, + trackSelectionDialog.getIsDisabled(/* rendererIndex= */ i)); + List overrides = + trackSelectionDialog.getOverrides(/* rendererIndex= */ i); + if (!overrides.isEmpty()) { + builder.setSelectionOverride( + /* rendererIndex= */ i, + mappedTrackInfo.getTrackGroups(/* rendererIndex= */ i), + overrides.get(0)); + } + } + trackSelector.setParameters(builder); + }, + onDismissListener); + return trackSelectionDialog; + } + + /** + * Creates a dialog for given {@link MappedTrackInfo} and {@link DefaultTrackSelector.Parameters}. + * + * @param titleId The resource id of the dialog title. + * @param mappedTrackInfo The {@link MappedTrackInfo} to display. + * @param initialParameters The {@link DefaultTrackSelector.Parameters} describing the initial + * track selection. + * @param allowAdaptiveSelections Whether adaptive selections (consisting of more than one track) + * can be made. + * @param allowMultipleOverrides Whether tracks from multiple track groups can be selected. + * @param onClickListener {@link DialogInterface.OnClickListener} called when tracks are selected. + * @param onDismissListener {@link DialogInterface.OnDismissListener} called when the dialog is + * dismissed. + */ + public static TrackSelectionDialog createForMappedTrackInfoAndParameters( + int titleId, + MappedTrackInfo mappedTrackInfo, + DefaultTrackSelector.Parameters initialParameters, + boolean allowAdaptiveSelections, + boolean allowMultipleOverrides, + DialogInterface.OnClickListener onClickListener, + DialogInterface.OnDismissListener onDismissListener) { + TrackSelectionDialog trackSelectionDialog = new TrackSelectionDialog(); + trackSelectionDialog.init( + titleId, + mappedTrackInfo, + initialParameters, + allowAdaptiveSelections, + allowMultipleOverrides, + onClickListener, + onDismissListener); + return trackSelectionDialog; + } + + public TrackSelectionDialog() { + tabFragments = new SparseArray<>(); + tabTrackTypes = new ArrayList<>(); + // Retain instance across activity re-creation to prevent losing access to init data. + setRetainInstance(true); + } + + private void init( + int titleId, + MappedTrackInfo mappedTrackInfo, + DefaultTrackSelector.Parameters initialParameters, + boolean allowAdaptiveSelections, + boolean allowMultipleOverrides, + DialogInterface.OnClickListener onClickListener, + DialogInterface.OnDismissListener onDismissListener) { + this.titleId = titleId; + this.onClickListener = onClickListener; + this.onDismissListener = onDismissListener; + for (int i = 0; i < mappedTrackInfo.getRendererCount(); i++) { + if (showTabForRenderer(mappedTrackInfo, i)) { + int trackType = mappedTrackInfo.getRendererType(/* rendererIndex= */ i); + TrackGroupArray trackGroupArray = mappedTrackInfo.getTrackGroups(i); + TrackSelectionViewFragment tabFragment = new TrackSelectionViewFragment(); + tabFragment.init( + mappedTrackInfo, + /* rendererIndex= */ i, + initialParameters.getRendererDisabled(/* rendererIndex= */ i), + initialParameters.getSelectionOverride(/* rendererIndex= */ i, trackGroupArray), + allowAdaptiveSelections, + allowMultipleOverrides); + tabFragments.put(i, tabFragment); + tabTrackTypes.add(trackType); + } + } + } + + /** + * Returns whether a renderer is disabled. + * + * @param rendererIndex Renderer index. + * @return Whether the renderer is disabled. + */ + public boolean getIsDisabled(int rendererIndex) { + TrackSelectionViewFragment rendererView = tabFragments.get(rendererIndex); + return rendererView != null && rendererView.isDisabled; + } + + /** + * Returns the list of selected track selection overrides for the specified renderer. There will + * be at most one override for each track group. + * + * @param rendererIndex Renderer index. + * @return The list of track selection overrides for this renderer. + */ + public List getOverrides(int rendererIndex) { + TrackSelectionViewFragment rendererView = tabFragments.get(rendererIndex); + return rendererView == null ? Collections.emptyList() : rendererView.overrides; + } + + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + // We need to own the view to let tab layout work correctly on all API levels. We can't use + // AlertDialog because it owns the view itself, so we use AppCompatDialog instead, themed using + // the AlertDialog theme overlay with force-enabled title. + AppCompatDialog dialog = + new AppCompatDialog(getActivity(), R.style.TrackSelectionDialogThemeOverlay); + dialog.setTitle(titleId); + return dialog; + } + + @Override + public void onDismiss(DialogInterface dialog) { + super.onDismiss(dialog); + onDismissListener.onDismiss(dialog); + } + + @Nullable + @Override + public View onCreateView( + LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + + View dialogView = inflater.inflate(R.layout.track_selection_dialog, container, false); + TabLayout tabLayout = dialogView.findViewById(R.id.track_selection_dialog_tab_layout); + ViewPager viewPager = dialogView.findViewById(R.id.track_selection_dialog_view_pager); + Button cancelButton = dialogView.findViewById(R.id.track_selection_dialog_cancel_button); + Button okButton = dialogView.findViewById(R.id.track_selection_dialog_ok_button); + viewPager.setAdapter(new FragmentAdapter(getChildFragmentManager())); + tabLayout.setupWithViewPager(viewPager); + tabLayout.setVisibility(tabFragments.size() > 1 ? View.VISIBLE : View.GONE); + cancelButton.setOnClickListener(view -> dismiss()); + okButton.setOnClickListener( + view -> { + onClickListener.onClick(getDialog(), DialogInterface.BUTTON_POSITIVE); + dismiss(); + }); + return dialogView; + } + + private static boolean showTabForRenderer(MappedTrackInfo mappedTrackInfo, int rendererIndex) { + TrackGroupArray trackGroupArray = mappedTrackInfo.getTrackGroups(rendererIndex); + if (trackGroupArray.length == 0) { + return false; + } + int trackType = mappedTrackInfo.getRendererType(rendererIndex); + return isSupportedTrackType(trackType); + } + + private static boolean isSupportedTrackType(int trackType) { + switch (trackType) { + case C.TRACK_TYPE_VIDEO: + case C.TRACK_TYPE_AUDIO: + case C.TRACK_TYPE_TEXT: + return true; + default: + return false; + } + } + + private static String getTrackTypeString(Resources resources, int trackType) { + switch (trackType) { + case C.TRACK_TYPE_VIDEO: + return resources.getString(R.string.exo_track_selection_title_video); + case C.TRACK_TYPE_AUDIO: + return resources.getString(R.string.exo_track_selection_title_audio); + case C.TRACK_TYPE_TEXT: + return resources.getString(R.string.exo_track_selection_title_text); + default: + throw new IllegalArgumentException(); + } + } + + private final class FragmentAdapter extends FragmentPagerAdapter { + + public FragmentAdapter(FragmentManager fragmentManager) { + super(fragmentManager); + } + + @Override + public Fragment getItem(int position) { + return tabFragments.valueAt(position); + } + + @Override + public int getCount() { + return tabFragments.size(); + } + + @Nullable + @Override + public CharSequence getPageTitle(int position) { + return getTrackTypeString(getResources(), tabTrackTypes.get(position)); + } + } + + /** Fragment to show a track selection in tab of the track selection dialog. */ + public static final class TrackSelectionViewFragment extends Fragment + implements TrackSelectionView.TrackSelectionListener { + + private MappedTrackInfo mappedTrackInfo; + private int rendererIndex; + private boolean allowAdaptiveSelections; + private boolean allowMultipleOverrides; + + /* package */ boolean isDisabled; + /* package */ List overrides; + + public TrackSelectionViewFragment() { + // Retain instance across activity re-creation to prevent losing access to init data. + setRetainInstance(true); + } + + public void init( + MappedTrackInfo mappedTrackInfo, + int rendererIndex, + boolean initialIsDisabled, + @Nullable SelectionOverride initialOverride, + boolean allowAdaptiveSelections, + boolean allowMultipleOverrides) { + this.mappedTrackInfo = mappedTrackInfo; + this.rendererIndex = rendererIndex; + this.isDisabled = initialIsDisabled; + this.overrides = + initialOverride == null + ? Collections.emptyList() + : Collections.singletonList(initialOverride); + this.allowAdaptiveSelections = allowAdaptiveSelections; + this.allowMultipleOverrides = allowMultipleOverrides; + } + + @Nullable + @Override + public View onCreateView( + LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + View rootView = + inflater.inflate( + R.layout.exo_track_selection_dialog, container, /* attachToRoot= */ false); + TrackSelectionView trackSelectionView = rootView.findViewById(R.id.exo_track_selection_view); + trackSelectionView.setShowDisableOption(true); + trackSelectionView.setAllowMultipleOverrides(allowMultipleOverrides); + trackSelectionView.setAllowAdaptiveSelections(allowAdaptiveSelections); + trackSelectionView.init( + mappedTrackInfo, rendererIndex, isDisabled, overrides, /* listener= */ this); + return rootView; + } + + @Override + public void onTrackSelectionChanged(boolean isDisabled, List overrides) { + this.isDisabled = isDisabled; + this.overrides = overrides; + } + } +} diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/exoplayer2/VideoActivity.java b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/exoplayer2/VideoActivity.java index 5357b4a..a83512c 100644 --- a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/exoplayer2/VideoActivity.java +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/exoplayer2/VideoActivity.java @@ -2,20 +2,28 @@ import com.github.warren_bank.exoplayer_airplay_receiver.R; -import android.app.Activity; import android.content.Intent; import android.os.Bundle; +import android.text.TextUtils; import android.util.Log; import android.view.KeyEvent; +import android.view.View; import android.view.Window; import android.view.WindowManager; +import android.widget.Button; +import androidx.appcompat.app.AppCompatActivity; + +import com.google.android.exoplayer2.ui.PlayerControlView; import com.google.android.exoplayer2.ui.PlayerView; -public class VideoActivity extends Activity { +public class VideoActivity extends AppCompatActivity implements PlayerControlView.VisibilityListener, View.OnClickListener { private static final String tag = "VideoActivity"; private PlayerView playerView; + private Button selectTracksButton; + private boolean isShowingTrackSelectionDialog; + public PlayerManager playerManager; // Activity lifecycle methods. @@ -29,8 +37,13 @@ protected void onCreate(Bundle savedInstanceState) { setContentView(R.layout.activity_video); playerView = (PlayerView) findViewById(R.id.player_view); + playerView.setControllerVisibilityListener(this); playerView.requestFocus(); + selectTracksButton = (Button) findViewById(R.id.select_tracks_button); + selectTracksButton.setOnClickListener(this); + isShowingTrackSelectionDialog = false; + playerManager = PlayerManager.createPlayerManager(/* context= */ this, playerView); handleIntent(getIntent()); @@ -59,6 +72,37 @@ public boolean dispatchKeyEvent(KeyEvent event) { return super.dispatchKeyEvent(event) || playerManager.dispatchKeyEvent(event); } + // Event handler interfaces. + + // PlayerControlView.VisibilityListener + @Override + public void onVisibilityChange(int visibility) { + selectTracksButton.setVisibility(visibility); + + if (visibility == View.VISIBLE) { + selectTracksButton.setEnabled( + (playerManager != null) && TrackSelectionDialog.willHaveContent(playerManager.trackSelector) + ); + } + } + + // View.OnClickListener + @Override + public void onClick(View view) { + if ( + view == selectTracksButton + && !isShowingTrackSelectionDialog + && TrackSelectionDialog.willHaveContent(playerManager.trackSelector) + ) { + isShowingTrackSelectionDialog = true; + TrackSelectionDialog trackSelectionDialog = TrackSelectionDialog.createForTrackSelector( + playerManager.trackSelector, + /* onDismissListener= */ dismissedDialog -> isShowingTrackSelectionDialog = false + ); + trackSelectionDialog.show(getSupportFragmentManager(), /* tag= */ null); + } + } + // Internal methods. private void handleIntent(Intent intent) { @@ -76,19 +120,29 @@ private void handleIntent(Intent intent) { String mode = intent.getStringExtra("mode"); String uri = intent.getStringExtra("uri"); + String caption = intent.getStringExtra("caption"); String referer = intent.getStringExtra("referer"); float startPosition = (float) intent.getDoubleExtra("startPosition", 0); + // normalize empty data fields to: null + if (TextUtils.isEmpty(uri)) + uri = null; + if (TextUtils.isEmpty(caption)) + caption = null; + if (TextUtils.isEmpty(referer)) + referer = null; + + // ignore bad requests if (uri == null) return; if ((mode != null) && mode.equals("queue")) { - playerManager.AirPlay_queue(uri, referer, startPosition); - Log.d(tag, "queue video: url = " + uri + "; position = " + startPosition + "; referer = " + referer); + playerManager.AirPlay_queue(uri, caption, referer, startPosition); + Log.d(tag, "queue video: url = " + uri + "; position = " + startPosition + "; captions = " + caption + "; referer = " + referer); } else /* if ((mode != null) && mode.equals("play")) */ { - playerManager.AirPlay_play(uri, startPosition); - Log.d(tag, "play video: url = " + uri + "; position = " + startPosition); + playerManager.AirPlay_play(uri, caption, referer, startPosition); + Log.d(tag, "play video: url = " + uri + "; position = " + startPosition + "; captions = " + caption + "; referer = " + referer); } } diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/exoplayer2/VideoSource.java b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/exoplayer2/VideoSource.java index 5fb5862..b6a63d8 100644 --- a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/exoplayer2/VideoSource.java +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/exoplayer2/VideoSource.java @@ -6,7 +6,9 @@ final class VideoSource { public final String uri; - public final String mimeType; + public final String caption; + public final String uri_mimeType; + public final String caption_mimeType; public final String referer; public final float startPosition; @@ -14,28 +16,26 @@ final class VideoSource { public static VideoSource createVideoSource( String uri, - String mimeType, + String caption, String referer, float startPosition ) { - VideoSource videoSource = new VideoSource(uri, mimeType, referer, startPosition); + VideoSource videoSource = new VideoSource(uri, caption, referer, startPosition); return videoSource; } private VideoSource( String uri, - String mimeType, + String caption, String referer, float startPosition ) { - if ((mimeType == null) || mimeType.isEmpty()) { - mimeType = VideoSource.get_mimeType(uri); - } - - this.uri = uri; - this.mimeType = mimeType; - this.referer = referer; - this.startPosition = startPosition; + this.uri = uri; + this.caption = caption; + this.uri_mimeType = VideoSource.get_video_mimeType(uri); + this.caption_mimeType = VideoSource.get_caption_mimeType(caption); + this.referer = referer; + this.startPosition = startPosition; } // Public methods. @@ -47,9 +47,12 @@ public String toString() { // Static helpers. + // =========================================================================== + // video mime-type + public static Pattern video_regex = Pattern.compile("\\.(mp4|mp4v|mpv|m1v|m4v|mpg|mpg2|mpeg|xvid|webm|3gp|avi|mov|mkv|ogg|ogv|ogm|m3u8|mpd|ism(?:[vc]|/manifest)?)(?:[\\?#]|$)"); - public static String get_mimeType(String uri) { + public static String get_video_mimeType(String uri) { if (uri == null) return null; Matcher matcher = VideoSource.video_regex.matcher(uri.toLowerCase()); @@ -114,4 +117,40 @@ public static String get_mimeType(String uri) { return mimeType; } + // =========================================================================== + // captions mime-type + + public static Pattern caption_regex = Pattern.compile("\\.(srt|ttml|vtt|webvtt|ssa|ass)(?:[\\?#]|$)"); + + public static String get_caption_mimeType(String caption) { + if (caption == null) return null; + + Matcher matcher = VideoSource.caption_regex.matcher(caption.toLowerCase()); + String file_ext = ""; + String mimeType = null; + + if (matcher.find()) { + file_ext = matcher.group(1); + + switch (file_ext) { + case "srt": + mimeType = "application/x-subrip"; + break; + case "ttml": + mimeType = "application/ttml+xml"; + break; + case "vtt": + case "webvtt": + mimeType = "text/vtt"; + break; + case "ssa": + case "ass": + mimeType = "text/x-ssa"; + break; + } + } + return mimeType; + } + + // =========================================================================== } diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/layout/activity_video.xml b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/layout/activity_video.xml index 39e9261..150b735 100644 --- a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/layout/activity_video.xml +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/layout/activity_video.xml @@ -1,5 +1,5 @@ - - + android:layout_height="match_parent" /> - + + + diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/layout/track_selection_dialog.xml b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/layout/track_selection_dialog.xml new file mode 100644 index 0000000..7f6c45e --- /dev/null +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/layout/track_selection_dialog.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/values/strings.xml b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/values/strings.xml index 95e7230..7231a47 100644 --- a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/values/strings.xml +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/values/strings.xml @@ -1,10 +1,14 @@ ExoAirPlayer + Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36 - + ExoPlayer AirPlay Receiver: service running ExoAirPlayer running at: Click to stop service. + + + Select tracks diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/values/styles.xml b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/values/styles.xml new file mode 100644 index 0000000..5247088 --- /dev/null +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/values/styles.xml @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/android-studio-project/constants.gradle b/android-studio-project/constants.gradle index 54f91ae..6273b0a 100644 --- a/android-studio-project/constants.gradle +++ b/android-studio-project/constants.gradle @@ -1,13 +1,14 @@ project.ext { - releaseVersionCode = Integer.parseInt("001000516", 10) - releaseVersion = '001.00.05-16API' + releaseVersionCode = Integer.parseInt("001000616", 10) + releaseVersion = '001.00.06-16API' minSdkVersion = 16 targetSdkVersion = 28 compileSdkVersion = 28 buildToolsVersion = '28.0.3' javaVersion = JavaVersion.VERSION_1_8 libVersionAndroidX = '1.1.0' - libVersionExoPlayer = '2.10.8' + libVersionMaterial = '1.0.0' + libVersionExoPlayer = '2.11.3' libVersionDdPlist = '1.23' libVersionHttpCore = '4.4.13' libVersionJmDNS = '3.5.5' diff --git a/tests/02. AirPlay sender.html b/tests/02. AirPlay sender.html index 6a343fb..95e57e5 100644 --- a/tests/02. AirPlay sender.html +++ b/tests/02. AirPlay sender.html @@ -84,11 +84,14 @@ const $airplay_port = document.querySelector('input#airplay_port') const $airplay_tls = document.querySelector('input#airplay_tls') const $video_url = document.querySelector('input#video_url') + const $caption_url = document.querySelector('input#caption_url') const $referer_url = document.querySelector('input#referer_url') const $play = document.querySelector('button#play') const $queue = document.querySelector('button#queue') const $pause = document.querySelector('button#pause') const $resume = document.querySelector('button#resume') + const $captions_on = document.querySelector('button#captions_on') + const $captions_off = document.querySelector('button#captions_off') const $stop = document.querySelector('button#stop') const $seek_hours = document.querySelector('input#seek_hours') const $seek_minutes = document.querySelector('input#seek_minutes') @@ -107,6 +110,7 @@ const encoded_urls = { video: null, + caption: null, referer: null } @@ -114,15 +118,18 @@ var b64, hash_regex_pattern, matches b64 = '[A-Za-z0-9+/=%]' - hash_regex_pattern = `^#/watch/(${b64}+?)(?:/referer/(${b64}+))?$` + hash_regex_pattern = `^#/watch/(${b64}+?)(?:/subtitle/(${b64}+?))?(?:/referer/(${b64}+?))?$` hash_regex_pattern = new RegExp(hash_regex_pattern) matches = hash_regex_pattern.exec(window.location.hash) if (matches && matches.length && matches[1]) { - encoded_urls.video = matches[1] - if (matches.length > 2 && matches[2]) { - encoded_urls.referer = matches[2] - } + encoded_urls.video = matches[1] + + if (matches[2]) + encoded_urls.caption = matches[2] + + if (matches[3]) + encoded_urls.referer = matches[3] } } @@ -179,6 +186,9 @@ if (encoded_urls.video) $video_url.value = decode_URL(encoded_urls.video) + if (encoded_urls.caption) + $caption_url.value = decode_URL(encoded_urls.caption) + if (encoded_urls.referer) $referer_url.value = decode_URL(encoded_urls.referer) } @@ -277,21 +287,37 @@ } // =============================================================================================== - // add event listeners to form fields - - $play.onclick = function(event) { - event.preventDefault() - event.stopPropagation() + // form field data collection helper + var get_video_POST_data = function() { var url = $video_url.value + var txt = $caption_url.value + var ref = $referer_url.value if (!url) { alert('Video URL is required') return } - var path = '/play' var data = `Content-Location: ${url}\nStart-Position: 0` + if (txt) { + data += `\nCaption-Location: ${txt}` + } + if (ref) { + data += `\nReferer: ${ref}` + } + return data + } + + // =============================================================================================== + // add event listeners to form fields + + $play.onclick = function(event) { + event.preventDefault() + event.stopPropagation() + + var path = '/play' + var data = get_video_POST_data() POST_message(path, data) } @@ -300,20 +326,8 @@ event.preventDefault() event.stopPropagation() - var url = $video_url.value - var ref = $referer_url.value - - if (!url) { - alert('Video URL is required') - return - } - var path = '/queue' - var data = `Content-Location: ${url}\nStart-Position: 0` - - if (ref) { - data += `\nReferer: ${ref}` - } + var data = get_video_POST_data() POST_message(path, data) } @@ -332,6 +346,20 @@ POST_message('/rate?value=1.0', null) } + $captions_on.onclick = function(event) { + event.preventDefault() + event.stopPropagation() + + POST_message('/captions?toggle=1', null) + } + + $captions_off.onclick = function(event) { + event.preventDefault() + event.stopPropagation() + + POST_message('/captions?toggle=0', null) + } + $stop.onclick = function(event) { event.preventDefault() event.stopPropagation() @@ -397,10 +425,12 @@ var item = $sample_videos.selectedOptions[0] var url = item.getAttribute('x-video-url') + var txt = item.getAttribute('x-caption-url') var ref = item.getAttribute('x-referer-url') if (url) { $video_url.value = url + $caption_url.value = (txt) ? txt : '' $referer_url.value = (ref) ? ref : '' } } @@ -433,6 +463,10 @@ Network Location of Video Media: Video URL: + + Caption URL: + + Referer URL: @@ -449,6 +483,10 @@ Remote Control of Video Playback: Pause Resume + + CC On + CC Off + Previous Next @@ -476,10 +514,11 @@ Sample Videos: -- video -- - Big Buck Bunny - HD World - CCTV - Sintel + Big Buck Bunny (vid:hls, subs:none) + Sintel (vid:hls, subs:srt) + Tears Of Steel (vid:dash, subs:vtt) + Dizzy (vid:mp4, subs:ttml) + Sample (vid:mp4, subs:ssa)