From 04d76fa8fc3f997580c38ef1965dd8e6a58a7f29 Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Thu, 17 Aug 2017 07:41:10 -0700 Subject: [PATCH] Allow easier ExoPlayer/Cast integration This CL adds the fundamental pieces for ExoPlayer/Cast integration and includes a demo app to showcase this functionality. However, media queues should be supported in the first release of this extension. Issue:#2283 ------------- Created by MOE: https://github.com/google/moe MOE_MIGRATED_REVID=165576892 --- constants.gradle | 1 + core_settings.gradle | 2 + demos/cast/README.md | 4 + demos/cast/build.gradle | 51 ++ demos/cast/src/main/AndroidManifest.xml | 43 ++ .../exoplayer2/castdemo/CastDemoUtil.java | 92 +++ .../exoplayer2/castdemo/MainActivity.java | 152 ++++ .../exoplayer2/castdemo/PlayerManager.java | 196 ++++++ .../src/main/res/layout/main_activity.xml | 41 ++ demos/cast/src/main/res/menu/menu.xml | 25 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 4315 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2734 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 6159 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 10578 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 11743 bytes demos/cast/src/main/res/values/strings.xml | 25 + demos/cast/src/main/res/values/styles.xml | 22 + extensions/cast/README.md | 33 + extensions/cast/build.gradle | 45 ++ extensions/cast/src/main/AndroidManifest.xml | 16 + .../exoplayer2/ext/cast/CastPlayer.java | 649 ++++++++++++++++++ .../exoplayer2/ext/cast/CastUtils.java | 94 +++ .../ext/cast/DefaultCastOptionsProvider.java | 42 ++ extensions/ima/build.gradle | 2 +- library/all/src/main/AndroidManifest.xml | 1 - 25 files changed, 1534 insertions(+), 2 deletions(-) create mode 100644 demos/cast/README.md create mode 100644 demos/cast/build.gradle create mode 100644 demos/cast/src/main/AndroidManifest.xml create mode 100644 demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/CastDemoUtil.java create mode 100644 demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/MainActivity.java create mode 100644 demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/PlayerManager.java create mode 100644 demos/cast/src/main/res/layout/main_activity.xml create mode 100644 demos/cast/src/main/res/menu/menu.xml create mode 100644 demos/cast/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 demos/cast/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 demos/cast/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 demos/cast/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 demos/cast/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 demos/cast/src/main/res/values/strings.xml create mode 100644 demos/cast/src/main/res/values/styles.xml create mode 100644 extensions/cast/README.md create mode 100644 extensions/cast/build.gradle create mode 100644 extensions/cast/src/main/AndroidManifest.xml create mode 100644 extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java create mode 100644 extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastUtils.java create mode 100644 extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/DefaultCastOptionsProvider.java diff --git a/constants.gradle b/constants.gradle index 576091f937..4107faab4c 100644 --- a/constants.gradle +++ b/constants.gradle @@ -22,6 +22,7 @@ project.ext { buildToolsVersion = '26' testSupportLibraryVersion = '0.5' supportLibraryVersion = '26.0.1' + playServicesLibraryVersion = '11.0.2' dexmakerVersion = '1.2' mockitoVersion = '1.9.5' releaseVersion = 'r2.5.1' diff --git a/core_settings.gradle b/core_settings.gradle index 7a8320b1a1..20a7c87bde 100644 --- a/core_settings.gradle +++ b/core_settings.gradle @@ -28,6 +28,7 @@ include modulePrefix + 'extension-ffmpeg' include modulePrefix + 'extension-flac' include modulePrefix + 'extension-gvr' include modulePrefix + 'extension-ima' +include modulePrefix + 'extension-cast' include modulePrefix + 'extension-mediasession' include modulePrefix + 'extension-okhttp' include modulePrefix + 'extension-opus' @@ -46,6 +47,7 @@ project(modulePrefix + 'extension-ffmpeg').projectDir = new File(rootDir, 'exten project(modulePrefix + 'extension-flac').projectDir = new File(rootDir, 'extensions/flac') project(modulePrefix + 'extension-gvr').projectDir = new File(rootDir, 'extensions/gvr') project(modulePrefix + 'extension-ima').projectDir = new File(rootDir, 'extensions/ima') +project(modulePrefix + 'extension-cast').projectDir = new File(rootDir, 'extensions/cast') project(modulePrefix + 'extension-mediasession').projectDir = new File(rootDir, 'extensions/mediasession') project(modulePrefix + 'extension-okhttp').projectDir = new File(rootDir, 'extensions/okhttp') project(modulePrefix + 'extension-opus').projectDir = new File(rootDir, 'extensions/opus') diff --git a/demos/cast/README.md b/demos/cast/README.md new file mode 100644 index 0000000000..2c68a5277a --- /dev/null +++ b/demos/cast/README.md @@ -0,0 +1,4 @@ +# Cast demo application # + +This folder contains a demo application that showcases ExoPlayer integration +with Google Cast. diff --git a/demos/cast/build.gradle b/demos/cast/build.gradle new file mode 100644 index 0000000000..a9fa27ad58 --- /dev/null +++ b/demos/cast/build.gradle @@ -0,0 +1,51 @@ +// Copyright (C) 2017 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +apply from: '../../constants.gradle' +apply plugin: 'com.android.application' + +android { + compileSdkVersion project.ext.compileSdkVersion + buildToolsVersion project.ext.buildToolsVersion + + defaultConfig { + minSdkVersion 16 + targetSdkVersion project.ext.targetSdkVersion + } + + buildTypes { + release { + shrinkResources true + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android.txt') + } + debug { + jniDebuggable = true + } + } + + lintOptions { + // The demo app does not have translations. + disable 'MissingTranslation' + } + +} + +dependencies { + compile project(modulePrefix + 'library-core') + compile project(modulePrefix + 'library-dash') + compile project(modulePrefix + 'library-hls') + compile project(modulePrefix + 'library-smoothstreaming') + compile project(modulePrefix + 'library-ui') + compile project(modulePrefix + 'extension-cast') +} diff --git a/demos/cast/src/main/AndroidManifest.xml b/demos/cast/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..eeb28438bd --- /dev/null +++ b/demos/cast/src/main/AndroidManifest.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/CastDemoUtil.java b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/CastDemoUtil.java new file mode 100644 index 0000000000..f819e54e50 --- /dev/null +++ b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/CastDemoUtil.java @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.castdemo; + +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.android.gms.cast.MediaInfo; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Utility methods and constants for the Cast demo application. + */ +/* package */ final class CastDemoUtil { + + public static final String MIME_TYPE_DASH = "application/dash+xml"; + public static final String MIME_TYPE_HLS = "application/vnd.apple.mpegurl"; + public static final String MIME_TYPE_SS = "application/vnd.ms-sstr+xml"; + public static final String MIME_TYPE_VIDEO_MP4 = MimeTypes.VIDEO_MP4; + + /** + * The list of samples available in the cast demo app. + */ + public static final List SAMPLES; + + /** + * Represents a media sample. + */ + public static final class Sample { + + /** + * The uri from which the media sample is obtained. + */ + public final String uri; + /** + * A descriptive name for the sample. + */ + public final String name; + /** + * The mime type of the media sample, as required by {@link MediaInfo#setContentType}. + */ + public final String type; + + /** + * @param uri See {@link #uri}. + * @param name See {@link #name}. + * @param type See {@link #type}. + */ + public Sample(String uri, String name, String type) { + this.uri = uri; + this.name = name; + this.type = type; + } + + @Override + public String toString() { + return name; + } + + } + + static { + // App samples. + ArrayList samples = new ArrayList<>(); + samples.add(new Sample("https://storage.googleapis.com/wvmedia/clear/h264/tears/tears.mpd", + "DASH (clear,MP4,H264)", MIME_TYPE_DASH)); + samples.add(new Sample("https://commondatastorage.googleapis.com/gtv-videos-bucket/CastVideos/" + + "hls/TearsOfSteel.m3u8", "Tears of Steel (HLS)", MIME_TYPE_HLS)); + samples.add(new Sample("https://html5demos.com/assets/dizzy.mp4", "Dizzy (MP4)", + MIME_TYPE_VIDEO_MP4)); + + + SAMPLES = Collections.unmodifiableList(samples); + + } + + private CastDemoUtil() {} + +} diff --git a/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/MainActivity.java b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/MainActivity.java new file mode 100644 index 0000000000..e1367858aa --- /dev/null +++ b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/MainActivity.java @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.castdemo; + +import android.graphics.Color; +import android.os.Bundle; +import android.support.v7.app.AppCompatActivity; +import android.view.KeyEvent; +import android.view.Menu; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.ListView; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.ext.cast.CastPlayer; +import com.google.android.exoplayer2.ui.PlaybackControlView; +import com.google.android.exoplayer2.ui.SimpleExoPlayerView; +import com.google.android.exoplayer2.util.Util; +import com.google.android.gms.cast.framework.CastButtonFactory; + +/** + * An activity that plays video using {@link SimpleExoPlayer} and {@link CastPlayer}. + */ +public class MainActivity extends AppCompatActivity { + + private SimpleExoPlayerView simpleExoPlayerView; + private PlaybackControlView castControlView; + private PlayerManager playerManager; + + // Activity lifecycle methods. + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.main_activity); + + simpleExoPlayerView = (SimpleExoPlayerView) findViewById(R.id.player_view); + simpleExoPlayerView.requestFocus(); + + castControlView = (PlaybackControlView) findViewById(R.id.cast_control_view); + + ListView sampleList = (ListView) findViewById(R.id.sample_list); + sampleList.setAdapter(new SampleListAdapter()); + sampleList.setOnItemClickListener(new SampleClickListener()); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + super.onCreateOptionsMenu(menu); + getMenuInflater().inflate(R.menu.menu, menu); + CastButtonFactory.setUpMediaRouteButton(getApplicationContext(), menu, + R.id.media_route_menu_item); + return true; + } + + @Override + public void onStart() { + super.onStart(); + if (Util.SDK_INT > 23) { + setupPlayerManager(); + } + } + + @Override + public void onResume() { + super.onResume(); + if ((Util.SDK_INT <= 23)) { + setupPlayerManager(); + } + } + + @Override + public void onPause() { + super.onPause(); + if (Util.SDK_INT <= 23) { + releasePlayerManager(); + } + } + + @Override + public void onStop() { + super.onStop(); + if (Util.SDK_INT > 23) { + releasePlayerManager(); + } + } + + // Activity input. + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + // If the event was not handled then see if the player view can handle it. + return super.dispatchKeyEvent(event) || playerManager.dispatchKeyEvent(event); + } + + // Internal methods. + + private void setupPlayerManager() { + playerManager = new PlayerManager(simpleExoPlayerView, castControlView, + getApplicationContext()); + } + + private void releasePlayerManager() { + playerManager.release(); + playerManager = null; + } + + // User controls. + + private final class SampleListAdapter extends ArrayAdapter { + + public SampleListAdapter() { + super(getApplicationContext(), android.R.layout.simple_list_item_1, CastDemoUtil.SAMPLES); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + View view = super.getView(position, convertView, parent); + view.setBackgroundColor(Color.WHITE); + return view; + } + + } + + private class SampleClickListener implements AdapterView.OnItemClickListener { + + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + if (parent.getSelectedItemPosition() != position) { + CastDemoUtil.Sample currentSample = CastDemoUtil.SAMPLES.get(position); + playerManager.setCurrentSample(currentSample, 0, true); + } + } + + } + +} diff --git a/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/PlayerManager.java b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/PlayerManager.java new file mode 100644 index 0000000000..741df7eff1 --- /dev/null +++ b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/PlayerManager.java @@ -0,0 +1,196 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.castdemo; + +import android.content.Context; +import android.net.Uri; +import android.view.KeyEvent; +import android.view.View; +import com.google.android.exoplayer2.DefaultRenderersFactory; +import com.google.android.exoplayer2.ExoPlayerFactory; +import com.google.android.exoplayer2.RenderersFactory; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.ext.cast.CastPlayer; +import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; +import com.google.android.exoplayer2.source.ExtractorMediaSource; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.dash.DashMediaSource; +import com.google.android.exoplayer2.source.dash.DefaultDashChunkSource; +import com.google.android.exoplayer2.source.hls.HlsMediaSource; +import com.google.android.exoplayer2.source.smoothstreaming.DefaultSsChunkSource; +import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.ui.PlaybackControlView; +import com.google.android.exoplayer2.ui.SimpleExoPlayerView; +import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; +import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory; +import com.google.android.gms.cast.framework.CastContext; + +/** + * Manages players for the ExoPlayer/Cast integration app. + */ +/* package */ final class PlayerManager implements CastPlayer.SessionAvailabilityListener { + + private static final int PLAYBACK_REMOTE = 1; + private static final int PLAYBACK_LOCAL = 2; + + private static final String USER_AGENT = "ExoCastDemoPlayer"; + private static final DefaultBandwidthMeter BANDWIDTH_METER = new DefaultBandwidthMeter(); + private static final DefaultHttpDataSourceFactory DATA_SOURCE_FACTORY = + new DefaultHttpDataSourceFactory(USER_AGENT, BANDWIDTH_METER); + + private final SimpleExoPlayerView exoPlayerView; + private final PlaybackControlView castControlView; + private final CastContext castContext; + private final SimpleExoPlayer exoPlayer; + private final CastPlayer castPlayer; + + private int playbackLocation; + private CastDemoUtil.Sample currentSample; + + /** + * @param exoPlayerView The {@link SimpleExoPlayerView} for local playback. + * @param castControlView The {@link PlaybackControlView} to control remote playback. + * @param context A {@link Context}. + */ + public PlayerManager(SimpleExoPlayerView exoPlayerView, PlaybackControlView castControlView, + Context context) { + this.exoPlayerView = exoPlayerView; + this.castControlView = castControlView; + castContext = CastContext.getSharedInstance(context); + + DefaultTrackSelector trackSelector = new DefaultTrackSelector(BANDWIDTH_METER); + RenderersFactory renderersFactory = new DefaultRenderersFactory(context, null); + exoPlayer = ExoPlayerFactory.newSimpleInstance(renderersFactory, trackSelector); + exoPlayerView.setPlayer(exoPlayer); + + castPlayer = new CastPlayer(castContext); + castPlayer.setSessionAvailabilityListener(this); + castControlView.setPlayer(castPlayer); + + setPlaybackLocation(castPlayer.isCastSessionAvailable() ? PLAYBACK_REMOTE : PLAYBACK_LOCAL); + } + + /** + * Starts playback of the given sample at the given position. + * + * @param currentSample The {@link CastDemoUtil} to play. + * @param positionMs The position at which playback should start. + * @param playWhenReady Whether the player should proceed when ready to do so. + */ + public void setCurrentSample(CastDemoUtil.Sample currentSample, long positionMs, + boolean playWhenReady) { + this.currentSample = currentSample; + if (playbackLocation == PLAYBACK_REMOTE) { + castPlayer.load(currentSample.name, currentSample.uri, currentSample.type, positionMs, + playWhenReady); + } else /* playbackLocation == PLAYBACK_LOCAL */ { + exoPlayer.setPlayWhenReady(playWhenReady); + exoPlayer.seekTo(positionMs); + exoPlayer.prepare(buildMediaSource(currentSample), true, true); + } + } + + /** + * Dispatches a given {@link KeyEvent} to whichever view corresponds according to the current + * playback location. + * + * @param event The {@link KeyEvent}. + * @return Whether the event was handled by the target view. + */ + public boolean dispatchKeyEvent(KeyEvent event) { + if (playbackLocation == PLAYBACK_REMOTE) { + return castControlView.dispatchKeyEvent(event); + } else /* playbackLocation == PLAYBACK_REMOTE */ { + return exoPlayerView.dispatchKeyEvent(event); + } + } + + /** + * Releases the manager and the players that it holds. + */ + public void release() { + castPlayer.setSessionAvailabilityListener(null); + castPlayer.release(); + exoPlayerView.setPlayer(null); + exoPlayer.release(); + } + + // CastPlayer.SessionAvailabilityListener implementation. + + @Override + public void onCastSessionAvailable() { + setPlaybackLocation(PLAYBACK_REMOTE); + } + + @Override + public void onCastSessionUnavailable() { + setPlaybackLocation(PLAYBACK_LOCAL); + } + + // Internal methods. + + private static MediaSource buildMediaSource(CastDemoUtil.Sample sample) { + Uri uri = Uri.parse(sample.uri); + switch (sample.type) { + case CastDemoUtil.MIME_TYPE_SS: + return new SsMediaSource(uri, DATA_SOURCE_FACTORY, + new DefaultSsChunkSource.Factory(DATA_SOURCE_FACTORY), null, null); + case CastDemoUtil.MIME_TYPE_DASH: + return new DashMediaSource(uri, DATA_SOURCE_FACTORY, + new DefaultDashChunkSource.Factory(DATA_SOURCE_FACTORY), null, null); + case CastDemoUtil.MIME_TYPE_HLS: + return new HlsMediaSource(uri, DATA_SOURCE_FACTORY, null, null); + case CastDemoUtil.MIME_TYPE_VIDEO_MP4: + return new ExtractorMediaSource(uri, DATA_SOURCE_FACTORY, new DefaultExtractorsFactory(), + null, null); + default: { + throw new IllegalStateException("Unsupported type: " + sample.type); + } + } + } + + private void setPlaybackLocation(int playbackLocation) { + if (this.playbackLocation == playbackLocation) { + return; + } + + // View management. + if (playbackLocation == PLAYBACK_LOCAL) { + exoPlayerView.setVisibility(View.VISIBLE); + castControlView.hide(); + } else { + exoPlayerView.setVisibility(View.GONE); + castControlView.show(); + } + + long playbackPositionMs = 0; + boolean playWhenReady = true; + if (exoPlayer != null) { + playbackPositionMs = exoPlayer.getCurrentPosition(); + playWhenReady = exoPlayer.getPlayWhenReady(); + } else if (this.playbackLocation == PLAYBACK_REMOTE) { + playbackPositionMs = castPlayer.getCurrentPosition(); + playWhenReady = castPlayer.getPlayWhenReady(); + } + + this.playbackLocation = playbackLocation; + if (currentSample != null) { + setCurrentSample(currentSample, playbackPositionMs, playWhenReady); + } + } + +} diff --git a/demos/cast/src/main/res/layout/main_activity.xml b/demos/cast/src/main/res/layout/main_activity.xml new file mode 100644 index 0000000000..7e39320e3b --- /dev/null +++ b/demos/cast/src/main/res/layout/main_activity.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/demos/cast/src/main/res/menu/menu.xml b/demos/cast/src/main/res/menu/menu.xml new file mode 100644 index 0000000000..075ad34ec4 --- /dev/null +++ b/demos/cast/src/main/res/menu/menu.xml @@ -0,0 +1,25 @@ + + + + + + + diff --git a/demos/cast/src/main/res/mipmap-hdpi/ic_launcher.png b/demos/cast/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..52e8dc93d9895cf4e991eb391eb3f608d89b4743 GIT binary patch literal 4315 zcmV<15G3!3P)~6bRYXAG+QmeMh z<9=$p6+cwf=e|ixvhG*soC~t_&w2dk_|E|#l$=g3AmQX@a)-d%4)=a{y7yeEy*xG6 z+KGgCKc%)Hesl-`{_5@Yi)1jFM%FerHyk1eSy>IOe`%e4JA@*uy0*RSWtpYCQ}Hd^afC+ zE2p`cz)Q0sf_CJ%u|3<3ty${l{sK3OYh^fH`Vqy*ud>-}eRl5L8A_yMtZ82wdd zvpKP6&z_x#6Gq+(q!%Yt@M2kG79Y9snvH zk6MrNtC@DWs;C2uCKCnfMH1^#L0w(lQ$+H`y9^0wmmr4c4HUC4ptNeLfP3?Ma4&xe z?iCRNwqNFFB;@Ubpp0vMZnsG&Ct%${~{l+%4e^KjtK|cPww>~v{@h@okK^F$kwIks>8y+96ikh(0f%+y@ zl8CCEEaE#zwOK!k+-|q4s;X+>lP`7eMA|2aH@S;)^7ZAk6cjB)=yx8ZQ9^>#?E#>E{;}1GXUALd(j+UkX1W1* zP}itNktp0|n-a*-kJ{SW|J|@*eV4QQB80*_5y=;=(Gn3oFHw$}H!@0)zNpm+kPl9V zQlH8DDZbMJF=DX@s|8y8TNLAw3mtg$ODjH2aR_N}kwZml6qVKM3epO;B`+^GLXz@A z7)1if7q!uXii(P^ym$!(!B9afBOtHxrIoZ3qSp6;oHwd7C#v0=l8Fk1bMf?$*&A)tO*GRT1hM0d^P{lk*+eY9 zOq1h3i8zllh`XblaKUT=#1UZVtwj^+%D!;&k7l-5b@mW=#QVj-_q&lhQz|7k4N zKboXQT6i)FpN~aDOe9Qiy#d?&d628Cg(OW>&~UXcXJ%%;OC(=>{~^-rzhqgSM?o-3 zkZV~4cE=|$Qv3@N-|dV}K+}RKRD2VI?GcGcyE+-#HA}CepcmrM5EqU5m?+f8M#DU4 z037SqL1}7&2MAh_DAwHeSTuU{=+lU#;ccjJW*N?tGl^k>oEed#!+r&6q*KWHN0PAY zCQXc>6+y9Hq9akC7>~v=U&3|hAUtkY(4y+=>vPtuS#u4MG_;05?Dss+6B~^bBsln5 zysjYPQ={|+`HE`j-4ji}{tS0vVNlyDRvx=|?|zv`8s3HqH+{tY$=i0@RgZM%qq_VFac1Zne`CeCqA_KU;P z8$^xqFK6}jNyvXTR&H`O=LqGrg^Cd(QNLpjxzI|6Q z#IlVNB*ZwI&L;O9!ObpaI$%&aWWO zrpso`m~j`81d`TRwrtt8e1nl4tf1}rCM3-|iFlGU{TM#{Yz2OJ{xhUp)$4CYQ#y-u z=98zWNb}P?co1BN5BZg&397EH&YLu8(hWosNLqu1`f^Sb_ZuZBVde=4;u%dnemjH_ zedl1_O>g*>w3w{CE(u~JtBdg&;)4;xVczuio4%`m-9D6+l^q^7Y}l1V5=dHO`0(N9 zaVfHv77Z075Yg1@=lz)a&>*Ip|0vSdUEA+UgqR$jYIFn%lBONS=srIoCA??Aqng-1N+qH# zq!sBcHMA9eLL4lge+uW`z3@1lZC+|{x-%;zB%~{m1d`U^(Yd&|csNgNHZ;Z9?)ULh zEV%A}#HbeJVpXTk^y>@zs+CYImUeA@u*6{V=FOi77g8%n=-$11qiC_wNI{7+k7HuO z6y1@Gh;xL*z_&ZJI`jH#u+IJiaz#bEcNs-#F`LcSIdkU33m2^k3Yj`}>aDbxWu&0w zsYmeRbD!!8T6j$#{Px&T%(>+)ZT_UYVb;(+8BJeLfh#i;9!1d?qa%m~I&Z{?5qAg| ztqOYZ!3WRdT4a%tg2Yh&O~hDDkl)Dp=9Mw%^~(XgH+CJqjvR}H*Y;5h`sFPIi8>P> z3(KgHaPHUv#o^FNr@-sa;lqd1ZoKiv3;YS{L{8&LeHBG<8(D)!d^87(yLI;~NEGiO zgBEd0kO^_Vh`NEpQ}OHL!+Bi~EDcZcZrVq|^uhbEXQV?hne;cs+7LY)!R55Hv_Zmw z>Z0`{?1vwIxSuRZ1?_YAAWrH6E%u_YYf||c#6uy*m@L z@B08nNw32;eJb1~CGD#-I)b^wLYuFDfdUW%NAgwXe4`b;e=A z^{-*^mE9pCN-RUB#7xAi-|j@bc8w2lq}QA*%uG81%kgY@WViN*4Ti!M1h~|ibMCq4 zUf^4x{ia!JYHB=-waF+!0xfmkAH4>{`_9F%e)I4y>rL{sBY%<;H{%5FaRQ&NEX1xt zGwiYl%?Lt_{vMF|`|rO`)=Mw#2z&V9hr5-Olq@q+kgq7#8kLrjHbbS~l3Xl1UJH}W z4KxQULBBZf-@kw5?YG|^uBDgW5kk5yTC}Jq3zyj#LHdZOd1F4)X?G<`O%C<-anN$~ zqo<~(#=_|7!#HKK?%dBsAN8Zh|A_YAoL%2^ z^ytx4x7)2Rh`)G$u?0PtGOef6alAS=3%~6sM^U|<*BGBpfxbayYGvGU#~n9|KB|3f z-w{gA>f5*PQw+yLoMG$!olR|%3qv#V5j*`jeCteiPW;BLEykhJM!01qh;#~m)Z&}u zBd@*o+B2eWdh?z@Q|*EY6DB0{FVYKiC6U9FFl=c)MDZ1f_J-^RG&yA;*cosTh-c@k z<)TreMs;VOE(|=i^i!=1x%8J`etEaDva&)q$4MO{=|D*%4pJ8DjQ(GJbpYJ&s^s?a z`|QhQq)Qu9OMh+@8XDT|_uqf-SHUBYbmI?!ex2!n$PseB{r1}d+#KvC`l2&xwYwgj z-nnzqAxLzyJ=1Bj@h-c(Jb0McJAB2U4T_yUUQ+I-vbOyQdlTSYB$%*7)jtI>> z{^7)kXX1LYnIrHKuF`w)nY)Q+H%N|tB$3N|_wM~Xhs$)%DIEW3g7}Wzv1!w$jHINb z7sRs~A36Fp>HnIw0?ve@FlwuE|<%X_#yW`7z~EUzs!+mTGFu9jthPn$-0ZhVu{Sc9EK0O-R=it zGPa^9K5~&$xQ)7vnl+5F$#8dDtyX_57K`G)(;XJ|60!t!T>Iqlk6Rm=fEjzcK3@WdKaF#o7U#JdDfC%*?x9-!L%k-lrv_#VD{ ziJrh+sGLf?Ob_2ms4S?LsC+NJCvock(?B@!Fg>gw1mU3?Dd1-i;r~+ubmLq2>BjDM4V6tA8agFxPWeyg zDpJb79S+B-gxDX_bOf6S$^hMn#ZUUj0(DuLppCGBU2Ph+?NYJ6M#aWj6+2rsI9&mw zAyrkK+}YWADj^u3@S}q=Af-HpXFjla30c1k+S+%BcOiGC!L67<9u8?j)x21{Bx;&9 zHiD8m4Gtx+38&NfcU@iGv4mhq+hK=HA*0b4M#A3?m^LiZS_gIMFsK>N5YIx*iiWoS zBgp3UaBGTgB%t`oD;4~etzh#G6^iOct3_f1MWVD`gG&t*HK|yw)^{gPnjB69R|9k* zuQttrM6fv7UI;ZO9%^Q!j@L*|BDC$lK^m)k=X(4MjLKJ#l;S|dFLtEnDI5tjn+3os+AI=IGV*JN6Y+mKu%ksoJ00VC=(I_AJOR>ARkhaE z)>nj>9!>}yZ|8#w<}5mSGl7r{kZ4y%By6*z7#{O6UwylR2!1=}F8meR#+ zmnhz&;hCT9NZaZ{7ZvdaGEmd#lvI| zTnCI(acR5uGz=$%jy~We>S!tDVm>hx1Im&}Y)VeV{PU6zV9S66xY8q`Wk

KMFM; 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 literal 0 HcmV?d00001 diff --git a/demos/cast/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/demos/cast/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..d1eb9b78cf195a355c8354f38c22999778ceb52a GIT binary patch literal 11743 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 literal 0 HcmV?d00001 diff --git a/demos/cast/src/main/res/values/strings.xml b/demos/cast/src/main/res/values/strings.xml new file mode 100644 index 0000000000..503892da27 --- /dev/null +++ b/demos/cast/src/main/res/values/strings.xml @@ -0,0 +1,25 @@ + + + + + + ExoCast Demo + + ExoCast + + DRM scheme not supported by this device. + + diff --git a/demos/cast/src/main/res/values/styles.xml b/demos/cast/src/main/res/values/styles.xml new file mode 100644 index 0000000000..1484a68a68 --- /dev/null +++ b/demos/cast/src/main/res/values/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/extensions/cast/README.md b/extensions/cast/README.md new file mode 100644 index 0000000000..73f7041729 --- /dev/null +++ b/extensions/cast/README.md @@ -0,0 +1,33 @@ +# ExoPlayer Cast extension # + +## Description ## + +The cast extension is a [Player][] implementation that controls playback on a +Cast receiver app. + +[Player]: https://google.github.io/ExoPlayer/doc/reference/index.html?com/google/android/exoplayer2/Player.html + +## Getting the extension ## + +The easiest way to use the extension is to add it as a gradle dependency: + +```gradle +compile 'com.google.android.exoplayer:extension-cast:rX.X.X' +``` + +where `rX.X.X` is the version, which must match the version of the ExoPlayer +library being used. + +Alternatively, you can clone the ExoPlayer repository and depend on the module +locally. Instructions for doing this can be found in ExoPlayer's +[top level README][]. + +[top level README]: https://github.com/google/ExoPlayer/blob/release-v2/README.md + +## Using the extension ## + +Create a `CastPlayer` and use it to integrate Cast into your app using +ExoPlayer's common Player interface. You can try the Cast Extension to see how a +[PlaybackControlView][] can be used to control playback in a remote receiver app. + +[PlaybackControlView]: https://google.github.io/ExoPlayer/doc/reference/com/google/android/exoplayer2/ui/PlaybackControlView.html diff --git a/extensions/cast/build.gradle b/extensions/cast/build.gradle new file mode 100644 index 0000000000..7d252332c9 --- /dev/null +++ b/extensions/cast/build.gradle @@ -0,0 +1,45 @@ +// Copyright (C) 2017 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +apply from: '../../constants.gradle' +apply plugin: 'com.android.library' + +android { + compileSdkVersion project.ext.compileSdkVersion + buildToolsVersion project.ext.buildToolsVersion + + defaultConfig { + minSdkVersion 14 + targetSdkVersion project.ext.targetSdkVersion + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + } +} + +dependencies { + compile 'com.android.support:appcompat-v7:' + supportLibraryVersion + compile 'com.android.support:mediarouter-v7:' + supportLibraryVersion + compile 'com.google.android.gms:play-services-cast-framework:' + playServicesLibraryVersion + compile project(modulePrefix + 'library-core') + compile project(modulePrefix + 'library-ui') +} + +ext { + javadocTitle = 'Cast extension' +} +apply from: '../../javadoc_library.gradle' + +ext { + releaseArtifact = 'extension-cast' + releaseDescription = 'Cast extension for ExoPlayer.' +} +apply from: '../../publish.gradle' diff --git a/extensions/cast/src/main/AndroidManifest.xml b/extensions/cast/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..c12fc1289f --- /dev/null +++ b/extensions/cast/src/main/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java new file mode 100644 index 0000000000..ef84c04c04 --- /dev/null +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java @@ -0,0 +1,649 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.cast; + +import android.support.annotation.Nullable; +import android.util.Log; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.PlaybackParameters; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.source.SinglePeriodTimeline; +import com.google.android.exoplayer2.source.TrackGroup; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.trackselection.FixedTrackSelection; +import com.google.android.exoplayer2.trackselection.TrackSelection; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.android.exoplayer2.util.Util; +import com.google.android.gms.cast.CastStatusCodes; +import com.google.android.gms.cast.MediaInfo; +import com.google.android.gms.cast.MediaMetadata; +import com.google.android.gms.cast.MediaStatus; +import com.google.android.gms.cast.MediaTrack; +import com.google.android.gms.cast.framework.CastContext; +import com.google.android.gms.cast.framework.CastSession; +import com.google.android.gms.cast.framework.SessionManager; +import com.google.android.gms.cast.framework.SessionManagerListener; +import com.google.android.gms.cast.framework.media.RemoteMediaClient; +import com.google.android.gms.cast.framework.media.RemoteMediaClient.MediaChannelResult; +import com.google.android.gms.common.api.CommonStatusCodes; +import com.google.android.gms.common.api.ResultCallback; +import java.util.List; +import java.util.concurrent.CopyOnWriteArraySet; + +/** + * {@link Player} implementation that communicates with a Cast receiver app. + * + *

Calls to the methods in this class depend on the availability of an underlying cast session. + * If no session is available, method calls have no effect. To keep track of the underyling session, + * {@link #isCastSessionAvailable()} can be queried and {@link SessionAvailabilityListener} can be + * implemented and attached to the player. + * + *

Methods should be called on the application's main thread. + * + *

Known issues: + *

    + *
  • Part of the Cast API is not exposed through this interface. For instance, volume settings + * and track selection.
  • + *
  • Repeat mode is not working. See [internal: b/64137174].
  • + *
+ */ +public final class CastPlayer implements Player { + + /** + * Listener of changes in the cast session availability. + */ + public interface SessionAvailabilityListener { + + /** + * Called when a cast session becomes available to the player. + */ + void onCastSessionAvailable(); + + /** + * Called when the cast session becomes unavailable. + */ + void onCastSessionUnavailable(); + + } + + private static final String TAG = "CastPlayer"; + + private static final int RENDERER_COUNT = 3; + private static final int RENDERER_INDEX_VIDEO = 0; + private static final int RENDERER_INDEX_AUDIO = 1; + private static final int RENDERER_INDEX_TEXT = 2; + private static final long PROGRESS_REPORT_PERIOD_MS = 1000; + private static final TrackGroupArray EMPTY_TRACK_GROUP_ARRAY = new TrackGroupArray(); + private static final TrackSelectionArray EMPTY_TRACK_SELECTION_ARRAY = + new TrackSelectionArray(null, null, null); + private static final long[] EMPTY_TRACK_ID_ARRAY = new long[0]; + + private final CastContext castContext; + private final Timeline.Window window; + + // Result callbacks. + private final StatusListener statusListener; + private final RepeatModeResultCallback repeatModeResultCallback; + private final SeekResultCallback seekResultCallback; + + // Listeners. + private final CopyOnWriteArraySet listeners; + private SessionAvailabilityListener sessionAvailabilityListener; + + // Internal state. + private RemoteMediaClient remoteMediaClient; + private Timeline currentTimeline; + private TrackGroupArray currentTrackGroups; + private TrackSelectionArray currentTrackSelection; + private long lastReportedPositionMs; + private long pendingSeekPositionMs; + + /** + * @param castContext The context from which the cast session is obtained. + */ + public CastPlayer(CastContext castContext) { + this.castContext = castContext; + window = new Timeline.Window(); + statusListener = new StatusListener(); + repeatModeResultCallback = new RepeatModeResultCallback(); + seekResultCallback = new SeekResultCallback(); + listeners = new CopyOnWriteArraySet<>(); + SessionManager sessionManager = castContext.getSessionManager(); + sessionManager.addSessionManagerListener(statusListener, CastSession.class); + CastSession session = sessionManager.getCurrentCastSession(); + remoteMediaClient = session != null ? session.getRemoteMediaClient() : null; + pendingSeekPositionMs = C.TIME_UNSET; + updateInternalState(); + } + + /** + * Loads media into the receiver app. + * + * @param title The title of the media sample. + * @param url The url from which the media is obtained. + * @param contentMimeType The mime type of the content to play. + * @param positionMs The position at which the playback should start in milliseconds. + * @param playWhenReady Whether the player should start playback as soon as it is ready to do so. + */ + public void load(String title, String url, String contentMimeType, long positionMs, + boolean playWhenReady) { + lastReportedPositionMs = 0; + if (remoteMediaClient != null) { + MediaMetadata movieMetadata = new MediaMetadata(MediaMetadata.MEDIA_TYPE_MOVIE); + movieMetadata.putString(MediaMetadata.KEY_TITLE, title); + MediaInfo mediaInfo = new MediaInfo.Builder(url).setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) + .setContentType(contentMimeType).setMetadata(movieMetadata).build(); + remoteMediaClient.load(mediaInfo, playWhenReady, positionMs); + } + } + + /** + * Returns whether a cast session is available for playback. + */ + public boolean isCastSessionAvailable() { + return remoteMediaClient != null; + } + + /** + * Sets a listener for updates on the cast session availability. + * + * @param listener The {@link SessionAvailabilityListener}. + */ + public void setSessionAvailabilityListener(SessionAvailabilityListener listener) { + sessionAvailabilityListener = listener; + } + + // Player implementation. + + @Override + public void addListener(EventListener listener) { + listeners.add(listener); + } + + @Override + public void removeListener(EventListener listener) { + listeners.remove(listener); + } + + @Override + public int getPlaybackState() { + if (remoteMediaClient == null) { + return STATE_IDLE; + } + int receiverAppStatus = remoteMediaClient.getPlayerState(); + switch (receiverAppStatus) { + case MediaStatus.PLAYER_STATE_BUFFERING: + return STATE_BUFFERING; + case MediaStatus.PLAYER_STATE_PLAYING: + case MediaStatus.PLAYER_STATE_PAUSED: + return STATE_READY; + case MediaStatus.PLAYER_STATE_IDLE: + case MediaStatus.PLAYER_STATE_UNKNOWN: + default: + return STATE_IDLE; + } + } + + @Override + public void setPlayWhenReady(boolean playWhenReady) { + if (remoteMediaClient == null) { + return; + } + if (playWhenReady) { + remoteMediaClient.play(); + } else { + remoteMediaClient.pause(); + } + } + + @Override + public boolean getPlayWhenReady() { + return remoteMediaClient != null && !remoteMediaClient.isPaused(); + } + + @Override + public void seekToDefaultPosition() { + seekTo(0); + } + + @Override + public void seekToDefaultPosition(int windowIndex) { + seekTo(windowIndex, 0); + } + + @Override + public void seekTo(long positionMs) { + seekTo(0, positionMs); + } + + @Override + public void seekTo(int windowIndex, long positionMs) { + if (remoteMediaClient != null) { + remoteMediaClient.seek(positionMs).setResultCallback(seekResultCallback); + pendingSeekPositionMs = positionMs; + for (EventListener listener : listeners) { + listener.onPositionDiscontinuity(); + } + } + } + + @Override + public void setPlaybackParameters(@Nullable PlaybackParameters playbackParameters) { + // Unsupported by the RemoteMediaClient API. Do nothing. + } + + @Override + public PlaybackParameters getPlaybackParameters() { + return PlaybackParameters.DEFAULT; + } + + @Override + public void stop() { + if (remoteMediaClient != null) { + remoteMediaClient.stop(); + } + } + + @Override + public void release() { + castContext.getSessionManager().removeSessionManagerListener(statusListener, CastSession.class); + } + + @Override + public int getRendererCount() { + // We assume there are three renderers: video, audio, and text. + return RENDERER_COUNT; + } + + @Override + public int getRendererType(int index) { + switch (index) { + case RENDERER_INDEX_VIDEO: + return C.TRACK_TYPE_VIDEO; + case RENDERER_INDEX_AUDIO: + return C.TRACK_TYPE_AUDIO; + case RENDERER_INDEX_TEXT: + return C.TRACK_TYPE_TEXT; + default: + throw new IndexOutOfBoundsException(); + } + } + + @Override + public void setRepeatMode(@RepeatMode int repeatMode) { + if (remoteMediaClient != null) { + int castRepeatMode; + switch (repeatMode) { + case REPEAT_MODE_ONE: + castRepeatMode = MediaStatus.REPEAT_MODE_REPEAT_SINGLE; + break; + case REPEAT_MODE_ALL: + castRepeatMode = MediaStatus.REPEAT_MODE_REPEAT_ALL; + break; + case REPEAT_MODE_OFF: + castRepeatMode = MediaStatus.REPEAT_MODE_REPEAT_OFF; + break; + default: + throw new IllegalArgumentException(); + } + remoteMediaClient.queueSetRepeatMode(castRepeatMode, null) + .setResultCallback(repeatModeResultCallback); + } + } + + @Override + @RepeatMode public int getRepeatMode() { + if (remoteMediaClient == null) { + return REPEAT_MODE_OFF; + } + MediaStatus mediaStatus = getMediaStatus(); + if (mediaStatus == null) { + // No media session active, yet. + return REPEAT_MODE_OFF; + } + int castRepeatMode = mediaStatus.getQueueRepeatMode(); + switch (castRepeatMode) { + case MediaStatus.REPEAT_MODE_REPEAT_SINGLE: + return REPEAT_MODE_ONE; + case MediaStatus.REPEAT_MODE_REPEAT_ALL: + case MediaStatus.REPEAT_MODE_REPEAT_ALL_AND_SHUFFLE: + return REPEAT_MODE_ALL; + case MediaStatus.REPEAT_MODE_REPEAT_OFF: + return REPEAT_MODE_OFF; + default: + throw new IllegalStateException(); + } + } + + @Override + public TrackSelectionArray getCurrentTrackSelections() { + return currentTrackSelection; + } + + @Override + public TrackGroupArray getCurrentTrackGroups() { + return currentTrackGroups; + } + + @Override + public Timeline getCurrentTimeline() { + return currentTimeline; + } + + @Override + @Nullable public Object getCurrentManifest() { + return null; + } + + @Override + public int getCurrentPeriodIndex() { + return 0; + } + + @Override + public int getCurrentWindowIndex() { + return 0; + } + + @Override + public long getDuration() { + return currentTimeline.isEmpty() ? C.TIME_UNSET + : currentTimeline.getWindow(0, window).getDurationMs(); + } + + @Override + public long getCurrentPosition() { + return remoteMediaClient == null ? lastReportedPositionMs + : pendingSeekPositionMs != C.TIME_UNSET ? pendingSeekPositionMs + : remoteMediaClient.getApproximateStreamPosition(); + } + + @Override + public long getBufferedPosition() { + return getCurrentPosition(); + } + + @Override + public int getBufferedPercentage() { + long position = getBufferedPosition(); + long duration = getDuration(); + return position == C.TIME_UNSET || duration == C.TIME_UNSET ? 0 + : duration == 0 ? 100 + : Util.constrainValue((int) ((position * 100) / duration), 0, 100); + } + + @Override + public boolean isCurrentWindowDynamic() { + return !currentTimeline.isEmpty() + && currentTimeline.getWindow(getCurrentWindowIndex(), window).isDynamic; + } + + @Override + public boolean isCurrentWindowSeekable() { + return !currentTimeline.isEmpty() + && currentTimeline.getWindow(getCurrentWindowIndex(), window).isSeekable; + } + + @Override + public boolean isPlayingAd() { + return false; + } + + @Override + public int getCurrentAdGroupIndex() { + return C.INDEX_UNSET; + } + + @Override + public int getCurrentAdIndexInAdGroup() { + return C.INDEX_UNSET; + } + + @Override + public boolean isLoading() { + return false; + } + + @Override + public long getContentPosition() { + return getCurrentPosition(); + } + + // Internal methods. + + private void setRemoteMediaClient(@Nullable RemoteMediaClient remoteMediaClient) { + if (this.remoteMediaClient == remoteMediaClient) { + // Do nothing. + return; + } + if (this.remoteMediaClient != null) { + this.remoteMediaClient.removeListener(statusListener); + this.remoteMediaClient.removeProgressListener(statusListener); + } + this.remoteMediaClient = remoteMediaClient; + if (remoteMediaClient != null) { + if (sessionAvailabilityListener != null) { + sessionAvailabilityListener.onCastSessionAvailable(); + } + remoteMediaClient.addListener(statusListener); + remoteMediaClient.addProgressListener(statusListener, PROGRESS_REPORT_PERIOD_MS); + } else { + if (sessionAvailabilityListener != null) { + sessionAvailabilityListener.onCastSessionUnavailable(); + } + } + } + + private @Nullable MediaStatus getMediaStatus() { + return remoteMediaClient != null ? remoteMediaClient.getMediaStatus() : null; + } + + private @Nullable MediaInfo getMediaInfo() { + return remoteMediaClient != null ? remoteMediaClient.getMediaInfo() : null; + } + + private void updateInternalState() { + currentTimeline = Timeline.EMPTY; + currentTrackGroups = EMPTY_TRACK_GROUP_ARRAY; + currentTrackSelection = EMPTY_TRACK_SELECTION_ARRAY; + MediaInfo mediaInfo = getMediaInfo(); + if (mediaInfo == null) { + return; + } + long streamDurationMs = mediaInfo.getStreamDuration(); + boolean isSeekable = streamDurationMs != MediaInfo.UNKNOWN_DURATION; + currentTimeline = new SinglePeriodTimeline( + isSeekable ? C.msToUs(streamDurationMs) : C.TIME_UNSET, isSeekable); + + List tracks = mediaInfo.getMediaTracks(); + if (tracks == null) { + return; + } + + MediaStatus mediaStatus = getMediaStatus(); + long[] activeTrackIds = mediaStatus != null ? mediaStatus.getActiveTrackIds() : null; + if (activeTrackIds == null) { + activeTrackIds = EMPTY_TRACK_ID_ARRAY; + } + + TrackGroup[] trackGroups = new TrackGroup[tracks.size()]; + TrackSelection[] trackSelections = new TrackSelection[RENDERER_COUNT]; + for (int i = 0; i < tracks.size(); i++) { + MediaTrack mediaTrack = tracks.get(i); + trackGroups[i] = new TrackGroup(CastUtils.mediaTrackToFormat(mediaTrack)); + + long id = mediaTrack.getId(); + int trackType = MimeTypes.getTrackType(mediaTrack.getContentType()); + int rendererIndex = getRendererIndexForTrackType(trackType); + if (isTrackActive(id, activeTrackIds) && rendererIndex != C.INDEX_UNSET + && trackSelections[rendererIndex] == null) { + trackSelections[rendererIndex] = new FixedTrackSelection(trackGroups[i], 0); + } + } + currentTrackSelection = new TrackSelectionArray(trackSelections); + currentTrackGroups = new TrackGroupArray(trackGroups); + } + + private static boolean isTrackActive(long id, long[] activeTrackIds) { + for (long activeTrackId : activeTrackIds) { + if (activeTrackId == id) { + return true; + } + } + return false; + } + + private static int getRendererIndexForTrackType(int trackType) { + return trackType == C.TRACK_TYPE_VIDEO ? RENDERER_INDEX_VIDEO + : trackType == C.TRACK_TYPE_AUDIO ? RENDERER_INDEX_AUDIO + : trackType == C.TRACK_TYPE_TEXT ? RENDERER_INDEX_TEXT + : C.INDEX_UNSET; + } + + private final class StatusListener implements RemoteMediaClient.Listener, + SessionManagerListener, RemoteMediaClient.ProgressListener { + + // RemoteMediaClient.ProgressListener implementation. + + @Override + public void onProgressUpdated(long progressMs, long unusedDurationMs) { + lastReportedPositionMs = progressMs; + } + + // RemoteMediaClient.Listener implementation. + + @Override + public void onStatusUpdated() { + boolean playWhenReady = getPlayWhenReady(); + int playbackState = getPlaybackState(); + for (EventListener listener : listeners) { + listener.onPlayerStateChanged(playWhenReady, playbackState); + } + } + + @Override + public void onMetadataUpdated() { + updateInternalState(); + for (EventListener listener : listeners) { + listener.onTracksChanged(currentTrackGroups, currentTrackSelection); + listener.onTimelineChanged(currentTimeline, null); + } + } + + @Override + public void onQueueStatusUpdated() {} + + @Override + public void onPreloadStatusUpdated() {} + + @Override + public void onSendingRemoteMediaRequest() {} + + @Override + public void onAdBreakStatusUpdated() {} + + + // SessionManagerListener implementation. + + @Override + public void onSessionStarted(CastSession castSession, String s) { + setRemoteMediaClient(castSession.getRemoteMediaClient()); + } + + @Override + public void onSessionResumed(CastSession castSession, boolean b) { + setRemoteMediaClient(castSession.getRemoteMediaClient()); + } + + @Override + public void onSessionEnded(CastSession castSession, int i) { + setRemoteMediaClient(null); + } + + @Override + public void onSessionSuspended(CastSession castSession, int i) { + setRemoteMediaClient(null); + } + + @Override + public void onSessionResumeFailed(CastSession castSession, int statusCode) { + Log.e(TAG, "Session resume failed. Error code " + statusCode + ": " + + CastUtils.getLogString(statusCode)); + } + + @Override + public void onSessionStarting(CastSession castSession) { + // Do nothing. + } + + @Override + public void onSessionStartFailed(CastSession castSession, int statusCode) { + Log.e(TAG, "Session start failed. Error code " + statusCode + ": " + + CastUtils.getLogString(statusCode)); + } + + @Override + public void onSessionEnding(CastSession castSession) { + // Do nothing. + } + + @Override + public void onSessionResuming(CastSession castSession, String s) { + // Do nothing. + } + + } + + // Result callbacks hooks. + + private final class RepeatModeResultCallback implements ResultCallback { + + @Override + public void onResult(MediaChannelResult result) { + int statusCode = result.getStatus().getStatusCode(); + if (statusCode == CommonStatusCodes.SUCCESS) { + int repeatMode = getRepeatMode(); + for (EventListener listener : listeners) { + listener.onRepeatModeChanged(repeatMode); + } + } else { + Log.e(TAG, "Set repeat mode failed. Error code " + statusCode + ": " + + CastUtils.getLogString(statusCode)); + } + } + + } + + private final class SeekResultCallback implements ResultCallback { + + @Override + public void onResult(MediaChannelResult result) { + int statusCode = result.getStatus().getStatusCode(); + if (statusCode == CommonStatusCodes.SUCCESS) { + pendingSeekPositionMs = C.TIME_UNSET; + } else if (statusCode == CastStatusCodes.REPLACED) { + // A seek was executed before this one completed. Do nothing. + } else { + Log.e(TAG, "Seek failed. Error code " + statusCode + ": " + + CastUtils.getLogString(statusCode)); + } + } + + } + +} diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastUtils.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastUtils.java new file mode 100644 index 0000000000..de60437444 --- /dev/null +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastUtils.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.cast; + +import com.google.android.exoplayer2.Format; +import com.google.android.gms.cast.CastStatusCodes; +import com.google.android.gms.cast.MediaTrack; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Utility methods for ExoPlayer/Cast integration. + */ +/* package */ final class CastUtils { + + private static final Map CAST_STATUS_CODE_TO_STRING; + + /** + * Returns a descriptive log string for the given {@code statusCode}, or "Unknown." if not one of + * {@link CastStatusCodes}. + * + * @param statusCode A Cast API status code. + * @return A descriptive log string for the given {@code statusCode}, or "Unknown." if not one of + * {@link CastStatusCodes}. + */ + public static String getLogString(int statusCode) { + String description = CAST_STATUS_CODE_TO_STRING.get(statusCode); + return description != null ? description : "Unknown."; + } + + /** + * Creates a {@link Format} instance containing all information contained in the given + * {@link MediaTrack} object. + * + * @param mediaTrack The {@link MediaTrack}. + * @return The equivalent {@link Format}. + */ + public static Format mediaTrackToFormat(MediaTrack mediaTrack) { + return Format.createContainerFormat(mediaTrack.getContentId(), mediaTrack.getContentType(), + null, null, Format.NO_VALUE, 0, mediaTrack.getLanguage()); + } + + static { + HashMap statusCodeToString = new HashMap<>(); + statusCodeToString.put(CastStatusCodes.APPLICATION_NOT_FOUND, + "A requested application could not be found."); + statusCodeToString.put(CastStatusCodes.APPLICATION_NOT_RUNNING, + "A requested application is not currently running."); + statusCodeToString.put(CastStatusCodes.AUTHENTICATION_FAILED, "Authentication failure."); + statusCodeToString.put(CastStatusCodes.CANCELED, "An in-progress request has been " + + "canceled, most likely because another action has preempted it."); + statusCodeToString.put(CastStatusCodes.ERROR_SERVICE_CREATION_FAILED, + "The Cast Remote Display service could not be created."); + statusCodeToString.put(CastStatusCodes.ERROR_SERVICE_DISCONNECTED, + "The Cast Remote Display service was disconnected."); + statusCodeToString.put(CastStatusCodes.FAILED, "The in-progress request failed."); + statusCodeToString.put(CastStatusCodes.INTERNAL_ERROR, "An internal error has occurred."); + statusCodeToString.put(CastStatusCodes.INTERRUPTED, + "A blocking call was interrupted while waiting and did not run to completion."); + statusCodeToString.put(CastStatusCodes.INVALID_REQUEST, "An invalid request was made."); + statusCodeToString.put(CastStatusCodes.MESSAGE_SEND_BUFFER_TOO_FULL, "A message could " + + "not be sent because there is not enough room in the send buffer at this time."); + statusCodeToString.put(CastStatusCodes.MESSAGE_TOO_LARGE, + "A message could not be sent because it is too large."); + statusCodeToString.put(CastStatusCodes.NETWORK_ERROR, "Network I/O error."); + statusCodeToString.put(CastStatusCodes.NOT_ALLOWED, + "The request was disallowed and could not be completed."); + statusCodeToString.put(CastStatusCodes.REPLACED, + "The request's progress is no longer being tracked because another request of the same type" + + " has been made before the first request completed."); + statusCodeToString.put(CastStatusCodes.SUCCESS, "Success."); + statusCodeToString.put(CastStatusCodes.TIMEOUT, "An operation has timed out."); + statusCodeToString.put(CastStatusCodes.UNKNOWN_ERROR, + "An unknown, unexpected error has occurred."); + CAST_STATUS_CODE_TO_STRING = Collections.unmodifiableMap(statusCodeToString); + } + + private CastUtils() {} + +} diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/DefaultCastOptionsProvider.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/DefaultCastOptionsProvider.java new file mode 100644 index 0000000000..06f0bec971 --- /dev/null +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/DefaultCastOptionsProvider.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.ext.cast; + +import android.content.Context; +import com.google.android.gms.cast.CastMediaControlIntent; +import com.google.android.gms.cast.framework.CastOptions; +import com.google.android.gms.cast.framework.OptionsProvider; +import com.google.android.gms.cast.framework.SessionProvider; +import java.util.List; + +/** + * A convenience {@link OptionsProvider} to target the default cast receiver app. + */ +public final class DefaultCastOptionsProvider implements OptionsProvider { + + @Override + public CastOptions getCastOptions(Context context) { + return new CastOptions.Builder() + .setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID) + .setStopReceiverApplicationWhenEndingSession(true).build(); + } + + @Override + public List getAdditionalSessionProviders(Context context) { + return null; + } + +} diff --git a/extensions/ima/build.gradle b/extensions/ima/build.gradle index a4ead9e01f..c084ec6bf8 100644 --- a/extensions/ima/build.gradle +++ b/extensions/ima/build.gradle @@ -22,7 +22,7 @@ dependencies { // |-- com.android.support:support-v4:25.2.0 compile 'com.android.support:support-v4:' + supportLibraryVersion compile 'com.google.ads.interactivemedia.v3:interactivemedia:3.7.4' - compile 'com.google.android.gms:play-services-ads:11.0.2' + compile 'com.google.android.gms:play-services-ads:' + playServicesLibraryVersion androidTestCompile project(modulePrefix + 'library') androidTestCompile 'com.google.dexmaker:dexmaker:' + dexmakerVersion androidTestCompile 'com.google.dexmaker:dexmaker-mockito:' + dexmakerVersion diff --git a/library/all/src/main/AndroidManifest.xml b/library/all/src/main/AndroidManifest.xml index 1efda648e9..f31f55b40a 100644 --- a/library/all/src/main/AndroidManifest.xml +++ b/library/all/src/main/AndroidManifest.xml @@ -13,5 +13,4 @@ See the License for the specific language governing permissions and limitations under the License. --> -