From 0ddd3c2bd019325ff51db88399d33d081d2749b2 Mon Sep 17 00:00:00 2001 From: aquilescanta Date: Mon, 24 Jun 2019 14:31:54 +0100 Subject: [PATCH] Implement DecryptableSampleQueueReader.isReady PiperOrigin-RevId: 254746146 --- cast_receiver_app/BUILD | 310 ++++ cast_receiver_app/README.md | 72 + cast_receiver_app/WORKSPACE | 38 + cast_receiver_app/app-desktop/html/index.css | 156 ++ cast_receiver_app/app-desktop/html/index.html | 55 + cast_receiver_app/app-desktop/src/main.js | 170 ++ .../app-desktop/src/player_controls.js | 164 ++ cast_receiver_app/app-desktop/src/samples.js | 70 + .../app-desktop/src/samples_internal.js | 79 + cast_receiver_app/app/html/index.css | 39 + cast_receiver_app/app/html/index.html | 40 + .../app/html/playback_info_view.css | 59 + cast_receiver_app/app/src/main.js | 55 + .../app/src/message_dispatcher.js | 234 +++ cast_receiver_app/app/src/receiver.js | 191 +++ cast_receiver_app/app/src/validation.js | 163 ++ cast_receiver_app/assemble.bazel.sh | 93 + cast_receiver_app/externs/protocol.js | 489 ++++++ cast_receiver_app/externs/shaka.js | 68 + .../src/configuration_factory.js | 90 + cast_receiver_app/src/constants.js | 140 ++ cast_receiver_app/src/playback_info_view.js | 233 +++ cast_receiver_app/src/player.js | 1522 +++++++++++++++++ cast_receiver_app/src/timeout.js | 68 + cast_receiver_app/src/util.js | 62 + cast_receiver_app/test/caf_bootstrap.js | 33 + .../test/configuration_factory_test.js | 86 + cast_receiver_app/test/externs.js | 36 + .../test/message_dispatcher_test.js | 49 + cast_receiver_app/test/mocks.js | 277 +++ .../test/playback_info_view_test.js | 242 +++ cast_receiver_app/test/player_test.js | 470 +++++ cast_receiver_app/test/queue_test.js | 166 ++ cast_receiver_app/test/receiver_test.js | 1027 +++++++++++ .../test/shaka_error_handling_test.js | 84 + cast_receiver_app/test/util.js | 87 + cast_receiver_app/test/validation_test.js | 266 +++ .../castdemo/ExoCastPlayerManager.java | 421 +++++ .../ext/cast/CastSessionManager.java | 86 + .../ext/cast/DefaultCastSessionManager.java | 187 ++ .../exoplayer2/ext/cast/ExoCastConstants.java | 118 ++ .../exoplayer2/ext/cast/ExoCastMessage.java | 474 +++++ .../ext/cast/ExoCastOptionsProvider.java | 40 + .../exoplayer2/ext/cast/ExoCastPlayer.java | 958 +++++++++++ .../exoplayer2/ext/cast/ExoCastTimeline.java | 342 ++++ .../exoplayer2/ext/cast/MediaItemInfo.java | 160 ++ .../ext/cast/ReceiverAppStateUpdate.java | 633 +++++++ .../ext/cast/ExoCastMessageTest.java | 436 +++++ .../ext/cast/ExoCastPlayerTest.java | 1018 +++++++++++ .../ext/cast/ExoCastTimelineTest.java | 466 +++++ .../ext/cast/ReceiverAppStateUpdateTest.java | 378 ++++ .../source/DecryptableSampleQueueReader.java | 18 + .../source/SampleMetadataQueue.java | 22 + .../exoplayer2/source/SampleQueue.java | 35 + 54 files changed, 13275 insertions(+) create mode 100644 cast_receiver_app/BUILD create mode 100644 cast_receiver_app/README.md create mode 100644 cast_receiver_app/WORKSPACE create mode 100644 cast_receiver_app/app-desktop/html/index.css create mode 100644 cast_receiver_app/app-desktop/html/index.html create mode 100644 cast_receiver_app/app-desktop/src/main.js create mode 100644 cast_receiver_app/app-desktop/src/player_controls.js create mode 100644 cast_receiver_app/app-desktop/src/samples.js create mode 100644 cast_receiver_app/app-desktop/src/samples_internal.js create mode 100644 cast_receiver_app/app/html/index.css create mode 100644 cast_receiver_app/app/html/index.html create mode 100644 cast_receiver_app/app/html/playback_info_view.css create mode 100644 cast_receiver_app/app/src/main.js create mode 100644 cast_receiver_app/app/src/message_dispatcher.js create mode 100644 cast_receiver_app/app/src/receiver.js create mode 100644 cast_receiver_app/app/src/validation.js create mode 100755 cast_receiver_app/assemble.bazel.sh create mode 100644 cast_receiver_app/externs/protocol.js create mode 100644 cast_receiver_app/externs/shaka.js create mode 100644 cast_receiver_app/src/configuration_factory.js create mode 100644 cast_receiver_app/src/constants.js create mode 100644 cast_receiver_app/src/playback_info_view.js create mode 100644 cast_receiver_app/src/player.js create mode 100644 cast_receiver_app/src/timeout.js create mode 100644 cast_receiver_app/src/util.js create mode 100644 cast_receiver_app/test/caf_bootstrap.js create mode 100644 cast_receiver_app/test/configuration_factory_test.js create mode 100644 cast_receiver_app/test/externs.js create mode 100644 cast_receiver_app/test/message_dispatcher_test.js create mode 100644 cast_receiver_app/test/mocks.js create mode 100644 cast_receiver_app/test/playback_info_view_test.js create mode 100644 cast_receiver_app/test/player_test.js create mode 100644 cast_receiver_app/test/queue_test.js create mode 100644 cast_receiver_app/test/receiver_test.js create mode 100644 cast_receiver_app/test/shaka_error_handling_test.js create mode 100644 cast_receiver_app/test/util.js create mode 100644 cast_receiver_app/test/validation_test.js create mode 100644 demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/ExoCastPlayerManager.java create mode 100644 extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastSessionManager.java create mode 100644 extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/DefaultCastSessionManager.java create mode 100644 extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/ExoCastConstants.java create mode 100644 extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/ExoCastMessage.java create mode 100644 extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/ExoCastOptionsProvider.java create mode 100644 extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/ExoCastPlayer.java create mode 100644 extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/ExoCastTimeline.java create mode 100644 extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/MediaItemInfo.java create mode 100644 extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/ReceiverAppStateUpdate.java create mode 100644 extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/ExoCastMessageTest.java create mode 100644 extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/ExoCastPlayerTest.java create mode 100644 extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/ExoCastTimelineTest.java create mode 100644 extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/ReceiverAppStateUpdateTest.java diff --git a/cast_receiver_app/BUILD b/cast_receiver_app/BUILD new file mode 100644 index 0000000000..2bd0526cdd --- /dev/null +++ b/cast_receiver_app/BUILD @@ -0,0 +1,310 @@ +# Copyright (C) 2019 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. + +load("@io_bazel_rules_closure//closure:defs.bzl", "closure_js_library") +load("@io_bazel_rules_closure//closure:defs.bzl", "closure_js_binary") +load("@io_bazel_rules_closure//closure:defs.bzl", "closure_js_test") +load("@io_bazel_rules_closure//closure:defs.bzl", "closure_css_library") +load("@io_bazel_rules_closure//closure:defs.bzl", "closure_css_binary") + +licenses(["notice"]) # Apache 2.0 + +# The Shaka player library - 2.5.0-beta2 (needs to be cloned from Github). +closure_js_library( + name = "shaka_player_library", + srcs = glob( + [ + "external-js/shaka-player/lib/**/*.js", + "external-js/shaka-player/externs/**/*.js", + ], + exclude = [ + "external-js/shaka-player/lib/debug/asserts.js", + "external-js/shaka-player/externs/mediakeys.js", + "external-js/shaka-player/externs/networkinformation.js", + "external-js/shaka-player/externs/vtt_region.js", + ], + ), + suppress = [ + "strictMissingRequire", + "missingSourcesWarnings", + "analyzerChecks", + "strictCheckTypes", + "checkTypes", + ], + deps = [ + "@io_bazel_rules_closure//closure/library", + ], +) + +# The plain player not depending on the cast library. +closure_js_library( + name = "player_lib", + srcs = [ + "externs/protocol.js", + "src/configuration_factory.js", + "src/constants.js", + "src/playback_info_view.js", + "src/player.js", + "src/timeout.js", + "src/util.js", + ], + suppress = [ + "missingSourcesWarnings", + "analyzerChecks", + "strictCheckTypes", + ], + deps = [ + ":shaka_player_library", + "@io_bazel_rules_closure//closure/library", + ], +) + +# A debug app to test the player with a desktop browser. +closure_js_library( + name = "app_desktop_lib", + srcs = [ + "app-desktop/src/main.js", + "app-desktop/src/player_controls.js", + "app-desktop/src/samples.js", + "externs/shaka.js", + ], + suppress = [ + "reportUnknownTypes", + "strictCheckTypes", + ], + deps = [ + ":player_lib", + ":shaka_player_library", + "@io_bazel_rules_closure//closure/library", + ], +) + +# Includes the javascript files of the cast receiver app. +closure_js_library( + name = "app_lib", + srcs = [ + "app/src/main.js", + "app/src/message_dispatcher.js", + "app/src/receiver.js", + "app/src/validation.js", + "externs/cast.js", + "externs/shaka.js", + ], + suppress = [ + "missingSourcesWarnings", + "analyzerChecks", + "strictCheckTypes", + ], + deps = [ + ":player_lib", + ":shaka_player_library", + "@io_bazel_rules_closure//closure/library", + ], +) + +# Test utils like mocks. +closure_js_library( + name = "test_util_lib", + testonly = 1, + srcs = [ + "externs/protocol.js", + "test/externs.js", + "test/mocks.js", + "test/util.js", + ], + suppress = [ + "checkTypes", + "strictCheckTypes", + "reportUnknownTypes", + "accessControls", + "analyzerChecks", + "missingSourcesWarnings", + ], + deps = [ + ":shaka_player_library", + "@io_bazel_rules_closure//closure/library", + "@io_bazel_rules_closure//closure/library/testing:jsunit", + ], +) + +# Unit test for the player. +closure_js_test( + name = "player_tests", + srcs = glob([ + "test/player_test.js", + ]), + entry_points = [ + "exoplayer.cast.test", + ], + suppress = [ + "checkTypes", + "strictCheckTypes", + "reportUnknownTypes", + "accessControls", + "analyzerChecks", + "missingSourcesWarnings", + ], + deps = [ + ":app_lib", + ":player_lib", + ":test_util_lib", + "@io_bazel_rules_closure//closure/library/testing:asserts", + "@io_bazel_rules_closure//closure/library/testing:jsunit", + "@io_bazel_rules_closure//closure/library/testing:testsuite", + ], +) + +# Unit test for the queue in the player. +closure_js_test( + name = "queue_tests", + srcs = glob([ + "test/queue_test.js", + ]), + entry_points = [ + "exoplayer.cast.test.queue", + ], + suppress = [ + "checkTypes", + "strictCheckTypes", + "reportUnknownTypes", + "accessControls", + "analyzerChecks", + "missingSourcesWarnings", + ], + deps = [ + ":app_lib", + ":player_lib", + ":test_util_lib", + "@io_bazel_rules_closure//closure/library/testing:asserts", + "@io_bazel_rules_closure//closure/library/testing:jsunit", + "@io_bazel_rules_closure//closure/library/testing:testsuite", + ], +) + +# Unit test for the receiver. +closure_js_test( + name = "receiver_tests", + srcs = glob([ + "test/receiver_test.js", + ]), + entry_points = [ + "exoplayer.cast.test.receiver", + ], + suppress = [ + "checkTypes", + "strictCheckTypes", + "reportUnknownTypes", + "accessControls", + "analyzerChecks", + "missingSourcesWarnings", + ], + deps = [ + ":app_lib", + ":player_lib", + ":test_util_lib", + "@io_bazel_rules_closure//closure/library/testing:asserts", + "@io_bazel_rules_closure//closure/library/testing:jsunit", + "@io_bazel_rules_closure//closure/library/testing:testsuite", + ], +) + +# Unit test for the validations. +closure_js_test( + name = "validation_tests", + srcs = [ + "test/validation_test.js", + ], + entry_points = [ + "exoplayer.cast.test.validation", + ], + suppress = [ + "checkTypes", + "strictCheckTypes", + "reportUnknownTypes", + "accessControls", + "analyzerChecks", + "missingSourcesWarnings", + ], + deps = [ + ":app_lib", + ":player_lib", + ":test_util_lib", + "@io_bazel_rules_closure//closure/library/testing:asserts", + "@io_bazel_rules_closure//closure/library/testing:jsunit", + "@io_bazel_rules_closure//closure/library/testing:testsuite", + ], +) + +# The receiver app as a compiled binary. +closure_js_binary( + name = "app", + entry_points = [ + "exoplayer.cast.app", + "shaka.dash.DashParser", + "shaka.hls.HlsParser", + "shaka.abr.SimpleAbrManager", + "shaka.net.HttpFetchPlugin", + "shaka.net.HttpXHRPlugin", + "shaka.media.AdaptationSetCriteria", + ], + deps = [":app_lib"], +) + +# The debug app for the player as a compiled binary. +closure_js_binary( + name = "app_desktop", + entry_points = [ + "exoplayer.cast.debug", + "exoplayer.cast.samples", + "shaka.dash.DashParser", + "shaka.hls.HlsParser", + "shaka.abr.SimpleAbrManager", + "shaka.net.HttpFetchPlugin", + "shaka.net.HttpXHRPlugin", + "shaka.media.AdaptationSetCriteria", + ], + deps = [":app_desktop_lib"], +) + +# Defines the css style of the receiver app. +closure_css_library( + name = "app_styles_lib", + srcs = [ + "app/html/index.css", + "app/html/playback_info_view.css", + ], +) + +# Defines the css styles of the debug app. +closure_css_library( + name = "app_desktop_styles_lib", + srcs = [ + "app-desktop/html/index.css", + "app/html/playback_info_view.css", + ], +) + +# Compiles the css styles of the receiver app. +closure_css_binary( + name = "app_styles", + renaming = False, + deps = ["app_styles_lib"], +) + +# Compiles the css styles of the debug app. +closure_css_binary( + name = "app_desktop_styles", + renaming = False, + deps = ["app_desktop_styles_lib"], +) diff --git a/cast_receiver_app/README.md b/cast_receiver_app/README.md new file mode 100644 index 0000000000..6504cb4f94 --- /dev/null +++ b/cast_receiver_app/README.md @@ -0,0 +1,72 @@ +# ExoPlayer cast receiver # + +An HTML/JavaScript app which runs within a Google cast device and can be loaded +and controller by an Android app which uses the ExoPlayer cast extension +(https://github.com/google/ExoPlayer/tree/release-v2/extensions/cast). + +# Build the app # + +You can build and deploy the app to your web server and register the url as your +cast receiver app (see: https://developers.google.com/cast/docs/registration). + +Building the app compiles JavaScript and CSS files. Dead JavaScript code of the +app itself and their dependencies (like ShakaPlayer) is removed and the +remaining code is minimized. + +## Prerequisites ## + +1. Install the most recent bazel release (https://bazel.build/) which is at + least 0.22.0. + +From within the root of the exo_receiver_app project do the following steps: + +2. Clone shaka from GitHub into the directory external-js/shaka-player: +``` +# git clone https://github.com/google/shaka-player.git \ + external-js/shaka-player +``` + +## 1. Customize html page and css (optional) ## + +(Optional) Edit index.html. **Make sure you do not change the id of the video +element**. +(Optional) Customize main.css. + +## 2. Build javascript and css files ## +``` +# bazel build ... +``` +## 3. Assemble the receiver app ## +``` +# WEB_DEPLOY_DIR=www +# mkdir ${WEB_DEPLOY_DIR} +# cp bazel-bin/exo_receiver_app.js ${WEB_DEPLOY_DIR} +# cp bazel-bin/exo_receiver_styles_bin.css ${WEB_DEPLOY_DIR} +# cp html/index.html ${WEB_DEPLOY_DIR} +``` + +Deploy the content of ${WEB_DEPLOY_DIR} to your web server. + +## 4. Assemble the debug app (optional) ## + +Debugging the player in a cast device is a little bit cumbersome compared to +debugging in a desktop browser. For this reason there is a debug app which +contains the player parts which are not depending on the cast library in a +traditional HTML app which can be run in a desktop browser. + +``` +# WEB_DEPLOY_DIR=www +# mkdir ${WEB_DEPLOY_DIR} +# cp bazel-bin/debug_app.js ${WEB_DEPLOY_DIR} +# cp bazel-bin/debug_styles_bin.css ${WEB_DEPLOY_DIR} +# cp html/player.html ${WEB_DEPLOY_DIR} +``` + +Deploy the content of ${WEB_DEPLOY_DIR} to your web server. + +# Unit test + +Unit tests can be run by the command +``` +# bazel test ... +``` diff --git a/cast_receiver_app/WORKSPACE b/cast_receiver_app/WORKSPACE new file mode 100644 index 0000000000..e6be3b9026 --- /dev/null +++ b/cast_receiver_app/WORKSPACE @@ -0,0 +1,38 @@ +# Copyright (C) 2019 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. + +load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") + +http_archive( + name = "com_google_protobuf", + sha256 = "73fdad358857e120fd0fa19e071a96e15c0f23bb25f85d3f7009abfd4f264a2a", + strip_prefix = "protobuf-3.6.1.3", + urls = ["https://github.com/google/protobuf/archive/v3.6.1.3.tar.gz"], +) + +http_archive( + name = "io_bazel_rules_closure", + sha256 = "b29a8bc2cb10513c864cb1084d6f38613ef14a143797cea0af0f91cd385f5e8c", + strip_prefix = "rules_closure-0.8.0", + urls = [ + "https://mirror.bazel.build/github.com/bazelbuild/rules_closure/archive/0.8.0.tar.gz", + "https://github.com/bazelbuild/rules_closure/archive/0.8.0.tar.gz", + ], +) +load("@io_bazel_rules_closure//closure:defs.bzl", "closure_repositories") + +closure_repositories( + omit_com_google_protobuf = True, +) + diff --git a/cast_receiver_app/app-desktop/html/index.css b/cast_receiver_app/app-desktop/html/index.css new file mode 100644 index 0000000000..ff77e1cbfa --- /dev/null +++ b/cast_receiver_app/app-desktop/html/index.css @@ -0,0 +1,156 @@ +/* + * Copyright (C) 2018 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. + */ +html, body, section, video, div, span, ul, li { + border: 0; + box-sizing: border-box; + margin: 0; + padding: 0; +} +body, html { + height: 100%; + overflow: auto; + background-color: #333; + color: #eeeeee; + font-family: Roboto, Arial, sans-serif; +} +body { + padding-top: 24px; +} +.exo_controls { + list-style: none; + padding: 0; + white-space: nowrap; + margin-top: 12px; +} +.exo_controls > li { + display: inline-block; + width: 72px; +} +.exo_controls > .large { + width: 140px; +} +/* an action element to add or remove a media item */ +.action { + margin: 4px auto; + max-width: 640px; +} +.action.prepared { + background-color: #AA0000; +} +/** marks whether a given media item is in the queue */ +.queue-marker { + background-color: #AA0000; + border-radius: 50%; + border: 1px solid #ffc0c0; + display: none; + float: right; + height: 1em; + margin-top: 1px; + width: 1em; +} +.action[data-uuid] .queue-marker { + display: inline-block; +} +.action.prepared .queue-marker { + background-color: #fff900; +} +.playing .action.prepared .queue-marker { + animation-name: spin; + animation-iteration-count: infinite; + animation-duration: 1.6s; +} +/* A simple button. */ +.button { + background-color: #45484d; + border: 1px solid #495267; + border-radius: 3px; + color: #FFFFFF; + cursor: pointer; + font-size: 12px; + font-weight: bold; + padding: 10px 10px 10px 10px; + text-decoration: none; + text-shadow: -1px -1px 0 rgba(0,0,0,0.3); + -webkit-user-select: none; +} +.button:hover { + border: 1px solid #363d4c; + background-color: #2d2f32; + background-image: linear-gradient(to bottom, #2d2f32, #1a1a1a); +} +.ribbon { + background-color: #003a5dc2; + box-shadow: 2px 2px 4px #000; + left: -60px; + height: 3.3em; + padding-top: 7px; + position: absolute; + text-align: center; + top: 27px; + transform: rotateZ(-45deg); + width: 220px; + border: 1px dashed #cacaca; + outline-color: #003a5dc2; + outline-width: 2px; + outline-style: solid; +} +.ribbon a { + color: white; + text-decoration: none; + -webkit-user-select: none; +} +#button_prepare { + left: 0; + position: absolute; +} +#button_stop { + position: absolute; + right: 0; +} +#exo_demo_view { + height: 360px; + margin: auto; + overflow: hidden; + position: relative; + width: 640px; +} +#video { + background-color: #000; + border-radius: 8px; + height: 100%; + margin-bottom: auto; + margin-top: auto; + width: 100%; +} +#exo_controls { + display: none; + margin: auto; + position: relative; + text-align: center; + width: 640px; +} +#media-actions { + margin-top: 12px; +} + +@keyframes spin { + from { + transform: rotateX(0deg); + } + to { + transform: rotateX(180deg); + } +} diff --git a/cast_receiver_app/app-desktop/html/index.html b/cast_receiver_app/app-desktop/html/index.html new file mode 100644 index 0000000000..19a118913b --- /dev/null +++ b/cast_receiver_app/app-desktop/html/index.html @@ -0,0 +1,55 @@ + + + + + + + + +
+ +
+
+
+
+
+ + +
+
+
+ for debugging
purpose only +
+
+
+ +
+
+
+ + + diff --git a/cast_receiver_app/app-desktop/src/main.js b/cast_receiver_app/app-desktop/src/main.js new file mode 100644 index 0000000000..5645d70787 --- /dev/null +++ b/cast_receiver_app/app-desktop/src/main.js @@ -0,0 +1,170 @@ +/* + * Copyright (C) 2018 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. + */ + +goog.module('exoplayer.cast.debug'); + +const ConfigurationFactory = goog.require('exoplayer.cast.ConfigurationFactory'); +const PlaybackInfoView = goog.require('exoplayer.cast.PlaybackInfoView'); +const Player = goog.require('exoplayer.cast.Player'); +const PlayerControls = goog.require('exoplayer.cast.PlayerControls'); +const ShakaPlayer = goog.require('shaka.Player'); +const SimpleTextDisplayer = goog.require('shaka.text.SimpleTextDisplayer'); +const installAll = goog.require('shaka.polyfill.installAll'); +const util = goog.require('exoplayer.cast.util'); + +/** @type {!Array} */ +let queue = []; +/** @type {number} */ +let uuidCounter = 1; + +// install all polyfills for the Shaka player +installAll(); + +/** + * Listens for player state changes and logs the state to the console. + * + * @param {!PlayerState} playerState The player state. + */ +const playerListener = function(playerState) { + util.log(['playerState: ', playerState.playbackPosition, playerState]); + queue = playerState.mediaQueue; + highlightCurrentItem( + playerState.playbackPosition && playerState.playbackPosition.uuid ? + playerState.playbackPosition.uuid : + ''); + if (playerState.playWhenReady && playerState.playbackState === 'READY') { + document.body.classList.add('playing'); + } else { + document.body.classList.remove('playing'); + } + if (playerState.playbackState === 'IDLE' && queue.length === 0) { + // Stop has been called or player not yet prepared. + resetSampleList(); + } +}; + +/** + * Highlights the currently playing item in the samples list. + * + * @param {string} uuid + */ +const highlightCurrentItem = function(uuid) { + const actions = /** @type {!NodeList} */ ( + document.querySelectorAll('#media-actions .action')); + for (let action of actions) { + if (action.dataset['uuid'] === uuid) { + action.classList.add('prepared'); + } else { + action.classList.remove('prepared'); + } + } +}; + +/** + * Makes sure all items reflect being removed from the timeline. + */ +const resetSampleList = function() { + const actions = /** @type {!NodeList} */ ( + document.querySelectorAll('#media-actions .action')); + for (let action of actions) { + action.classList.remove('prepared'); + delete action.dataset['uuid']; + } +}; + +/** + * If the arguments provide a valid media item it is added to the player. + * + * @param {!MediaItem} item The media item. + * @return {string} The uuid which has been created for the item before adding. + */ +const addQueueItem = function(item) { + if (!(item.media && item.media.uri && item.mimeType)) { + throw Error('insufficient arguments to add a queue item'); + } + item.uuid = 'uuid-' + uuidCounter++; + player.addQueueItems(queue.length, [item], /* playbackOrder= */ undefined); + return item.uuid; +}; + +/** + * An event listener which listens for actions. + * + * @param {!Event} ev The DOM event. + */ +const handleAction = (ev) => { + let target = ev.target; + while (target !== document.body && !target.dataset['action']) { + target = target.parentNode; + } + if (!target || !target.dataset['action']) { + return; + } + switch (target.dataset['action']) { + case 'player.addItems': + if (target.dataset['uuid']) { + player.removeQueueItems([target.dataset['uuid']]); + delete target.dataset['uuid']; + } else { + const uuid = addQueueItem(/** @type {!MediaItem} */ + (JSON.parse(target.dataset['item']))); + target.dataset['uuid'] = uuid; + } + break; + } +}; + +/** + * Appends samples to the list of media item actions. + * + * @param {!Array} mediaItems The samples to add. + */ +const appendSamples = function(mediaItems) { + const samplesList = document.getElementById('media-actions'); + mediaItems.forEach((item) => { + const div = /** @type {!HTMLElement} */ (document.createElement('div')); + div.classList.add('action', 'button'); + div.dataset['action'] = 'player.addItems'; + div.dataset['item'] = JSON.stringify(item); + div.appendChild(document.createTextNode(item.title)); + const marker = document.createElement('span'); + marker.classList.add('queue-marker'); + div.appendChild(marker); + samplesList.appendChild(div); + }); +}; + +/** @type {!HTMLMediaElement} */ +const mediaElement = + /** @type {!HTMLMediaElement} */ (document.getElementById('video')); +// Workaround for https://github.com/google/shaka-player/issues/1819 +// TODO(bachinger) Remove line when better fix available. +new SimpleTextDisplayer(mediaElement); +/** @type {!ShakaPlayer} */ +const shakaPlayer = new ShakaPlayer(mediaElement); +/** @type {!Player} */ +const player = new Player(shakaPlayer, new ConfigurationFactory()); +new PlayerControls(player, 'exo_controls'); +new PlaybackInfoView(player, 'exo_playback_info'); + +// register listeners +document.body.addEventListener('click', handleAction); +player.addPlayerListener(playerListener); + +// expose the player for debugging purposes. +window['player'] = player; + +exports.appendSamples = appendSamples; diff --git a/cast_receiver_app/app-desktop/src/player_controls.js b/cast_receiver_app/app-desktop/src/player_controls.js new file mode 100644 index 0000000000..e29f74148c --- /dev/null +++ b/cast_receiver_app/app-desktop/src/player_controls.js @@ -0,0 +1,164 @@ +/* + * Copyright (C) 2018 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. + */ +goog.module('exoplayer.cast.PlayerControls'); + +const Player = goog.require('exoplayer.cast.Player'); + +/** + * A simple UI to control the player. + * + */ +class PlayerControls { + /** + * @param {!Player} player The player. + * @param {string} containerId The id of the container element. + */ + constructor(player, containerId) { + /** @const @private {!Player} */ + this.player_ = player; + /** @const @private {?Element} */ + this.root_ = document.getElementById(containerId); + /** @const @private {?Element} */ + this.playButton_ = this.root_.querySelector('#button_play'); + /** @const @private {?Element} */ + this.pauseButton_ = this.root_.querySelector('#button_pause'); + /** @const @private {?Element} */ + this.previousButton_ = this.root_.querySelector('#button_previous'); + /** @const @private {?Element} */ + this.nextButton_ = this.root_.querySelector('#button_next'); + + const previous = () => { + const index = player.getPreviousWindowIndex(); + if (index !== -1) { + player.seekToWindow(index, 0); + } + }; + const next = () => { + const index = player.getNextWindowIndex(); + if (index !== -1) { + player.seekToWindow(index, 0); + } + }; + const rewind = () => { + player.seekToWindow( + player.getCurrentWindowIndex(), + player.getCurrentPositionMs() - 15000); + }; + const fastForward = () => { + player.seekToWindow( + player.getCurrentWindowIndex(), + player.getCurrentPositionMs() + 30000); + }; + const actions = { + 'pwr_1': (ev) => player.setPlayWhenReady(true), + 'pwr_0': (ev) => player.setPlayWhenReady(false), + 'rewind': rewind, + 'fastforward': fastForward, + 'previous': previous, + 'next': next, + 'prepare': (ev) => player.prepare(), + 'stop': (ev) => player.stop(true), + 'remove_queue_item': (ev) => { + player.removeQueueItems([ev.target.dataset.id]); + }, + }; + /** + * @param {!Event} ev The key event. + * @return {boolean} true if the key event has been handled. + */ + const keyListener = (ev) => { + const key = /** @type {!KeyboardEvent} */ (ev).key; + switch (key) { + case 'ArrowUp': + case 'k': + previous(); + ev.preventDefault(); + return true; + case 'ArrowDown': + case 'j': + next(); + ev.preventDefault(); + return true; + case 'ArrowLeft': + case 'h': + rewind(); + ev.preventDefault(); + return true; + case 'ArrowRight': + case 'l': + fastForward(); + ev.preventDefault(); + return true; + case ' ': + case 'p': + player.setPlayWhenReady(!player.getPlayWhenReady()); + ev.preventDefault(); + return true; + } + return false; + }; + document.addEventListener('keydown', keyListener); + this.root_.addEventListener('click', function(ev) { + const method = ev.target['dataset']['method']; + if (actions[method]) { + actions[method](ev); + } + return true; + }); + player.addPlayerListener((playerState) => this.updateUi(playerState)); + player.invalidate(); + this.setVisible_(true); + } + + /** + * Syncs the ui with the player state. + * + * @param {!PlayerState} playerState The state of the player to be reflected + * by the UI. + */ + updateUi(playerState) { + if (playerState.playWhenReady) { + this.playButton_.style.display = 'none'; + this.pauseButton_.style.display = 'inline-block'; + } else { + this.playButton_.style.display = 'inline-block'; + this.pauseButton_.style.display = 'none'; + } + if (this.player_.getNextWindowIndex() === -1) { + this.nextButton_.style.visibility = 'hidden'; + } else { + this.nextButton_.style.visibility = 'visible'; + } + if (this.player_.getPreviousWindowIndex() === -1) { + this.previousButton_.style.visibility = 'hidden'; + } else { + this.previousButton_.style.visibility = 'visible'; + } + } + + /** + * @private + * @param {boolean} visible If `true` thie controls are shown. If `false` the + * controls are hidden. + */ + setVisible_(visible) { + if (this.root_) { + this.root_.style.display = visible ? 'block' : 'none'; + } + } +} + +exports = PlayerControls; diff --git a/cast_receiver_app/app-desktop/src/samples.js b/cast_receiver_app/app-desktop/src/samples.js new file mode 100644 index 0000000000..2d190bdef4 --- /dev/null +++ b/cast_receiver_app/app-desktop/src/samples.js @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2019 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. + */ +goog.module('exoplayer.cast.samples'); + +const {appendSamples} = goog.require('exoplayer.cast.debug'); + +appendSamples([ + { + title: 'DASH: multi-period', + mimeType: 'application/dash+xml', + media: { + uri: 'https://storage.googleapis.com/exoplayer-test-media-internal-6383' + + '4241aced7884c2544af1a3452e01/dash/multi-period/two-periods-minimal' + + '-duration.mpd', + }, + }, + { + title: 'HLS: Angel one', + mimeType: 'application/vnd.apple.mpegurl', + media: { + uri: 'https://storage.googleapis.com/shaka-demo-assets/angel-one-hls/hl' + + 's.m3u8', + }, + }, + { + title: 'MP4: Elephants dream', + mimeType: 'video/*', + media: { + uri: 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/' + + 'ElephantsDream.mp4', + }, + }, + { + title: 'MKV: Android screens', + mimeType: 'video/*', + media: { + uri: 'https://storage.googleapis.com/exoplayer-test-media-1/mkv/android' + + '-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv', + }, + }, + { + title: 'WV: HDCP not specified', + mimeType: 'application/dash+xml', + media: { + uri: 'https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd', + }, + drmSchemes: [ + { + licenseServer: { + uri: 'https://proxy.uat.widevine.com/proxy?video_id=d286538032258a1' + + 'c&provider=widevine_test', + }, + uuid: 'edef8ba9-79d6-4ace-a3c8-27dcd51d21ed', + }, + ], + }, +]); diff --git a/cast_receiver_app/app-desktop/src/samples_internal.js b/cast_receiver_app/app-desktop/src/samples_internal.js new file mode 100644 index 0000000000..71b05eb2c1 --- /dev/null +++ b/cast_receiver_app/app-desktop/src/samples_internal.js @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2019 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. + */ +goog.module('exoplayer.cast.samplesinternal'); + +const {appendSamples} = goog.require('exoplayer.cast.debug'); + +appendSamples([ + { + title: 'DAS: VOD', + mimeType: 'application/dash+xml', + media: { + uri: 'https://demo-dash-pvr.zahs.tv/hd/manifest.mpd', + }, + }, + { + title: 'MP3', + mimeType: 'audio/*', + media: { + uri: 'http://www.noiseaddicts.com/samples_1w72b820/4190.mp3', + }, + }, + { + title: 'DASH: live', + mimeType: 'application/dash+xml', + media: { + uri: 'https://demo-dash-live.zahs.tv/sd/manifest.mpd', + }, + }, + { + title: 'HLS: live', + mimeType: 'application/vnd.apple.mpegurl', + media: { + uri: 'https://demo-hls5-live.zahs.tv/sd/master.m3u8', + }, + }, + { + title: 'Live DASH (HD/Widevine)', + mimeType: 'application/dash+xml', + media: { + uri: 'https://demo-dashenc-live.zahs.tv/hd/widevine.mpd', + }, + drmSchemes: [ + { + licenseServer: { + uri: 'https://demo-dashenc-live.zahs.tv/hd/widevine-license', + }, + uuid: 'edef8ba9-79d6-4ace-a3c8-27dcd51d21ed', + }, + ], + }, + { + title: 'VOD DASH (HD/Widevine)', + mimeType: 'application/dash+xml', + media: { + uri: 'https://demo-dashenc-pvr.zahs.tv/hd/widevine.mpd', + }, + drmSchemes: [ + { + licenseServer: { + uri: 'https://demo-dashenc-live.zahs.tv/hd/widevine-license', + }, + uuid: 'edef8ba9-79d6-4ace-a3c8-27dcd51d21ed', + }, + ], + }, +]); diff --git a/cast_receiver_app/app/html/index.css b/cast_receiver_app/app/html/index.css new file mode 100644 index 0000000000..dfc9b4e0e5 --- /dev/null +++ b/cast_receiver_app/app/html/index.css @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2018 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. + */ +section, video, div, span, body, html { + border: 0; + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + background-color: #000; + height: 100%; + overflow: hidden; +} + +#exo_player_view { + background-color: #000; + height: 100%; + position: relative; +} + +#exo_video { + height: 100%; + width: 100%; +} + diff --git a/cast_receiver_app/app/html/index.html b/cast_receiver_app/app/html/index.html new file mode 100644 index 0000000000..64de3e8a8e --- /dev/null +++ b/cast_receiver_app/app/html/index.html @@ -0,0 +1,40 @@ + + + + + + + + + +
+ +
+
+
+
+
+ + +
+
+
+ + + diff --git a/cast_receiver_app/app/html/playback_info_view.css b/cast_receiver_app/app/html/playback_info_view.css new file mode 100644 index 0000000000..f70695d873 --- /dev/null +++ b/cast_receiver_app/app/html/playback_info_view.css @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2019 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. + */ + +.exo_text_label { + color: #fff; + font-family: Roboto, Arial, sans-serif; + font-size: 1em; + margin-top: 4px; +} + +#exo_playback_info { + bottom: 5%; + display: none; + left: 4%; + position: absolute; + right: 4%; + width: 92%; +} + +#exo_time_bar { + width: 100%; +} + +#exo_duration { + background-color: rgba(255, 255, 255, 0.4); + height: 0.5em; + overflow: hidden; + position: relative; + width: 100%; +} + +#exo_elapsed_time { + background-color: rgb(73, 128, 218); + height: 100%; + opacity: 1; + width: 0; +} + +#exo_duration_label { + float: right; +} + +#exo_elapsed_time_label { + float: left; +} + diff --git a/cast_receiver_app/app/src/main.js b/cast_receiver_app/app/src/main.js new file mode 100644 index 0000000000..37c6fd41eb --- /dev/null +++ b/cast_receiver_app/app/src/main.js @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2018 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. + */ + +goog.module('exoplayer.cast.app'); + +const ConfigurationFactory = goog.require('exoplayer.cast.ConfigurationFactory'); +const MessageDispatcher = goog.require('exoplayer.cast.MessageDispatcher'); +const PlaybackInfoView = goog.require('exoplayer.cast.PlaybackInfoView'); +const Player = goog.require('exoplayer.cast.Player'); +const Receiver = goog.require('exoplayer.cast.Receiver'); +const ShakaPlayer = goog.require('shaka.Player'); +const SimpleTextDisplayer = goog.require('shaka.text.SimpleTextDisplayer'); +const installAll = goog.require('shaka.polyfill.installAll'); + +/** + * The ExoPlayer namespace for messages sent and received via cast message bus. + */ +const MESSAGE_NAMESPACE_EXOPLAYER = 'urn:x-cast:com.google.exoplayer.cast'; + +// installs all polyfills for the Shaka player +installAll(); +/** @type {?HTMLMediaElement} */ +const videoElement = + /** @type {?HTMLMediaElement} */ (document.getElementById('exo_video')); +if (videoElement !== null) { + // Workaround for https://github.com/google/shaka-player/issues/1819 + // TODO(bachinger) Remove line when better fix available. + new SimpleTextDisplayer(videoElement); + /** @type {!cast.framework.CastReceiverContext} */ + const castReceiverContext = cast.framework.CastReceiverContext.getInstance(); + const shakaPlayer = new ShakaPlayer(/** @type {!HTMLMediaElement} */ + (videoElement)); + const player = new Player(shakaPlayer, new ConfigurationFactory()); + new PlaybackInfoView(player, 'exo_playback_info'); + if (castReceiverContext !== null) { + const messageDispatcher = + new MessageDispatcher(MESSAGE_NAMESPACE_EXOPLAYER, castReceiverContext); + new Receiver(player, castReceiverContext, messageDispatcher); + } + // expose player for debugging purposes. + window['player'] = player; +} diff --git a/cast_receiver_app/app/src/message_dispatcher.js b/cast_receiver_app/app/src/message_dispatcher.js new file mode 100644 index 0000000000..151ac87fbe --- /dev/null +++ b/cast_receiver_app/app/src/message_dispatcher.js @@ -0,0 +1,234 @@ +/* + * Copyright (C) 2018 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. + */ +goog.module('exoplayer.cast.MessageDispatcher'); + +const validation = goog.require('exoplayer.cast.validation'); + +/** + * A callback function which is called by an action handler to indicate when + * processing has completed. + * + * @typedef {function(?PlayerState): undefined} + */ +const Callback = undefined; + +/** + * Handles an action sent by a sender app. + * + * @typedef {function(!Object, number, string, !Callback): undefined} + */ +const ActionHandler = undefined; + +/** + * Dispatches messages of a cast message bus to registered action handlers. + * + *

The dispatcher listens to events of a CastMessageBus for the namespace + * passed to the constructor. The data property of the event is + * parsed as a json document and delegated to a handler registered for the given + * method. + */ +class MessageDispatcher { + /** + * @param {string} namespace The message namespace. + * @param {!cast.framework.CastReceiverContext} castReceiverContext The cast + * receiver manager. + */ + constructor(namespace, castReceiverContext) { + /** @private @const {string} */ + this.namespace_ = namespace; + /** @private @const {!cast.framework.CastReceiverContext} */ + this.castReceiverContext_ = castReceiverContext; + /** @private @const {!Array} */ + this.messageQueue_ = []; + /** @private @const {!Object} */ + this.actions_ = {}; + /** @private @const {!Object} */ + this.senderSequences_ = {}; + /** @private @const {function(string, *)} */ + this.jsonStringifyReplacer_ = (key, value) => { + if (value === Infinity || value === null) { + return undefined; + } + return value; + }; + this.castReceiverContext_.addCustomMessageListener( + this.namespace_, this.onMessage.bind(this)); + } + + /** + * Registers a handler of a given action. + * + * @param {string} method The method name for which to register the handler. + * @param {!Array>} argDefs The name and type of each argument + * or an empty array if the method has no arguments. + * @param {!ActionHandler} handler A function to process the action. + */ + registerActionHandler(method, argDefs, handler) { + this.actions_[method] = { + method, + argDefs, + handler, + }; + } + + /** + * Unregisters the handler of the given action. + * + * @param {string} action The action to unregister. + */ + unregisterActionHandler(action) { + delete this.actions_[action]; + } + + /** + * Callback to receive messages sent by sender apps. + * + * @param {!cast.framework.system.Event} event The event received from the + * sender app. + */ + onMessage(event) { + console.log('message arrived from sender', this.namespace_, event); + const message = /** @type {!ExoCastMessage} */ (event.data); + const action = this.actions_[message.method]; + if (action) { + const args = message.args; + for (let i = 0; i < action.argDefs.length; i++) { + if (!validation.validateProperty( + args, action.argDefs[i][0], action.argDefs[i][1])) { + console.warn('invalid method call', message); + return; + } + } + this.messageQueue_.push({ + senderId: event.senderId, + message: message, + handler: action.handler + }); + if (this.messageQueue_.length === 1) { + this.executeNext(); + } else { + // Do nothing. An action is executing asynchronously and will call + // executeNext when finished. + } + } else { + console.warn('handler of method not found', message); + } + } + + /** + * Executes the next message in the queue. + */ + executeNext() { + if (this.messageQueue_.length === 0) { + return; + } + const head = this.messageQueue_[0]; + const message = head.message; + const senderSequence = message.sequenceNumber; + this.senderSequences_[head.senderId] = senderSequence; + try { + head.handler(message.args, senderSequence, head.senderId, (response) => { + if (response) { + this.send(head.senderId, response); + } + this.shiftPendingMessage_(head); + }); + } catch (e) { + this.shiftPendingMessage_(head); + console.error('error while executing method : ' + message.method, e); + } + } + + /** + * Broadcasts the sender state to all sender apps registered for the + * given message namespace. + * + * @param {!PlayerState} playerState The player state to be sent. + */ + broadcast(playerState) { + this.castReceiverContext_.getSenders().forEach((sender) => { + this.send(sender.id, playerState); + }); + delete playerState.sequenceNumber; + } + + /** + * Sends the PlayerState to the given sender. + * + * @param {string} senderId The id of the sender. + * @param {!PlayerState} playerState The message to send. + */ + send(senderId, playerState) { + playerState.sequenceNumber = this.senderSequences_[senderId] || -1; + this.castReceiverContext_.sendCustomMessage( + this.namespace_, senderId, + // TODO(bachinger) Find a better solution. + JSON.parse(JSON.stringify(playerState, this.jsonStringifyReplacer_))); + } + + /** + * Notifies the message dispatcher that a given sender has disconnected from + * the receiver. + * + * @param {string} senderId The id of the sender. + */ + notifySenderDisconnected(senderId) { + delete this.senderSequences_[senderId]; + } + + /** + * Shifts the pending message and executes the next if any. + * + * @private + * @param {!Message} pendingMessage The pending message. + */ + shiftPendingMessage_(pendingMessage) { + if (pendingMessage === this.messageQueue_[0]) { + this.messageQueue_.shift(); + this.executeNext(); + } + } +} + +/** + * An item in the message queue. + * + * @record + */ +function Message() {} + +/** + * The sender id. + * + * @type {string} + */ +Message.prototype.senderId; + +/** + * The ExoCastMessage sent by the sender app. + * + * @type {!ExoCastMessage} + */ +Message.prototype.message; + +/** + * The handler function handling the message. + * + * @type {!ActionHandler} + */ +Message.prototype.handler; + +exports = MessageDispatcher; diff --git a/cast_receiver_app/app/src/receiver.js b/cast_receiver_app/app/src/receiver.js new file mode 100644 index 0000000000..5e67219e75 --- /dev/null +++ b/cast_receiver_app/app/src/receiver.js @@ -0,0 +1,191 @@ +/** + * Copyright (C) 2018 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. + */ + +goog.module('exoplayer.cast.Receiver'); + +const MessageDispatcher = goog.require('exoplayer.cast.MessageDispatcher'); +const Player = goog.require('exoplayer.cast.Player'); +const validation = goog.require('exoplayer.cast.validation'); + +/** + * The Receiver receives messages from a message bus and delegates to + * the player. + * + * @constructor + * @param {!Player} player The player. + * @param {!cast.framework.CastReceiverContext} context The cast receiver + * context. + * @param {!MessageDispatcher} messageDispatcher The message dispatcher to use. + */ +const Receiver = function(player, context, messageDispatcher) { + addPlayerActions(messageDispatcher, player); + addQueueActions(messageDispatcher, player); + player.addPlayerListener((playerState) => { + messageDispatcher.broadcast(playerState); + }); + + context.addEventListener( + cast.framework.system.EventType.SENDER_CONNECTED, (event) => { + messageDispatcher.send(event.senderId, player.getPlayerState()); + }); + + context.addEventListener( + cast.framework.system.EventType.SENDER_DISCONNECTED, (event) => { + messageDispatcher.notifySenderDisconnected(event.senderId); + if (event.reason === + cast.framework.system.DisconnectReason.REQUESTED_BY_SENDER && + context.getSenders().length === 0) { + window.close(); + } + }); + + // Start the cast receiver context. + context.start(); +}; + +/** + * Registers action handlers for playback messages sent by the sender app. + * + * @param {!MessageDispatcher} messageDispatcher The dispatcher. + * @param {!Player} player The player. + */ +const addPlayerActions = function(messageDispatcher, player) { + messageDispatcher.registerActionHandler( + 'player.setPlayWhenReady', [['playWhenReady', 'boolean']], + (args, senderSequence, senderId, callback) => { + const playWhenReady = args['playWhenReady']; + callback( + !player.setPlayWhenReady(playWhenReady) ? + player.getPlayerState() : + null); + }); + messageDispatcher.registerActionHandler( + 'player.seekTo', + [ + ['uuid', 'string'], + ['positionMs', '?number'], + ], + (args, senderSequence, senderId, callback) => { + callback( + !player.seekToUuid(args['uuid'], args['positionMs']) ? + player.getPlayerState() : + null); + }); + messageDispatcher.registerActionHandler( + 'player.setRepeatMode', [['repeatMode', 'RepeatMode']], + (args, senderSequence, senderId, callback) => { + callback( + !player.setRepeatMode(args['repeatMode']) ? + player.getPlayerState() : + null); + }); + messageDispatcher.registerActionHandler( + 'player.setShuffleModeEnabled', [['shuffleModeEnabled', 'boolean']], + (args, senderSequence, senderId, callback) => { + callback( + !player.setShuffleModeEnabled(args['shuffleModeEnabled']) ? + player.getPlayerState() : + null); + }); + messageDispatcher.registerActionHandler( + 'player.onClientConnected', [], + (args, senderSequence, senderId, callback) => { + callback(player.getPlayerState()); + }); + messageDispatcher.registerActionHandler( + 'player.stop', [['reset', 'boolean']], + (args, senderSequence, senderId, callback) => { + player.stop(args['reset']).then(() => { + callback(null); + }); + }); + messageDispatcher.registerActionHandler( + 'player.prepare', [], (args, senderSequence, senderId, callback) => { + player.prepare(); + callback(null); + }); + messageDispatcher.registerActionHandler( + 'player.setTrackSelectionParameters', + [ + ['preferredAudioLanguage', 'string'], + ['preferredTextLanguage', 'string'], + ['disabledTextTrackSelectionFlags', 'Array'], + ['selectUndeterminedTextLanguage', 'boolean'], + ], + (args, senderSequence, senderId, callback) => { + const trackSelectionParameters = + /** @type {!TrackSelectionParameters} */ ({ + preferredAudioLanguage: args['preferredAudioLanguage'], + preferredTextLanguage: args['preferredTextLanguage'], + disabledTextTrackSelectionFlags: + args['disabledTextTrackSelectionFlags'], + selectUndeterminedTextLanguage: + args['selectUndeterminedTextLanguage'], + }); + callback( + !player.setTrackSelectionParameters(trackSelectionParameters) ? + player.getPlayerState() : + null); + }); +}; + +/** + * Registers action handlers for queue management messages sent by the sender + * app. + * + * @param {!MessageDispatcher} messageDispatcher The dispatcher. + * @param {!Player} player The player. + */ +const addQueueActions = + function (messageDispatcher, player) { + messageDispatcher.registerActionHandler( + 'player.addItems', + [ + ['index', '?number'], + ['items', 'Array'], + ['shuffleOrder', 'Array'], + ], + (args, senderSequence, senderId, callback) => { + const mediaItems = args['items']; + const index = args['index'] || player.getQueueSize(); + let addedItemCount; + if (validation.validateMediaItems(mediaItems)) { + addedItemCount = + player.addQueueItems(index, mediaItems, args['shuffleOrder']); + } + callback(addedItemCount === 0 ? player.getPlayerState() : null); + }); + messageDispatcher.registerActionHandler( + 'player.removeItems', [['uuids', 'Array']], + (args, senderSequence, senderId, callback) => { + const removedItemsCount = player.removeQueueItems(args['uuids']); + callback(removedItemsCount === 0 ? player.getPlayerState() : null); + }); + messageDispatcher.registerActionHandler( + 'player.moveItem', + [ + ['uuid', 'string'], + ['index', 'number'], + ['shuffleOrder', 'Array'], + ], + (args, senderSequence, senderId, callback) => { + const hasMoved = player.moveQueueItem( + args['uuid'], args['index'], args['shuffleOrder']); + callback(!hasMoved ? player.getPlayerState() : null); + }); +}; + +exports = Receiver; diff --git a/cast_receiver_app/app/src/validation.js b/cast_receiver_app/app/src/validation.js new file mode 100644 index 0000000000..23e2708f8e --- /dev/null +++ b/cast_receiver_app/app/src/validation.js @@ -0,0 +1,163 @@ +/** + * Copyright (C) 2018 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. + * + * @fileoverview A validator for messages received from sender apps. + */ + +goog.module('exoplayer.cast.validation'); + +const {getPlaybackType, PlaybackType, RepeatMode} = goog.require('exoplayer.cast.constants'); + +/** + * Media item fields. + * + * @enum {string} + */ +const MediaItemField = { + UUID: 'uuid', + MEDIA: 'media', + MIME_TYPE: 'mimeType', + DRM_SCHEMES: 'drmSchemes', + TITLE: 'title', + DESCRIPTION: 'description', + START_POSITION_US: 'startPositionUs', + END_POSITION_US: 'endPositionUs', +}; + +/** + * DrmScheme fields. + * + * @enum {string} + */ +const DrmSchemeField = { + UUID: 'uuid', + LICENSE_SERVER_URI: 'licenseServer', +}; + +/** + * UriBundle fields. + * + * @enum {string} + */ +const UriBundleField = { + URI: 'uri', + REQUEST_HEADERS: 'requestHeaders', +}; + +/** + * Validates an array of media items. + * + * @param {!Array} mediaItems An array of media items. + * @return {boolean} true if all media items are valid, otherwise false is + * returned. + */ +const validateMediaItems = function (mediaItems) { + for (let i = 0; i < mediaItems.length; i++) { + if (!validateMediaItem(mediaItems[i])) { + return false; + } + } + return true; +}; + +/** + * Validates a queue item sent to the receiver by a sender app. + * + * @param {!MediaItem} mediaItem The media item. + * @return {boolean} true if the media item is valid, false otherwise. + */ +const validateMediaItem = function (mediaItem) { + // validate minimal properties + if (!validateProperty(mediaItem, MediaItemField.UUID, 'string')) { + console.log('missing mandatory uuid', mediaItem.uuid); + return false; + } + if (!validateProperty(mediaItem.media, UriBundleField.URI, 'string')) { + console.log('missing mandatory', mediaItem.media ? 'uri' : 'media'); + return false; + } + const mimeType = mediaItem.mimeType; + if (!mimeType || getPlaybackType(mimeType) === PlaybackType.UNKNOWN) { + console.log('unsupported mime type:', mimeType); + return false; + } + // validate optional properties + if (goog.isArray(mediaItem.drmSchemes)) { + for (let i = 0; i < mediaItem.drmSchemes.length; i++) { + let drmScheme = mediaItem.drmSchemes[i]; + if (!validateProperty(drmScheme, DrmSchemeField.UUID, 'string') || + !validateProperty( + drmScheme.licenseServer, UriBundleField.URI, 'string')) { + console.log('invalid drm scheme', drmScheme); + return false; + } + } + } + if (!validateProperty(mediaItem, MediaItemField.START_POSITION_US, '?number') + || !validateProperty(mediaItem, MediaItemField.END_POSITION_US, '?number') + || !validateProperty(mediaItem, MediaItemField.TITLE, '?string') + || !validateProperty(mediaItem, MediaItemField.DESCRIPTION, '?string')) { + console.log('invalid type of one of startPositionUs, endPositionUs, title' + + ' or description', mediaItem); + return false; + } + return true; +}; + +/** + * Validates the existence and type of a property. + * + *

Supported types: number, string, boolean, Array. + *

Prefix the type with a ? to indicate that the property is optional. + * + * @param {?Object|?MediaItem|?UriBundle} obj The object to validate. + * @param {string} propertyName The name of the property. + * @param {string} type The type of the property. + * @return {boolean} True if valid, false otherwise. + */ +const validateProperty = function (obj, propertyName, type) { + if (typeof obj === 'undefined' || obj === null) { + return false; + } + const isOptional = type.startsWith('?'); + const value = obj[propertyName]; + if (isOptional && typeof value === 'undefined') { + return true; + } + type = isOptional ? type.substring(1) : type; + switch (type) { + case 'string': + return typeof value === 'string' || value instanceof String; + case 'number': + return typeof value === 'number' && isFinite(value); + case 'Array': + return typeof value !== 'undefined' && typeof value === 'object' + && value.constructor === Array; + case 'boolean': + return typeof value === 'boolean'; + case 'RepeatMode': + return value === RepeatMode.OFF || value === RepeatMode.ONE || + value === RepeatMode.ALL; + default: + console.warn('Unsupported type when validating an object property. ' + + 'Supported types are string, number, boolean and Array.', type); + return false; + } +}; + +exports.validateMediaItem = validateMediaItem; +exports.validateMediaItems = validateMediaItems; +exports.validateProperty = validateProperty; + diff --git a/cast_receiver_app/assemble.bazel.sh b/cast_receiver_app/assemble.bazel.sh new file mode 100755 index 0000000000..d2039a5152 --- /dev/null +++ b/cast_receiver_app/assemble.bazel.sh @@ -0,0 +1,93 @@ +#!/bin/bash +# Copyright (C) 2019 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. + +## +# Assembles the html, css and javascript files which have been created by the +# bazel build in a destination directory. + +HTML_DIR=app/html +HTML_DEBUG_DIR=app-desktop/html +BIN=bazel-bin + +function usage { + echo "usage: `basename "$0"` -d=DESTINATION_DIR" +} + +for i in "$@" +do +case $i in + -d=*|--destination=*) + DESTINATION="${i#*=}" + shift # past argument=value + ;; + -h|--help) + usage + exit 0 + ;; + *) + # unknown option + ;; +esac +done + +if [ ! -d "$DESTINATION" ]; then + echo "destination directory '$DESTINATION' is not declared or is not a\ + directory" + usage + exit 1 +fi + +if [ ! -f "$BIN/app.js" ];then + echo "file $BIN/app.js not found. Did you build already with bazel?" + echo "-> # bazel build .. --incompatible_package_name_is_a_function=false" + exit 1 +fi + +if [ ! -f "$BIN/app_desktop.js" ];then + echo "file $BIN/app_desktop.js not found. Did you build already with bazel?" + echo "-> # bazel build .. --incompatible_package_name_is_a_function=false" + exit 1 +fi + +echo "assembling receiver and desktop app in $DESTINATION" +echo "-------" + +# cleaning up asset files in destination directory +FILES=( + app.js + app_desktop.js + app_styles.css + app_desktop_styles.css + index.html + player.html +) +for file in ${FILES[@]}; do + if [ -f $DESTINATION/$file ]; then + echo "deleting $file" + rm -f $DESTINATION/$file + fi +done +echo "-------" + +echo "copy html files to $DESTINATION" +cp $HTML_DIR/index.html $DESTINATION +cp $HTML_DEBUG_DIR/index.html $DESTINATION/player.html +echo "copy javascript files to $DESTINATION" +cp $BIN/app.js $BIN/app_desktop.js $DESTINATION +echo "copy css style to $DESTINATION" +cp $BIN/app_styles.css $BIN/app_desktop_styles.css $DESTINATION +echo "-------" + +echo "done." diff --git a/cast_receiver_app/externs/protocol.js b/cast_receiver_app/externs/protocol.js new file mode 100644 index 0000000000..d6544a6f37 --- /dev/null +++ b/cast_receiver_app/externs/protocol.js @@ -0,0 +1,489 @@ +/* + * Copyright (C) 2018 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. + */ + +/** + * @fileoverview Externs for messages sent by a sender app in JSON format. + * + * Fields defined here are prevented from being renamed by the js compiler. + * + * @externs + */ + +/** + * An uri bundle with an uri and request parameters. + * + * @record + */ +class UriBundle { + constructor() { + /** + * The URI. + * + * @type {string} + */ + this.uri; + + /** + * The request headers. + * + * @type {?Object} + */ + this.requestHeaders; + } +} + +/** + * @record + */ +class DrmScheme { + constructor() { + /** + * The DRM UUID. + * + * @type {string} + */ + this.uuid; + + /** + * The license URI. + * + * @type {?UriBundle} + */ + this.licenseServer; + } +} + +/** + * @record + */ +class MediaItem { + constructor() { + /** + * The uuid of the item. + * + * @type {string} + */ + this.uuid; + + /** + * The mime type. + * + * @type {string} + */ + this.mimeType; + + /** + * The media uri bundle. + * + * @type {!UriBundle} + */ + this.media; + + /** + * The DRM schemes. + * + * @type {!Array} + */ + this.drmSchemes; + + /** + * The position to start playback from. + * + * @type {number} + */ + this.startPositionUs; + + /** + * The position at which to end playback. + * + * @type {number} + */ + this.endPositionUs; + + /** + * The title of the media item. + * + * @type {string} + */ + this.title; + + /** + * The description of the media item. + * + * @type {string} + */ + this.description; + } +} + +/** + * Constraint parameters for track selection. + * + * @record + */ +class TrackSelectionParameters { + constructor() { + /** + * The preferred audio language. + * + * @type {string|undefined} + */ + this.preferredAudioLanguage; + + /** + * The preferred text language. + * + * @type {string|undefined} + */ + this.preferredTextLanguage; + + /** + * List of selection flags that are disabled for text track selections. + * + * @type {!Array} + */ + this.disabledTextTrackSelectionFlags; + + /** + * Whether a text track with undetermined language should be selected if no + * track with `preferredTextLanguage` is available, or if + * `preferredTextLanguage` is unset. + * + * @type {boolean} + */ + this.selectUndeterminedTextLanguage; + } +} + +/** + * The PlaybackPosition defined by the position, the uuid of the media item and + * the period id. + * + * @record + */ +class PlaybackPosition { + constructor() { + /** + * The current playback position in milliseconds. + * + * @type {number} + */ + this.positionMs; + + /** + * The uuid of the media item. + * + * @type {string} + */ + this.uuid; + + /** + * The id of the currently playing period. + * + * @type {string} + */ + this.periodId; + + /** + * The reason of a position discontinuity if any. + * + * @type {?string} + */ + this.discontinuityReason; + } +} + +/** + * The playback parameters. + * + * @record + */ +class PlaybackParameters { + constructor() { + /** + * The playback speed. + * + * @type {number} + */ + this.speed; + + /** + * The playback pitch. + * + * @type {number} + */ + this.pitch; + + /** + * Whether silence is skipped. + * + * @type {boolean} + */ + this.skipSilence; + } +} +/** + * The player state. + * + * @record + */ +class PlayerState { + constructor() { + /** + * The playback state. + * + * @type {string} + */ + this.playbackState; + + /** + * The playback parameters. + * + * @type {!PlaybackParameters} + */ + this.playbackParameters; + + /** + * Playback starts when ready if true. + * + * @type {boolean} + */ + this.playWhenReady; + + /** + * The current position within the media. + * + * @type {?PlaybackPosition} + */ + this.playbackPosition; + + /** + * The current window index. + * + * @type {number} + */ + this.windowIndex; + + /** + * The number of windows. + * + * @type {number} + */ + this.windowCount; + + /** + * The audio tracks. + * + * @type {!Array} + */ + this.audioTracks; + + /** + * The video tracks in case of adaptive media. + * + * @type {!Array>} + */ + this.videoTracks; + + /** + * The repeat mode. + * + * @type {string} + */ + this.repeatMode; + + /** + * Whether the shuffle mode is enabled. + * + * @type {boolean} + */ + this.shuffleModeEnabled; + + /** + * The playback order to use when shuffle mode is enabled. + * + * @type {!Array} + */ + this.shuffleOrder; + + /** + * The queue of media items. + * + * @type {!Array} + */ + this.mediaQueue; + + /** + * The media item info of the queue items if available. + * + * @type {!Object} + */ + this.mediaItemsInfo; + + /** + * The sequence number of the sender. + * + * @type {number} + */ + this.sequenceNumber; + + /** + * The player error. + * + * @type {?PlayerError} + */ + this.error; + } +} + +/** + * The error description. + * + * @record + */ +class PlayerError { + constructor() { + /** + * The error message. + * + * @type {string} + */ + this.message; + + /** + * The error code. + * + * @type {number} + */ + this.code; + + /** + * The error category. + * + * @type {number} + */ + this.category; + } +} + +/** + * A period. + * + * @record + */ +class Period { + constructor() { + /** + * The id of the period. Must be unique within a media item. + * + * @type {string} + */ + this.id; + + /** + * The duration of the period in microseconds. + * + * @type {number} + */ + this.durationUs; + } +} +/** + * Holds dynamic information for a MediaItem. + * + *

Holds information related to preparation for a specific {@link MediaItem}. + * Unprepared items are associated with an {@link #EMPTY} info object until + * prepared. + * + * @record + */ +class MediaItemInfo { + constructor() { + /** + * The duration of the window in microseconds. + * + * @type {number} + */ + this.windowDurationUs; + + /** + * The default start position relative to the start of the window in + * microseconds. + * + * @type {number} + */ + this.defaultStartPositionUs; + + /** + * The periods conforming the media item. + * + * @type {!Array} + */ + this.periods; + + /** + * The position of the window in the first period in microseconds. + * + * @type {number} + */ + this.positionInFirstPeriodUs; + + /** + * Whether it is possible to seek within the window. + * + * @type {boolean} + */ + this.isSeekable; + + /** + * Whether the window may change when the timeline is updated. + * + * @type {boolean} + */ + this.isDynamic; + } +} + +/** + * The message envelope send by a sender app. + * + * @record + */ +class ExoCastMessage { + constructor() { + /** + * The clients message sequenec number. + * + * @type {number} + */ + this.sequenceNumber; + + /** + * The name of the method. + * + * @type {string} + */ + this.method; + + /** + * The arguments of the method. + * + * @type {!Object} + */ + this.args; + } +}; + diff --git a/cast_receiver_app/externs/shaka.js b/cast_receiver_app/externs/shaka.js new file mode 100644 index 0000000000..0af36d7b8c --- /dev/null +++ b/cast_receiver_app/externs/shaka.js @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2019 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. + */ + +/** + * @fileoverview Externs of the Shaka configuration. + * + * @externs + */ + +/** + * The drm configuration for the Shaka player. + * + * @record + */ +class DrmConfiguration { + constructor() { + /** + * A map of license servers with the UUID of the drm system as the key and the + * license uri as the value. + * + * @type {!Object} + */ + this.servers; + } +} + +/** + * The configuration of the Shaka player. + * + * @record + */ +class PlayerConfiguration { + constructor() { + /** + * The preferred audio language. + * + * @type {string} + */ + this.preferredAudioLanguage; + + /** + * The preferred text language. + * + * @type {string} + */ + this.preferredTextLanguage; + + /** + * The drm configuration. + * + * @type {?DrmConfiguration} + */ + this.drm; + } +} diff --git a/cast_receiver_app/src/configuration_factory.js b/cast_receiver_app/src/configuration_factory.js new file mode 100644 index 0000000000..819e52a755 --- /dev/null +++ b/cast_receiver_app/src/configuration_factory.js @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2019 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. + */ + +goog.module('exoplayer.cast.ConfigurationFactory'); + +const {DRM_SYSTEMS} = goog.require('exoplayer.cast.constants'); + +const EMPTY_DRM_CONFIGURATION = + /** @type {!DrmConfiguration} */ (Object.freeze({ + servers: {}, + })); + +/** + * Creates the configuration of the Shaka player. + */ +class ConfigurationFactory { + /** + * Creates the Shaka player configuration. + * + * @param {!MediaItem} mediaItem The media item for which to create the + * configuration. + * @param {!TrackSelectionParameters} trackSelectionParameters The track + * selection parameters. + * @return {!PlayerConfiguration} The shaka player configuration. + */ + createConfiguration(mediaItem, trackSelectionParameters) { + const configuration = /** @type {!PlayerConfiguration} */ ({}); + this.mapLanguageConfiguration(trackSelectionParameters, configuration); + this.mapDrmConfiguration_(mediaItem, configuration); + return configuration; + } + + /** + * Maps the preferred audio and text language from the track selection + * parameters to the configuration. + * + * @param {!TrackSelectionParameters} trackSelectionParameters The selection + * parameters. + * @param {!PlayerConfiguration} playerConfiguration The player configuration. + */ + mapLanguageConfiguration(trackSelectionParameters, playerConfiguration) { + playerConfiguration.preferredAudioLanguage = + trackSelectionParameters.preferredAudioLanguage || ''; + playerConfiguration.preferredTextLanguage = + trackSelectionParameters.preferredTextLanguage || ''; + } + + /** + * Maps the drm configuration from the media item to the configuration. If no + * drm is specified for the given media item, null is assigned. + * + * @private + * @param {!MediaItem} mediaItem The media item. + * @param {!PlayerConfiguration} playerConfiguration The player configuration. + */ + mapDrmConfiguration_(mediaItem, playerConfiguration) { + if (!mediaItem.drmSchemes) { + playerConfiguration.drm = EMPTY_DRM_CONFIGURATION; + return; + } + const drmConfiguration = /** @type {!DrmConfiguration} */({ + servers: {}, + }); + let hasDrmServer = false; + mediaItem.drmSchemes.forEach((scheme) => { + const drmSystem = DRM_SYSTEMS[scheme.uuid]; + if (drmSystem && scheme.licenseServer && scheme.licenseServer.uri) { + hasDrmServer = true; + drmConfiguration.servers[drmSystem] = scheme.licenseServer.uri; + } + }); + playerConfiguration.drm = + hasDrmServer ? drmConfiguration : EMPTY_DRM_CONFIGURATION; + } +} + +exports = ConfigurationFactory; diff --git a/cast_receiver_app/src/constants.js b/cast_receiver_app/src/constants.js new file mode 100644 index 0000000000..e9600429f0 --- /dev/null +++ b/cast_receiver_app/src/constants.js @@ -0,0 +1,140 @@ +/** + * Copyright (C) 2019 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. + */ + +goog.module('exoplayer.cast.constants'); + +/** + * The underyling player. + * + * @enum {number} + */ +const PlaybackType = { + VIDEO_ELEMENT: 1, + SHAKA_PLAYER: 2, + UNKNOWN: 999, +}; + +/** + * Supported mime types and their playback mode. + * + * @type {!Object} + */ +const SUPPORTED_MIME_TYPES = Object.freeze({ + 'application/dash+xml': PlaybackType.SHAKA_PLAYER, + 'application/vnd.apple.mpegurl': PlaybackType.SHAKA_PLAYER, + 'application/vnd.ms-sstr+xml': PlaybackType.SHAKA_PLAYER, + 'application/x-mpegURL': PlaybackType.SHAKA_PLAYER, +}); + +/** + * Returns the playback type required for a given mime type, or + * PlaybackType.UNKNOWN if the mime type is not recognized. + * + * @param {string} mimeType The mime type. + * @return {!PlaybackType} The required playback type, or PlaybackType.UNKNOWN + * if the mime type is not recognized. + */ +const getPlaybackType = function(mimeType) { + if (mimeType.startsWith('video/') || mimeType.startsWith('audio/')) { + return PlaybackType.VIDEO_ELEMENT; + } else { + return SUPPORTED_MIME_TYPES[mimeType] || PlaybackType.UNKNOWN; + } +}; + +/** + * Error messages. + * + * @enum {string} + */ +const ErrorMessages = { + SHAKA_LOAD_ERROR: 'Error while loading media with Shaka.', + SHAKA_UNKNOWN_ERROR: 'Shaka error event captured.', + MEDIA_ELEMENT_UNKNOWN_ERROR: 'Media element error event captured.', + UNKNOWN_FATAL_ERROR: 'Fatal playback error. Shaka instance replaced.', + UNKNOWN_ERROR: 'Unknown error', +}; + +/** + * ExoPlayer's repeat modes. + * + * @enum {string} + */ +const RepeatMode = { + OFF: 'OFF', + ONE: 'ONE', + ALL: 'ALL', +}; + +/** + * Error categories. Error categories coming from Shaka are defined in [Shaka + * source + * code](https://shaka-player-demo.appspot.com/docs/api/shaka.util.Error.html). + * + * @enum {number} + */ +const ErrorCategory = { + MEDIA_ELEMENT: 0, + FATAL_SHAKA_ERROR: 1000, +}; + +/** + * An error object to be used if no media error is assigned to the `error` + * field of the media element when an error event is fired + * + * @type {!PlayerError} + */ +const UNKNOWN_ERROR = /** @type {!PlayerError} */ (Object.freeze({ + message: ErrorMessages.UNKNOWN_ERROR, + code: 0, + category: 0, +})); + +/** + * UUID for the Widevine DRM scheme. + * + * @type {string} + */ +const WIDEVINE_UUID = 'edef8ba9-79d6-4ace-a3c8-27dcd51d21ed'; + +/** + * UUID for the PlayReady DRM scheme. + * + * @type {string} + */ +const PLAYREADY_UUID = '9a04f079-9840-4286-ab92-e65be0885f95'; + +/** @type {!Object} */ +const drmSystems = {}; +drmSystems[WIDEVINE_UUID] = 'com.widevine.alpha'; +drmSystems[PLAYREADY_UUID] = 'com.microsoft.playready'; + +/** + * The uuids of the supported DRM systems. + * + * @type {!Object} + */ +const DRM_SYSTEMS = Object.freeze(drmSystems); + +exports.PlaybackType = PlaybackType; +exports.ErrorMessages = ErrorMessages; +exports.ErrorCategory = ErrorCategory; +exports.RepeatMode = RepeatMode; +exports.getPlaybackType = getPlaybackType; +exports.WIDEVINE_UUID = WIDEVINE_UUID; +exports.PLAYREADY_UUID = PLAYREADY_UUID; +exports.DRM_SYSTEMS = DRM_SYSTEMS; +exports.UNKNOWN_ERROR = UNKNOWN_ERROR; diff --git a/cast_receiver_app/src/playback_info_view.js b/cast_receiver_app/src/playback_info_view.js new file mode 100644 index 0000000000..22e2b8ded5 --- /dev/null +++ b/cast_receiver_app/src/playback_info_view.js @@ -0,0 +1,233 @@ +/* + * Copyright (C) 2018 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. + */ + +goog.module('exoplayer.cast.PlaybackInfoView'); + +const Player = goog.require('exoplayer.cast.Player'); +const Timeout = goog.require('exoplayer.cast.Timeout'); +const dom = goog.require('goog.dom'); + +/** The default timeout for hiding the UI in milliseconds. */ +const SHOW_TIMEOUT_MS = 5000; +/** The timeout for hiding the UI in audio only mode in milliseconds. */ +const SHOW_TIMEOUT_MS_AUDIO = 0; +/** The timeout for updating the UI while being displayed. */ +const UPDATE_TIMEOUT_MS = 1000; + +/** + * Formats a duration in milliseconds to a string in hh:mm:ss format. + * + * @param {number} durationMs The duration in milliseconds. + * @return {string} The duration formatted as hh:mm:ss. + */ +const formatTimestampMsAsString = function (durationMs) { + const hours = Math.floor(durationMs / 1000 / 60 / 60); + const minutes = Math.floor((durationMs / 1000 / 60) % 60); + const seconds = Math.floor((durationMs / 1000) % 60) % 60; + let timeString = ''; + if (hours > 0) { + timeString += hours + ':'; + } + if (minutes < 10) { + timeString += '0'; + } + timeString += minutes + ":"; + if (seconds < 10) { + timeString += '0'; + } + timeString += seconds; + return timeString; +}; + +/** + * A view to display information about the current media item and playback + * progress. + * + * @constructor + * @param {!Player} player The player of which to display the + * playback info. + * @param {string} viewId The id of the playback info view. + */ +const PlaybackInfoView = function (player, viewId) { + /** @const @private {!Player} */ + this.player_ = player; + /** @const @private {?Element} */ + this.container_ = document.getElementById(viewId); + /** @const @private {?Element} */ + this.elapsedTimeBar_ = document.getElementById('exo_elapsed_time'); + /** @const @private {?Element} */ + this.elapsedTimeLabel_ = document.getElementById('exo_elapsed_time_label'); + /** @const @private {?Element} */ + this.durationLabel_ = document.getElementById('exo_duration_label'); + /** @const @private {!Timeout} */ + this.hideTimeout_ = new Timeout(); + /** @const @private {!Timeout} */ + this.updateTimeout_ = new Timeout(); + /** @private {boolean} */ + this.wasPlaying_ = player.getPlayWhenReady() + && player.getPlaybackState() === Player.PlaybackState.READY; + /** @private {number} */ + this.showTimeoutMs_ = SHOW_TIMEOUT_MS; + /** @private {number} */ + this.showTimeoutMsVideo_ = this.showTimeoutMs_; + + if (this.wasPlaying_) { + this.hideAfterTimeout(); + } else { + this.show(); + } + + player.addPlayerListener((playerState) => { + if (this.container_ === null) { + return; + } + const playbackPosition = playerState.playbackPosition; + const discontinuityReason = + playbackPosition ? playbackPosition.discontinuityReason : null; + if (discontinuityReason) { + const currentMediaItem = player.getCurrentMediaItem(); + this.showTimeoutMs_ = + currentMediaItem && currentMediaItem.mimeType === 'audio/*' ? + SHOW_TIMEOUT_MS_AUDIO : + this.showTimeoutMsVideo_; + } + const playWhenReady = playerState.playWhenReady; + const state = playerState.playbackState; + const isPlaying = playWhenReady && state === Player.PlaybackState.READY; + const userSeekedInBufferedRange = + discontinuityReason === Player.DiscontinuityReason.SEEK && isPlaying; + if (!isPlaying) { + this.show(); + } else if ((!this.wasPlaying_ && isPlaying) || userSeekedInBufferedRange) { + this.hideAfterTimeout(); + } + this.wasPlaying_ = isPlaying; + }); +}; + +/** Shows the player info view. */ +PlaybackInfoView.prototype.show = function () { + if (this.container_ != null) { + this.hideTimeout_.cancel(); + this.updateUi_(); + this.container_.style.display = 'block'; + this.startUpdateTimeout_(); + } +}; + +/** Hides the player info view. */ +PlaybackInfoView.prototype.hideAfterTimeout = function() { + if (this.container_ === null) { + return; + } + this.show(); + this.hideTimeout_.postDelayed(this.showTimeoutMs_).then(() => { + this.container_.style.display = 'none'; + this.updateTimeout_.cancel(); + }); +}; + +/** + * Sets the playback info view timeout. The playback info view is automatically + * hidden after this duration of time has elapsed without show() being called + * again. When playing streams with content type 'audio/*' the view is always + * displayed. + * + * @param {number} showTimeoutMs The duration in milliseconds. A non-positive + * value will cause the view to remain visible indefinitely. + */ +PlaybackInfoView.prototype.setShowTimeoutMs = function(showTimeoutMs) { + this.showTimeoutMs_ = showTimeoutMs; + this.showTimeoutMsVideo_ = showTimeoutMs; +}; + +/** + * Updates all UI components. + * + * @private + */ +PlaybackInfoView.prototype.updateUi_ = function () { + const elapsedTimeMs = this.player_.getCurrentPositionMs(); + const durationMs = this.player_.getDurationMs(); + if (this.elapsedTimeLabel_ !== null) { + this.updateDuration_(this.elapsedTimeLabel_, elapsedTimeMs, false); + } + if (this.durationLabel_ !== null) { + this.updateDuration_(this.durationLabel_, durationMs, true); + } + if (this.elapsedTimeBar_ !== null) { + this.updateProgressBar_(elapsedTimeMs, durationMs); + } +}; + +/** + * Adjust the progress bar indicating the elapsed time relative to the duration. + * + * @private + * @param {number} elapsedTimeMs The elapsed time in milliseconds. + * @param {number} durationMs The duration in milliseconds. + */ +PlaybackInfoView.prototype.updateProgressBar_ = + function(elapsedTimeMs, durationMs) { + if (elapsedTimeMs <= 0 || durationMs <= 0) { + this.elapsedTimeBar_.style.width = 0; + } else { + const widthPercentage = elapsedTimeMs / durationMs * 100; + this.elapsedTimeBar_.style.width = Math.min(100, widthPercentage) + '%'; + } +}; + +/** + * Updates the display value of the duration in the DOM formatted as hh:mm:ss. + * + * @private + * @param {!Element} element The element to update. + * @param {number} durationMs The duration in milliseconds. + * @param {boolean} hideZero If true values of zero and below are not displayed. + */ +PlaybackInfoView.prototype.updateDuration_ = + function (element, durationMs, hideZero) { + while (element.firstChild) { + element.removeChild(element.firstChild); + } + if (durationMs <= 0 && !hideZero) { + element.appendChild(dom.createDom(dom.TagName.SPAN, {}, + formatTimestampMsAsString(0))); + } else if (durationMs > 0) { + element.appendChild(dom.createDom(dom.TagName.SPAN, {}, + formatTimestampMsAsString(durationMs))); + } +}; + +/** + * Starts a repeating timeout that updates the UI every UPDATE_TIMEOUT_MS + * milliseconds. + * + * @private + */ +PlaybackInfoView.prototype.startUpdateTimeout_ = function() { + this.updateTimeout_.cancel(); + if (!this.player_.getPlayWhenReady() || + this.player_.getPlaybackState() !== Player.PlaybackState.READY) { + return; + } + this.updateTimeout_.postDelayed(UPDATE_TIMEOUT_MS).then(() => { + this.updateUi_(); + this.startUpdateTimeout_(); + }); +}; + +exports = PlaybackInfoView; diff --git a/cast_receiver_app/src/player.js b/cast_receiver_app/src/player.js new file mode 100644 index 0000000000..d7ffc58f4c --- /dev/null +++ b/cast_receiver_app/src/player.js @@ -0,0 +1,1522 @@ +/* + * Copyright (C) 2018 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. + */ + +goog.module('exoplayer.cast.Player'); + +const ConfigurationFactory = goog.require('exoplayer.cast.ConfigurationFactory'); +const NetworkingEngine = goog.require('shaka.net.NetworkingEngine'); +const ShakaError = goog.require('shaka.util.Error'); +const ShakaPlayer = goog.require('shaka.Player'); +const asserts = goog.require('goog.dom.asserts'); +const googArray = goog.require('goog.array'); +const safedom = goog.require('goog.dom.safe'); +const {ErrorMessages, ErrorCategory, PlaybackType, RepeatMode, getPlaybackType, UNKNOWN_ERROR} = goog.require('exoplayer.cast.constants'); +const {UuidComparator, createUuidComparator, log} = goog.require('exoplayer.cast.util'); +const {assert, fail} = goog.require('goog.asserts'); +const {clamp} = goog.require('goog.math'); + +/** + * Value indicating that no window index is currently set. + */ +const INDEX_UNSET = -1; + +/** + * Estimated time for processing the manifest after download in millisecconds. + * + * See: https://github.com/google/shaka-player/issues/1734 + */ +const MANIFEST_PROCESSING_ESTIMATE_MS = 350; + +/** + * Media element events to listen to. + * + * @enum {string} + */ +const MediaElementEvent = { + ERROR: 'error', + LOADED_DATA: 'loadeddata', + PAUSE: 'pause', + PLAYING: 'playing', + SEEKED: 'seeked', + SEEKING: 'seeking', + WAITING: 'waiting', +}; + +/** + * Shaka events to listen to. + * + * @enum {string} + */ +const ShakaEvent = { + ERROR: 'error', + STREAMING: 'streaming', + TRACKS_CHANGED: 'trackschanged', +}; + +/** + * ExoPlayer's playback states. + * + * @enum {string} + */ +const PlaybackState = { + IDLE: 'IDLE', + BUFFERING: 'BUFFERING', + READY: 'READY', + ENDED: 'ENDED', +}; + +/** + * ExoPlayer's position discontinuity reasons. + * + * @enum {string} + */ +const DiscontinuityReason = { + PERIOD_TRANSITION: 'PERIOD_TRANSITION', + SEEK: 'SEEK', +}; + +/** + * A dummy `MediaIteminfo` to be used while the actual period is not + * yet available. + * + * @const + * @type {!MediaItemInfo} + */ +const DUMMY_MEDIA_ITEM_INFO = Object.freeze({ + isSeekable: false, + isDynamic: true, + positionInFirstPeriodUs: 0, + defaultStartPositionUs: 0, + windowDurationUs: 0, + periods: [{ + id: 1, + durationUs: 0, + }], +}); + +/** + * The Player wraps a Shaka player and maintains a queue of media items. + * + * After construction the player is in `IDLE` state. Calling `#prepare` prepares + * the player with the queue item at the given window index and position. The + * state transitions to `BUFFERING`. When 'playWhenReady' is set to `true` + * playback start when the player becomes 'READY'. + * + * When the player needs to rebuffer the state goes to 'BUFFERING' and becomes + * 'READY' again when playback can be resumed. + * + * The state transitions to `ENDED` when playback reached the end of the last + * item in the queue, when the last item has been removed from the queue if + * `!IDLE`, or when `prepare` is called with an empty queue. Seeking makes the + * player transition away from `ENDED` again. + * + * When `#stop` is called or when a fatal playback error occurs, the player + * transition to `IDLE` state and needs to be prepared again to resume playback. + * + * `playWhenReady`, `repeatMode`, `shuffleModeEnabled` can be manipulated in any + * state, just as media items can be added, moved and removed. + * + * @constructor + * @param {!ShakaPlayer} shakaPlayer The shaka player to wrap. + * @param {!ConfigurationFactory} configurationFactory A factory to create a + * configuration for the Shaka player. + */ +const Player = function(shakaPlayer, configurationFactory) { + /** @private @const {?HTMLMediaElement} */ + this.videoElement_ = shakaPlayer.getMediaElement(); + /** @private @const {!ConfigurationFactory} */ + this.configurationFactory_ = configurationFactory; + /** @private @const {!Array} */ + this.playerListeners_ = []; + /** + * @private + * @const + * {?function(NetworkingEngine.RequestType, (?|null))} + */ + this.manifestResponseFilter_ = (type, response) => { + if (type === NetworkingEngine.RequestType.MANIFEST) { + setTimeout(() => { + this.updateWindowMediaItemInfo_(); + this.invalidate(); + }, MANIFEST_PROCESSING_ESTIMATE_MS); + } + }; + + /** @private {!ShakaPlayer} */ + this.shakaPlayer_ = shakaPlayer; + /** @private {boolean} */ + this.playWhenReady_ = false; + /** @private {boolean} */ + this.shuffleModeEnabled_ = false; + /** @private {!RepeatMode} */ + this.repeatMode_ = RepeatMode.OFF; + /** @private {!TrackSelectionParameters} */ + this.trackSelectionParameters_ = /** @type {!TrackSelectionParameters} */ ({ + preferredAudioLanguage: '', + preferredTextLanguage: '', + disabledTextTrackSelectionFlags: [], + selectUndeterminedTextLanguage: false, + }); + /** @private {number} */ + this.windowIndex_ = INDEX_UNSET; + /** @private {!Array} */ + this.queue_ = []; + /** @private {!Object} */ + this.queueUuidIndexMap_ = {}; + /** @private {!UuidComparator} */ + this.uuidComparator_ = createUuidComparator(this.queueUuidIndexMap_); + + /** @private {!PlaybackState} */ + this.playbackState_ = PlaybackState.IDLE; + /** @private {!MediaItemInfo} */ + this.windowMediaItemInfo_ = DUMMY_MEDIA_ITEM_INFO; + /** @private {number} */ + this.windowPeriodIndex_ = 0; + /** @private {!Object} */ + this.mediaItemInfoMap_ = {}; + /** @private {?PlayerError} */ + this.playbackError_ = null; + /** @private {?DiscontinuityReason} */ + this.discontinuityReason_ = null; + /** @private {!Array} */ + this.shuffleOrder_ = []; + /** @private {number} */ + this.shuffleIndex_ = 0; + /** @private {!PlaybackType} */ + this.playbackType_ = PlaybackType.UNKNOWN; + /** @private {boolean} */ + this.isManifestFilterRegistered_ = false; + /** @private {?string} */ + this.uuidToPrepare_ = null; + + if (!this.shakaPlayer_ || !this.videoElement_) { + throw new Error('an instance of Shaka player with a media element ' + + 'attached to it needs to be passed to the constructor.'); + } + + /** @private @const {function(!Event)} */ + this.playbackStateListener_ = (ev) => { + log(['handle event: ', ev.type]); + let invalid = false; + switch (ev.type) { + case ShakaEvent.STREAMING: { + // Arrives once after prepare when the manifest is available. + const uuid = this.queue_[this.windowIndex_].uuid; + const cachedMediaItemInfo = this.mediaItemInfoMap_[uuid]; + if (!cachedMediaItemInfo || cachedMediaItemInfo.isDynamic) { + this.updateWindowMediaItemInfo_(); + if (this.windowMediaItemInfo_.isDynamic) { + this.registerManifestResponseFilter_(); + } + invalid = true; + } + break; + } + case ShakaEvent.TRACKS_CHANGED: { + // Arrives when tracks have changed either initially or at a period + // boundary. + const periods = this.windowMediaItemInfo_.periods; + const previousPeriodIndex = this.windowPeriodIndex_; + this.evaluateAndSetCurrentPeriod_(periods); + invalid = previousPeriodIndex !== this.windowPeriodIndex_; + if (periods.length && this.windowPeriodIndex_ > 0) { + // Player transitions to next period in multiperiod stream. + this.discontinuityReason_ = this.discontinuityReason_ || + DiscontinuityReason.PERIOD_TRANSITION; + invalid = true; + } + if (this.videoElement_.paused && this.playWhenReady_) { + this.videoElement_.play(); + } + break; + } + case MediaElementEvent.LOADED_DATA: { + // Arrives once when the first frame has been rendered. + if (this.playbackType_ === PlaybackType.VIDEO_ELEMENT) { + const uuid = this.queue_[this.windowIndex_].uuid; + let mediaItemInfo = this.mediaItemInfoMap_[uuid]; + if (!mediaItemInfo || mediaItemInfo.isDynamic) { + mediaItemInfo = this.buildMediaItemInfoFromElement_(); + if (mediaItemInfo !== null) { + this.mediaItemInfoMap_[uuid] = mediaItemInfo; + this.windowMediaItemInfo_ = mediaItemInfo; + } + } + this.evaluateAndSetCurrentPeriod_(mediaItemInfo.periods); + invalid = true; + } + if (this.videoElement_.paused && this.playWhenReady_) { + // Restart after automatic skip to next queue item. + this.videoElement_.play(); + } else if (this.videoElement_.paused) { + // If paused, the PLAYING event will not be fired, hence we transition + // to state READY right here. + this.playbackState_ = PlaybackState.READY; + invalid = true; + } + break; + } + case MediaElementEvent.WAITING: + case MediaElementEvent.SEEKING: { + // Arrives at a user seek or when re-buffering starts. + if (this.playbackState_ !== PlaybackState.BUFFERING) { + this.playbackState_ = PlaybackState.BUFFERING; + invalid = true; + } + break; + } + case MediaElementEvent.PLAYING: + case MediaElementEvent.SEEKED: { + // Arrives at the end of a user seek or after re-buffering. + if (this.playbackState_ !== PlaybackState.READY) { + this.playbackState_ = PlaybackState.READY; + invalid = true; + } + break; + } + case MediaElementEvent.PAUSE: { + // Detects end of media and either skips to next or transitions to ended + // state. + if (this.videoElement_.ended) { + let nextWindowIndex = this.getNextWindowIndex(); + if (nextWindowIndex !== INDEX_UNSET) { + this.seekToWindowInternal_(nextWindowIndex, undefined); + } else { + this.playbackState_ = PlaybackState.ENDED; + invalid = true; + } + } + break; + } + } + if (invalid) { + this.invalidate(); + } + }; + /** @private @const {function(!Event)} */ + this.mediaElementErrorHandler_ = (ev) => { + console.error('Media element error reported in handler'); + this.playbackError_ = !this.videoElement_.error ? UNKNOWN_ERROR : { + message: this.videoElement_.error.message, + code: this.videoElement_.error.code, + category: ErrorCategory.MEDIA_ELEMENT, + }; + this.playbackState_ = PlaybackState.IDLE; + this.uuidToPrepare_ = this.queue_[this.windowIndex_] ? + this.queue_[this.windowIndex_].uuid : + null; + this.invalidate(); + }; + /** @private @const {function(!Event)} */ + this.shakaErrorHandler_ = (ev) => { + const shakaError = /** @type {!ShakaError} */ (ev['detail']); + if (shakaError.severity !== ShakaError.Severity.RECOVERABLE) { + this.fatalShakaError_(shakaError, 'Shaka error reported by error event'); + this.invalidate(); + } else { + console.error('Recoverable Shaka error reported in handler'); + } + }; + + this.shakaPlayer_.addEventListener( + ShakaEvent.STREAMING, this.playbackStateListener_); + this.shakaPlayer_.addEventListener( + ShakaEvent.TRACKS_CHANGED, this.playbackStateListener_); + + this.videoElement_.addEventListener( + MediaElementEvent.LOADED_DATA, this.playbackStateListener_); + this.videoElement_.addEventListener( + MediaElementEvent.WAITING, this.playbackStateListener_); + this.videoElement_.addEventListener( + MediaElementEvent.PLAYING, this.playbackStateListener_); + this.videoElement_.addEventListener( + MediaElementEvent.PAUSE, this.playbackStateListener_); + this.videoElement_.addEventListener( + MediaElementEvent.SEEKING, this.playbackStateListener_); + this.videoElement_.addEventListener( + MediaElementEvent.SEEKED, this.playbackStateListener_); + + // Attach error handlers. + this.shakaPlayer_.addEventListener(ShakaEvent.ERROR, this.shakaErrorHandler_); + this.videoElement_.addEventListener( + MediaElementEvent.ERROR, this.mediaElementErrorHandler_); +}; + +/** + * Adds a listener to the player. + * + * @param {function(!PlayerState)} listener The player listener. + */ +Player.prototype.addPlayerListener = function(listener) { + this.playerListeners_.push(listener); +}; + +/** + * Removes a listener. + * + * @param {function(!Object)} listener The player listener. + */ +Player.prototype.removePlayerListener = function(listener) { + for (let i = 0; i < this.playerListeners_.length; i++) { + if (this.playerListeners_[i] === listener) { + this.playerListeners_.splice(i, 1); + break; + } + } +}; + +/** + * Gets the current PlayerState. + * + * @return {!PlayerState} + */ +Player.prototype.getPlayerState = function() { + return this.buildPlayerState_(); +}; + +/** + * Sends the current playback state to clients. + */ +Player.prototype.invalidate = function() { + const playbackState = this.buildPlayerState_(); + for (let i = 0; i < this.playerListeners_.length; i++) { + this.playerListeners_[i](playbackState); + } +}; + +/** + * Get the audio tracks. + * + * @return {!Array} An array with the track names}. + */ +Player.prototype.getAudioTracks = function() { + return this.windowMediaItemInfo_ !== DUMMY_MEDIA_ITEM_INFO ? + this.shakaPlayer_.getAudioLanguages() : + []; +}; + +/** + * Gets the video tracks. + * + * @return {!Array} An array with the video tracks. + */ +Player.prototype.getVideoTracks = function() { + return this.windowMediaItemInfo_ !== DUMMY_MEDIA_ITEM_INFO ? + this.shakaPlayer_.getVariantTracks() : + []; +}; + +/** + * Gets the playback state. + * + * @return {!PlaybackState} The playback state. + */ +Player.prototype.getPlaybackState = function() { + return this.playbackState_; +}; + +/** + * Gets the playback error if any. + * + * @return {?Object} The playback error. + */ +Player.prototype.getPlaybackError = function() { + return this.playbackError_; +}; + +/** + * Gets the duration in milliseconds or a negative value if unknown. + * + * @return {number} The duration in milliseconds. + */ +Player.prototype.getDurationMs = function() { + return this.windowMediaItemInfo_ ? + this.windowMediaItemInfo_.windowDurationUs / 1000 : -1; +}; + +/** + * Gets the current position in milliseconds or a negative value if not known. + * + * @return {number} The current position in milliseconds. + */ +Player.prototype.getCurrentPositionMs = function() { + if (!this.videoElement_.currentTime) { + return 0; + } + return (this.videoElement_.currentTime * 1000) - + (this.windowMediaItemInfo_.positionInFirstPeriodUs / 1000); +}; + +/** + * Gets the current window index. + * + * @return {number} The current window index. + */ +Player.prototype.getCurrentWindowIndex = function() { + if (this.playbackState_ === PlaybackState.IDLE) { + return this.queueUuidIndexMap_[this.uuidToPrepare_ || ''] || 0; + } + return Math.max(0, this.windowIndex_); +}; + +/** + * Gets the media item of the current window or null if the queue is empty. + * + * @return {?MediaItem} The media item of the current window. + */ +Player.prototype.getCurrentMediaItem = function() { + return this.windowIndex_ >= 0 ? this.queue_[this.windowIndex_] : null; +}; + +/** + * Gets the media item info of the current window index or null if not yet + * available. + * + * @return {?MediaItemInfo} The current media item info or undefined. + */ +Player.prototype.getCurrentMediaItemInfo = function () { + return this.windowMediaItemInfo_; +}; + +/** + * Gets the text tracks. + * + * @return {!TextTrackList} The text tracks. + */ +Player.prototype.getTextTracks = function() { + return this.videoElement_.textTracks; +}; + +/** + * Gets whether the player should play when ready. + * + * @return {boolean} True when it plays when ready. + */ +Player.prototype.getPlayWhenReady = function() { + return this.playWhenReady_; +}; + +/** + * Sets whether to play when ready. + * + * @param {boolean} playWhenReady Whether to play when ready. + * @return {boolean} Whether calling this method causes a change of the player + * state. + */ +Player.prototype.setPlayWhenReady = function(playWhenReady) { + if (this.playWhenReady_ === playWhenReady) { + return false; + } + this.playWhenReady_ = playWhenReady; + this.invalidate(); + if (this.playbackState_ === PlaybackState.IDLE || + this.playbackState_ === PlaybackState.ENDED) { + return true; + } + if (this.playWhenReady_) { + this.videoElement_.play(); + } else { + this.videoElement_.pause(); + } + return true; +}; + +/** + * Gets the repeat mode. + * + * @return {!RepeatMode} The repeat mode. + */ +Player.prototype.getRepeatMode = function() { + return this.repeatMode_; +}; + +/** + * Sets the repeat mode. Must be a value of the enum Player.RepeatMode. + * + * @param {!RepeatMode} mode The repeat mode. + * @return {boolean} Whether calling this method causes a change of the player + * state. + */ +Player.prototype.setRepeatMode = function(mode) { + if (this.repeatMode_ === mode) { + return false; + } + if (mode === Player.RepeatMode.OFF || + mode === Player.RepeatMode.ONE || + mode === Player.RepeatMode.ALL) { + this.repeatMode_ = mode; + } else { + throw new Error('illegal repeat mode: ' + mode); + } + this.invalidate(); + return true; +}; + +/** + * Enables or disables the shuffle mode. + * + * @param {boolean} enabled Whether the shuffle mode is enabled or not. + * @return {boolean} Whether calling this method causes a change of the player + * state. + */ +Player.prototype.setShuffleModeEnabled = function(enabled) { + if (this.shuffleModeEnabled_ === enabled) { + return false; + } + this.shuffleModeEnabled_ = enabled; + this.invalidate(); + return true; +}; + +/** + * Sets the track selection parameters. + * + * @param {!TrackSelectionParameters} trackSelectionParameters The parameters. + * @return {boolean} Whether calling this method causes a change of the player + * state. + */ +Player.prototype.setTrackSelectionParameters = function( + trackSelectionParameters) { + this.trackSelectionParameters_ = trackSelectionParameters; + /** @type {!PlayerConfiguration} */ + const configuration = /** @type {!PlayerConfiguration} */ ({}); + this.configurationFactory_.mapLanguageConfiguration( + trackSelectionParameters, configuration); + /** @type {!PlayerConfiguration} */ + const currentConfiguration = this.shakaPlayer_.getConfiguration(); + /** @type {boolean} */ + let isStateChange = false; + if (currentConfiguration.preferredAudioLanguage !== + configuration.preferredAudioLanguage) { + this.shakaPlayer_.selectAudioLanguage(configuration.preferredAudioLanguage); + isStateChange = true; + } + if (currentConfiguration.preferredTextLanguage !== + configuration.preferredTextLanguage) { + this.shakaPlayer_.selectTextLanguage(configuration.preferredTextLanguage); + isStateChange = true; + } + return isStateChange; +}; + +/** + * Gets the previous window index or a negative number if no item previous to + * the current item is available. + * + * @return {number} The previous window index or a negative number if the + * current item is the first item. + */ +Player.prototype.getPreviousWindowIndex = function() { + if (this.playbackType_ === PlaybackType.UNKNOWN) { + return INDEX_UNSET; + } + switch (this.repeatMode_) { + case RepeatMode.ONE: + return this.windowIndex_; + case RepeatMode.ALL: + if (this.shuffleModeEnabled_) { + const previousIndex = this.shuffleIndex_ > 0 ? + this.shuffleIndex_ - 1 : this.queue_.length - 1; + return this.shuffleOrder_[previousIndex]; + } else { + const previousIndex = this.windowIndex_ > 0 ? + this.windowIndex_ - 1 : this.queue_.length - 1; + return previousIndex; + } + break; + case RepeatMode.OFF: + if (this.shuffleModeEnabled_) { + const previousIndex = this.shuffleIndex_ - 1; + return previousIndex < 0 ? -1 : this.shuffleOrder_[previousIndex]; + } else { + const previousIndex = this.windowIndex_ - 1; + return previousIndex < 0 ? -1 : previousIndex; + } + break; + default: + throw new Error('illegal state of repeat mode: ' + this.repeatMode_); + } +}; + +/** + * Gets the next window index or a negative number if the current item is the + * last item. + * + * @return {number} The next window index or a negative number if the current + * item is the last item. + */ +Player.prototype.getNextWindowIndex = function() { + if (this.playbackType_ === PlaybackType.UNKNOWN) { + return INDEX_UNSET; + } + switch (this.repeatMode_) { + case RepeatMode.ONE: + return this.windowIndex_; + case RepeatMode.ALL: + if (this.shuffleModeEnabled_) { + const nextIndex = (this.shuffleIndex_ + 1) % this.queue_.length; + return this.shuffleOrder_[nextIndex]; + } else { + return (this.windowIndex_ + 1) % this.queue_.length; + } + break; + case RepeatMode.OFF: + if (this.shuffleModeEnabled_) { + const nextIndex = this.shuffleIndex_ + 1; + return nextIndex < this.shuffleOrder_.length ? + this.shuffleOrder_[nextIndex] : -1; + } else { + const nextIndex = this.windowIndex_ + 1; + return nextIndex < this.queue_.length ? nextIndex : -1; + } + break; + default: + throw new Error('illegal state of repeat mode: ' + this.repeatMode_); + } +}; + +/** + * Gets whether the current window is seekable. + * + * @return {boolean} True if seekable. + */ +Player.prototype.isCurrentWindowSeekable = function() { + return !!this.videoElement_.seekable; +}; + +/** + * Seeks to the positionMs of the media item with the given uuid. + * + * @param {string} uuid The uuid of the media item to seek to. + * @param {number|undefined} positionMs The position in milliseconds to seek to. + * @return {boolean} True if a seek operation has been processed, false + * otherwise. + */ +Player.prototype.seekToUuid = function(uuid, positionMs) { + if (this.playbackState_ === PlaybackState.IDLE) { + this.uuidToPrepare_ = uuid; + this.videoElement_.currentTime = + this.getPosition_(positionMs, INDEX_UNSET) / 1000; + this.invalidate(); + return true; + } + const windowIndex = this.queueUuidIndexMap_[uuid]; + if (windowIndex !== undefined) { + positionMs = this.getPosition_(positionMs, windowIndex); + this.discontinuityReason_ = DiscontinuityReason.SEEK; + this.seekToWindowInternal_(windowIndex, positionMs); + return true; + } + return false; +}; + +/** + * Seeks to the positionMs of the given window. + * + * The index must be a valid index of the current queue, else this method does + * nothing. + * + * @param {number} windowIndex The index of the window to seek to. + * @param {number|undefined} positionMs The position to seek to within the + * window. + */ +Player.prototype.seekToWindow = function(windowIndex, positionMs) { + if (windowIndex < 0 || windowIndex >= this.queue_.length) { + return; + } + this.seekToUuid(this.queue_[windowIndex].uuid, positionMs); +}; + +/** + * Gets the number of media items in the queue. + * + * @return {number} The size of the queue. + */ +Player.prototype.getQueueSize = function() { + return this.queue_.length; +}; + +/** + * Adds an array of items at the given index of the queue. + * + * Items are expected to have been validated with `validation#validateMediaItem` + * or `validation#validateMediaItems` before being passed to this method. + * + * @param {number} index The index where to insert the media item. + * @param {!Array} mediaItems The media items. + * @param {!Array|undefined} shuffleOrder The new shuffle order. + * @return {number} The number of added items. + */ +Player.prototype.addQueueItems = function(index, mediaItems, shuffleOrder) { + if (index < 0 || mediaItems.length === 0) { + return 0; + } + let addedItemCount = 0; + index = Math.min(this.queue_.length, index); + mediaItems.forEach((itemToAdd) => { + if (this.queueUuidIndexMap_[itemToAdd.uuid] === undefined) { + this.queue_.splice(index + addedItemCount, 0, itemToAdd); + this.queueUuidIndexMap_[itemToAdd.uuid] = index + addedItemCount; + addedItemCount++; + } + }); + if (addedItemCount === 0) { + return 0; + } + this.buildUuidIndexMap_(index + addedItemCount); + this.setShuffleOrder_(shuffleOrder); + if (this.queue_.length === addedItemCount) { + this.windowIndex_ = 0; + this.updateShuffleIndex_(); + } else if ( + index <= this.windowIndex_ && + this.playbackType_ !== PlaybackType.UNKNOWN) { + this.windowIndex_ += mediaItems.length; + this.updateShuffleIndex_(); + } + this.invalidate(); + return addedItemCount; +}; + +/** + * Removes the queue items with the given uuids. + * + * @param {!Array} uuids The uuids of the queue items to remove. + * @return {number} The number of items removed from the queue. + */ +Player.prototype.removeQueueItems = function(uuids) { + let currentWindowRemoved = false; + let lowestIndexRemoved = this.queue_.length - 1; + const initialQueueSize = this.queue_.length; + // Sort in descending order to start removing from the end. + uuids = uuids.sort(this.uuidComparator_); + uuids.forEach((uuid) => { + const indexToRemove = this.queueUuidIndexMap_[uuid]; + if (indexToRemove === undefined) { + return; + } + // Remove the item from the queue. + this.queue_.splice(indexToRemove, 1); + // Remove the corresponding media item info. + delete this.mediaItemInfoMap_[uuid]; + // Remove the mapping to the window index. + delete this.queueUuidIndexMap_[uuid]; + lowestIndexRemoved = Math.min(lowestIndexRemoved, indexToRemove); + currentWindowRemoved = + currentWindowRemoved || indexToRemove === this.windowIndex_; + // The window index needs to be decreased when the item which has been + // removed was before the current item, when the current item at the last + // position has been removed, or when the queue has been emptied. + if (indexToRemove < this.windowIndex_ || + (indexToRemove === this.windowIndex_ && + indexToRemove === this.queue_.length) || + this.queue_.length === 0) { + this.windowIndex_--; + } + // Adjust the shuffle order. + let shuffleIndexToRemove; + this.shuffleOrder_.forEach((windowIndex, index) => { + if (windowIndex > indexToRemove) { + // Decrease the index in the shuffle order. + this.shuffleOrder_[index]--; + } else if (windowIndex === indexToRemove) { + // Recall index for removal after traversing. + shuffleIndexToRemove = index; + } + }); + // Remove the shuffle order entry of the removed item. + this.shuffleOrder_.splice(shuffleIndexToRemove, 1); + }); + const removedItemsCount = initialQueueSize - this.queue_.length; + if (removedItemsCount === 0) { + return 0; + } + this.updateShuffleIndex_(); + this.buildUuidIndexMap_(lowestIndexRemoved); + if (currentWindowRemoved) { + if (this.queue_.length === 0) { + this.playbackState_ = this.playbackState_ === PlaybackState.IDLE ? + PlaybackState.IDLE : + PlaybackState.ENDED; + this.windowMediaItemInfo_ = DUMMY_MEDIA_ITEM_INFO; + this.windowPeriodIndex_ = 0; + this.videoElement_.currentTime = 0; + this.uuidToPrepare_ = null; + this.unregisterManifestResponseFilter_(); + this.unload_(/** reinitialiseMediaSource= */ true); + } else if (this.windowIndex_ >= 0) { + const windowIndexToPrepare = this.windowIndex_; + this.windowIndex_ = INDEX_UNSET; + this.seekToWindowInternal_(windowIndexToPrepare, undefined); + return removedItemsCount; + } + } + this.invalidate(); + return removedItemsCount; +}; + +/** + * Move the queue item with the given id to the given position. + * + * @param {string} uuid The uuid of the queue item to move. + * @param {number} to The position to move the item to. + * @param {!Array|undefined} shuffleOrder The new shuffle order. + * @return {boolean} Whether the item has been moved. + */ +Player.prototype.moveQueueItem = function(uuid, to, shuffleOrder) { + if (to < 0 || to >= this.queue_.length) { + return false; + } + const windowIndex = this.queueUuidIndexMap_[uuid]; + if (windowIndex === undefined) { + return false; + } + const itemMoved = this.moveInQueue_(windowIndex, to); + if (itemMoved) { + this.setShuffleOrder_(shuffleOrder); + this.invalidate(); + } + return itemMoved; +}; + +/** + * Prepares the player at the current window index and position. + * + * The playback state immediately transitions to `BUFFERING`. If the queue + * is empty the player transitions to `ENDED`. + */ +Player.prototype.prepare = function() { + if (this.queue_.length === 0) { + this.uuidToPrepare_ = null; + this.playbackState_ = PlaybackState.ENDED; + this.invalidate(); + return; + } + if (this.uuidToPrepare_) { + this.windowIndex_ = + this.queueUuidIndexMap_[this.uuidToPrepare_] || INDEX_UNSET; + this.uuidToPrepare_ = null; + } + this.windowIndex_ = clamp(this.windowIndex_, 0, this.queue_.length - 1); + this.prepare_(this.getCurrentPositionMs()); + this.invalidate(); +}; + +/** + * Stops the player. + * + * Calling this method causes the player to transition into `IDLE` state. + * If `reset` is `true` the player is reset to the initial state of right + * after construction. If `reset` is `false`, the media queue is preserved + * and calling `prepare()` results in resuming the player state to what it + * was before calling `#stop(false)`. + * + * @param {boolean} reset Whether the state should be reset. + * @return {!Promise} A promise which resolves after async unload + * tasks have finished. + */ +Player.prototype.stop = function(reset) { + this.playbackState_ = PlaybackState.IDLE; + this.playbackError_ = null; + this.discontinuityReason_ = null; + this.unregisterManifestResponseFilter_(); + this.uuidToPrepare_ = this.uuidToPrepare_ || (this.queue_[this.windowIndex_] ? + this.queue_[this.windowIndex_].uuid : + null); + if (reset) { + this.uuidToPrepare_ = null; + this.queue_ = []; + this.queueUuidIndexMap_ = {}; + this.uuidComparator_ = createUuidComparator(this.queueUuidIndexMap_); + this.windowIndex_ = INDEX_UNSET; + this.mediaItemInfoMap_ = {}; + this.windowMediaItemInfo_ = DUMMY_MEDIA_ITEM_INFO; + this.windowPeriodIndex_ = 0; + this.videoElement_.currentTime = 0; + this.shuffleOrder_ = []; + this.shuffleIndex_ = 0; + } + this.invalidate(); + return this.unload_(/** reinitialiseMediaSource= */ !reset); +}; + +/** + * Resets player and media element. + * + * @private + * @param {boolean} reinitialiseMediaSource Whether the media source should be + * reinitialized. + * @return {!Promise} A promise which resolves after async unload + * tasks have finished. + */ +Player.prototype.unload_ = function(reinitialiseMediaSource) { + const playbackTypeToUnload = this.playbackType_; + this.playbackType_ = PlaybackType.UNKNOWN; + switch (playbackTypeToUnload) { + case PlaybackType.VIDEO_ELEMENT: + this.videoElement_.removeAttribute('src'); + this.videoElement_.load(); + return Promise.resolve(); + case PlaybackType.SHAKA_PLAYER: + return new Promise((resolve, reject) => { + this.shakaPlayer_.unload(reinitialiseMediaSource) + .then(resolve) + .catch(resolve); + }); + default: + return Promise.resolve(); + } +}; + +/** + * Releases the current Shaka instance and create a new one. + * + * This function should only be called if the Shaka instance is out of order due + * to https://github.com/google/shaka-player/issues/1785. It assumes the current + * Shaka instance has fallen into a state in which promises returned by + * `shakaPlayer.load` and `shakaPlayer.unload` do not resolve nor are they + * rejected anymore. + * + * @private + */ +Player.prototype.replaceShaka_ = function() { + // Remove all listeners. + this.shakaPlayer_.removeEventListener( + ShakaEvent.STREAMING, this.playbackStateListener_); + this.shakaPlayer_.removeEventListener( + ShakaEvent.TRACKS_CHANGED, this.playbackStateListener_); + this.shakaPlayer_.removeEventListener( + ShakaEvent.ERROR, this.shakaErrorHandler_); + // Unregister response filter if any. + this.unregisterManifestResponseFilter_(); + // Unload the old instance. + this.shakaPlayer_.unload(false); + // Reset video element. + this.videoElement_.removeAttribute('src'); + this.videoElement_.load(); + // Create a new instance and add listeners. + this.shakaPlayer_ = new ShakaPlayer(this.videoElement_); + this.shakaPlayer_.addEventListener( + ShakaEvent.STREAMING, this.playbackStateListener_); + this.shakaPlayer_.addEventListener( + ShakaEvent.TRACKS_CHANGED, this.playbackStateListener_); + this.shakaPlayer_.addEventListener(ShakaEvent.ERROR, this.shakaErrorHandler_); +}; + +/** + * Moves a queue item within the queue. + * + * @private + * @param {number} from The initial position. + * @param {number} to The position to move the item to. + * @return {boolean} Whether the item has been moved. + */ +Player.prototype.moveInQueue_ = function(from, to) { + if (from < 0 || to < 0 + || from >= this.queue_.length || to >= this.queue_.length + || from === to) { + return false; + } + this.queue_.splice(to, 0, this.queue_.splice(from, 1)[0]); + this.buildUuidIndexMap_(Math.min(from, to)); + if (from === this.windowIndex_) { + this.windowIndex_ = to; + } else if (from > this.windowIndex_ && to <= this.windowIndex_) { + this.windowIndex_++; + } else if (from < this.windowIndex_ && to >= this.windowIndex_) { + this.windowIndex_--; + } + return true; +}; + +/** + * Shuffles the queue. + * + * @private + */ +Player.prototype.shuffle_ = function() { + this.shuffleOrder_ = this.queue_.map((item, index) => index); + googArray.shuffle(this.shuffleOrder_); + this.updateShuffleIndex_(); +}; + +/** + * Sets the new shuffle order. + * + * @private + * @param {!Array|undefined} shuffleOrder The new shuffle order. + */ +Player.prototype.setShuffleOrder_ = function(shuffleOrder) { + if (shuffleOrder && this.queue_.length === shuffleOrder.length) { + this.shuffleOrder_ = shuffleOrder; + this.updateShuffleIndex_(); + } else if (this.shuffleOrder_.length !== this.queue_.length) { + this.shuffle_(); + } +}; + +/** + * Updates the shuffle order to point to the current window index. + * + * @private + */ +Player.prototype.updateShuffleIndex_ = function() { + this.shuffleIndex_ = + this.shuffleOrder_.findIndex((idx) => idx === this.windowIndex_); +}; + +/** + * Builds the `queueUuidIndexMap` using the uuid of a media item as the key and + * the window index as the value of an entry. + * + * @private + * @param {number} startPosition The window index to start updating at. + */ +Player.prototype.buildUuidIndexMap_ = function(startPosition) { + for (let i = startPosition; i < this.queue_.length; i++) { + this.queueUuidIndexMap_[this.queue_[i].uuid] = i; + } +}; + +/** + * Gets the default position of the current window. + * + * @private + * @return {number} The default position of the current window. + */ +Player.prototype.getDefaultPosition_ = function() { + return this.windowMediaItemInfo_.defaultStartPositionUs; +}; + +/** + * Checks whether the given position is buffered. + * + * @private + * @param {number} positionMs The position to check. + * @return {boolean} true if the media data of the current position is buffered. + */ +Player.prototype.isBuffered_ = function(positionMs) { + const ranges = this.videoElement_.buffered; + for (let i = 0; i < ranges.length; i++) { + const start = ranges.start(i) * 1000; + const end = ranges.end(i) * 1000; + if (start <= positionMs && positionMs <= end) { + return true; + } + } + return false; +}; + +/** + * Seeks to the positionMs of the given window. + * + * To signal a user seek, callers are expected to set the discontinuity reason + * to `DiscontinuityReason.SEEK` before calling this method. If not set this + * method may set the `DiscontinuityReason.PERIOD_TRANSITION` in case the + * `windowIndex` changes. + * + * @private + * @param {number} windowIndex The non-negative index of the window to seek to. + * @param {number|undefined} positionMs The position to seek to within the + * window. If undefined it seeks to the default position of the window. + */ +Player.prototype.seekToWindowInternal_ = function(windowIndex, positionMs) { + const windowChanges = this.windowIndex_ !== windowIndex; + // Update window index and position in any case. + this.windowIndex_ = Math.max(0, windowIndex); + this.updateShuffleIndex_(); + const seekPositionMs = this.getPosition_(positionMs, windowIndex); + this.videoElement_.currentTime = seekPositionMs / 1000; + + // IDLE or ENDED with empty queue. + if (this.playbackState_ === PlaybackState.IDLE || this.queue_.length === 0) { + // Do nothing but report the change in window index and position. + this.invalidate(); + return; + } + + // Prepare for a seek to another window or when in ENDED state whilst the + // queue is not empty but prepare has not been called yet. + if (windowChanges || this.playbackType_ === PlaybackType.UNKNOWN) { + // Reset and prepare. + this.unregisterManifestResponseFilter_(); + this.discontinuityReason_ = + this.discontinuityReason_ || DiscontinuityReason.PERIOD_TRANSITION; + this.prepare_(seekPositionMs); + this.invalidate(); + return; + } + + // Sync playWhenReady with video element after ENDED state. + if (this.playbackState_ === PlaybackState.ENDED && this.playWhenReady_) { + this.videoElement_.play(); + return; + } + + // A seek within the current window when READY or BUFFERING. + this.playbackState_ = this.isBuffered_(seekPositionMs) ? + PlaybackState.READY : + PlaybackState.BUFFERING; + this.invalidate(); +}; + +/** + * Prepares the player at the current window index and the given + * `startPositionMs`. + * + * Calling this method resets the media item information, transitions to + * 'BUFFERING', prepares either the plain video element for progressive + * media, or the Shaka player for adaptive media. + * + * Media items are mapped by media type to a `PlaybackType`s in + * `exoplayer.cast.constants.SupportedMediaTypes`. Unsupported mime types will + * cause the player to transition to the `IDLE` state. + * + * Items in the queue are expected to have been validated with + * `validation#validateMediaItem` or `validation#validateMediaItems`. If this is + * not the case this method might throw an Assertion exception. + * + * @private + * @param {number} startPositionMs The position at which to start playback. + * @throws {!AssertionException} In case an unvalidated item can't be mapped to + * a supported playback type. + */ +Player.prototype.prepare_ = function(startPositionMs) { + const mediaItem = this.queue_[this.windowIndex_]; + const windowUuid = this.queue_[this.windowIndex_].uuid; + const mediaItemInfo = this.mediaItemInfoMap_[windowUuid]; + if (mediaItemInfo && !mediaItemInfo.isDynamic) { + // Do reuse if not dynamic. + this.windowMediaItemInfo_ = mediaItemInfo; + } else { + // Use the dummy info until manifest/data available. + this.windowMediaItemInfo_ = DUMMY_MEDIA_ITEM_INFO; + this.mediaItemInfoMap_[windowUuid] = DUMMY_MEDIA_ITEM_INFO; + } + this.windowPeriodIndex_ = 0; + this.playbackType_ = getPlaybackType(mediaItem.mimeType); + this.playbackState_ = PlaybackState.BUFFERING; + const uri = mediaItem.media.uri; + switch (this.playbackType_) { + case PlaybackType.VIDEO_ELEMENT: + this.videoElement_.currentTime = startPositionMs / 1000; + this.shakaPlayer_.unload(false) + .then(() => { + this.setMediaElementSrc(uri); + this.videoElement_.currentTime = startPositionMs / 1000; + }) + .catch((error) => { + // Let's still try. We actually don't need Shaka right now. + this.setMediaElementSrc(uri); + this.videoElement_.currentTime = startPositionMs / 1000; + console.error('Shaka error while unloading', error); + }); + break; + case PlaybackType.SHAKA_PLAYER: + this.shakaPlayer_.configure( + this.configurationFactory_.createConfiguration( + mediaItem, this.trackSelectionParameters_)); + this.shakaPlayer_.load(uri, startPositionMs / 1000).catch((error) => { + const shakaError = /** @type {!ShakaError} */ (error); + if (shakaError.severity !== ShakaError.Severity.RECOVERABLE && + shakaError.code !== ShakaError.Code.LOAD_INTERRUPTED) { + this.fatalShakaError_(shakaError, 'loading failed for uri: ' + uri); + this.invalidate(); + } else { + console.error('Recoverable Shaka error while loading', shakaError); + } + }); + break; + default: + fail('unknown playback type for mime type: ' + mediaItem.mimeType); + } +}; + +/** + * Sets the uri to the `src` attribute of the media element in a safe way. + * + * @param {string} uri The uri to set as the value of the `src` attribute. + */ +Player.prototype.setMediaElementSrc = function(uri) { + safedom.setVideoSrc( + asserts.assertIsHTMLVideoElement(this.videoElement_), uri); +}; + +/** + * Handles a fatal Shaka error by setting the playback error, transitioning to + * state `IDLE` and setting the playback type to `UNKNOWN`. Player needs to be + * reprepared after calling this method. + * + * @private + * @param {!ShakaError} shakaError The error. + * @param {string|undefined} customMessage A custom message. + */ +Player.prototype.fatalShakaError_ = function(shakaError, customMessage) { + this.playbackState_ = PlaybackState.IDLE; + this.playbackType_ = PlaybackType.UNKNOWN; + this.uuidToPrepare_ = this.queue_[this.windowIndex_] ? + this.queue_[this.windowIndex_].uuid : + null; + if (typeof shakaError.severity === 'undefined') { + // Not a Shaka error. We need to assume the worst case. + this.replaceShaka_(); + this.playbackError_ = /** @type {!PlayerError} */ ({ + message: ErrorMessages.UNKNOWN_FATAL_ERROR, + code: -1, + category: ErrorCategory.FATAL_SHAKA_ERROR, + }); + } else { + // A critical ShakaError. Can be recovered from by calling prepare. + this.playbackError_ = /** @type {!PlayerError} */ ({ + message: customMessage || shakaError.message || + ErrorMessages.SHAKA_UNKNOWN_ERROR, + code: shakaError.code, + category: shakaError.category, + }); + } + console.error('caught shaka load error', shakaError); +}; + +/** + * Gets the position to use. If `undefined` or `null` is passed as argument the + * default start position of the media item info of the given windowIndex is + * returned. + * + * @private + * @param {?number|undefined} positionMs The position in milliseconds, + * `undefined` or `null`. + * @param {number} windowIndex The window index for which to evaluate the + * position. + * @return {number} The position to use in milliseconds. + */ +Player.prototype.getPosition_ = function(positionMs, windowIndex) { + if (positionMs !== undefined) { + return Math.max(0, positionMs); + } + const windowUuid = assert(this.queue_[windowIndex]).uuid; + const mediaItemInfo = + this.mediaItemInfoMap_[windowUuid] || DUMMY_MEDIA_ITEM_INFO; + return mediaItemInfo.defaultStartPositionUs; +}; + +/** + * Refreshes the media item info of the current window. + * + * @private + */ +Player.prototype.updateWindowMediaItemInfo_ = function() { + this.windowMediaItemInfo_ = this.buildMediaItemInfo_(); + if (this.windowMediaItemInfo_) { + const mediaItem = this.queue_[this.windowIndex_]; + this.mediaItemInfoMap_[mediaItem.uuid] = this.windowMediaItemInfo_; + this.evaluateAndSetCurrentPeriod_(this.windowMediaItemInfo_.periods); + } +}; + +/** + * Evaluates the current period and stores it in a member variable. + * + * @private + * @param {!Array} periods The periods of the current mediaItem. + */ +Player.prototype.evaluateAndSetCurrentPeriod_ = function(periods) { + const positionUs = this.getCurrentPositionMs() * 1000; + let positionInWindowUs = 0; + periods.some((period, i) => { + positionInWindowUs += period.durationUs; + if (positionUs < positionInWindowUs) { + this.windowPeriodIndex_ = i; + return true; + } + return false; + }); +}; + +/** + * Registers a response filter which is notified when a manifest has been + * downloaded. + * + * @private + */ +Player.prototype.registerManifestResponseFilter_ = function() { + if (this.isManifestFilterRegistered_) { + return; + } + this.shakaPlayer_.getNetworkingEngine().registerResponseFilter( + this.manifestResponseFilter_); + this.isManifestFilterRegistered_ = true; +}; + +/** + * Unregisters the manifest response filter. + * + * @private + */ +Player.prototype.unregisterManifestResponseFilter_ = function() { + if (this.isManifestFilterRegistered_) { + this.shakaPlayer_.getNetworkingEngine().unregisterResponseFilter( + this.manifestResponseFilter_); + this.isManifestFilterRegistered_ = false; + } +}; + +/** + * Builds a MediaItemInfo from the media element. + * + * @private + * @return {!MediaItemInfo} A media item info. + */ +Player.prototype.buildMediaItemInfoFromElement_ = function() { + const durationUs = this.videoElement_.duration * 1000 * 1000; + return /** @type {!MediaItemInfo} */ ({ + isSeekable: !!this.videoElement_.seekable, + isDynamic: false, + positionInFirstPeriodUs: 0, + defaultStartPositionUs: 0, + windowDurationUs: durationUs, + periods: [{ + id: 0, + durationUs: durationUs, + }], + }); +}; + +/** + * Builds a MediaItemInfo from the manifest or null if no manifest is available. + * + * @private + * @return {!MediaItemInfo} + */ +Player.prototype.buildMediaItemInfo_ = function() { + const manifest = this.shakaPlayer_.getManifest(); + if (manifest === null) { + return DUMMY_MEDIA_ITEM_INFO; + } + const timeline = manifest.presentationTimeline; + const isDynamic = timeline.isLive(); + const windowStartUs = isDynamic ? + timeline.getSeekRangeStart() * 1000 * 1000 : + timeline.getSegmentAvailabilityStart() * 1000 * 1000; + const windowDurationUs = isDynamic ? + (timeline.getSeekRangeEnd() - timeline.getSeekRangeStart()) * 1000 * + 1000 : + timeline.getDuration() * 1000 * 1000; + const defaultStartPositionUs = isDynamic ? + timeline.getSeekRangeEnd() * 1000 * 1000 : + timeline.getSegmentAvailabilityStart() * 1000 * 1000; + + const periods = []; + let previousStartTimeUs = 0; + let positionInFirstPeriodUs = 0; + manifest.periods.forEach((period, index) => { + const startTimeUs = period.startTime * 1000 * 1000; + periods.push({ + id: Math.floor(startTimeUs), + }); + if (index > 0) { + // calculate duration of previous period + periods[index - 1].durationUs = startTimeUs - previousStartTimeUs; + if (previousStartTimeUs <= windowStartUs && windowStartUs < startTimeUs) { + positionInFirstPeriodUs = windowStartUs - previousStartTimeUs; + } + } + previousStartTimeUs = startTimeUs; + }); + // calculate duration of last period + if (periods.length) { + const lastPeriodDurationUs = + isDynamic ? Infinity : windowDurationUs - previousStartTimeUs; + periods.slice(-1)[0].durationUs = lastPeriodDurationUs; + if (previousStartTimeUs <= windowStartUs) { + positionInFirstPeriodUs = windowStartUs - previousStartTimeUs; + } + } + return /** @type {!MediaItemInfo} */ ({ + windowDurationUs: Math.floor(windowDurationUs), + defaultStartPositionUs: Math.floor(defaultStartPositionUs), + isSeekable: this.videoElement_ ? !!this.videoElement_.seekable : false, + positionInFirstPeriodUs: Math.floor(positionInFirstPeriodUs), + isDynamic: isDynamic, + periods: periods, + }); +}; + +/** + * Builds the player state message. + * + * @private + * @return {!PlayerState} The player state. + */ +Player.prototype.buildPlayerState_ = function() { + const playerState = { + playbackState: this.getPlaybackState(), + playbackParameters: { + speed: 1, + pitch: 1, + skipSilence: false, + }, + playbackPosition: this.buildPlaybackPosition_(), + playWhenReady: this.getPlayWhenReady(), + windowIndex: this.getCurrentWindowIndex(), + windowCount: this.queue_.length, + audioTracks: this.getAudioTracks() || [], + videoTracks: this.getVideoTracks(), + repeatMode: this.repeatMode_, + shuffleModeEnabled: this.shuffleModeEnabled_, + mediaQueue: this.queue_.slice(), + mediaItemsInfo: this.mediaItemInfoMap_, + shuffleOrder: this.shuffleOrder_, + sequenceNumber: -1, + }; + if (this.playbackError_) { + playerState.error = this.playbackError_; + this.playbackError_ = null; + } + return playerState; +}; + +/** + * Builds the playback position. Returns null if all properties of the playback + * position are empty. + * + * @private + * @return {?PlaybackPosition} The playback position. + */ +Player.prototype.buildPlaybackPosition_ = function() { + if ((this.playbackState_ === PlaybackState.IDLE && !this.uuidToPrepare_) || + this.playbackState_ === PlaybackState.ENDED && this.queue_.length === 0) { + this.discontinuityReason_ = null; + return null; + } + /** @type {!PlaybackPosition} */ + const playbackPosition = { + positionMs: this.getCurrentPositionMs(), + uuid: this.uuidToPrepare_ || this.queue_[this.windowIndex_].uuid, + periodId: this.windowMediaItemInfo_.periods[this.windowPeriodIndex_].id, + discontinuityReason: null, + }; + if (this.discontinuityReason_ !== null) { + playbackPosition.discontinuityReason = this.discontinuityReason_; + this.discontinuityReason_ = null; + } + return playbackPosition; +}; + +exports = Player; +exports.RepeatMode = RepeatMode; +exports.PlaybackState = PlaybackState; +exports.DiscontinuityReason = DiscontinuityReason; +exports.DUMMY_MEDIA_ITEM_INFO = DUMMY_MEDIA_ITEM_INFO; diff --git a/cast_receiver_app/src/timeout.js b/cast_receiver_app/src/timeout.js new file mode 100644 index 0000000000..e5df5ec2f4 --- /dev/null +++ b/cast_receiver_app/src/timeout.js @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2019 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. + */ + +goog.module('exoplayer.cast.Timeout'); + +/** + * A timeout which can be cancelled. + */ +class Timeout { + constructor() { + /** @private {?number} */ + this.timeout_ = null; + } + /** + * Returns a promise which resolves when the duration of time defined by + * delayMs has elapsed and cancel() has not been called earlier. + * + * If the timeout is already set, the former timeout is cancelled and a new + * one is started. + * + * @param {number} delayMs The delay after which to resolve or a non-positive + * value if it should never resolve. + * @return {!Promise} Resolves after the given delayMs or never + * for a non-positive delay. + */ + postDelayed(delayMs) { + this.cancel(); + return new Promise((resolve, reject) => { + if (delayMs <= 0) { + return; + } + this.timeout_ = setTimeout(() => { + if (this.timeout_) { + this.timeout_ = null; + resolve(); + } + }, delayMs); + }); + } + + /** Cancels the timeout. */ + cancel() { + if (this.timeout_) { + clearTimeout(this.timeout_); + this.timeout_ = null; + } + } + + /** @return {boolean} true if the timeout is currently ongoing. */ + isOngoing() { + return this.timeout_ !== null; + } +} + +exports = Timeout; diff --git a/cast_receiver_app/src/util.js b/cast_receiver_app/src/util.js new file mode 100644 index 0000000000..75afd9e5d3 --- /dev/null +++ b/cast_receiver_app/src/util.js @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2018 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. + */ + +goog.module('exoplayer.cast.util'); + +/** + * Indicates whether the logging is turned on. + */ +const enableLogging = true; + +/** + * Logs to the console if logging enabled. + * + * @param {!Array<*>} statements The log statements to be logged. + */ +const log = function(statements) { + if (enableLogging) { + console.log.apply(console, statements); + } +}; + +/** + * A comparator function for uuids. + * + * @typedef {function(string,string):number} + */ +let UuidComparator; + +/** + * Creates a comparator function which sorts uuids in descending order by the + * corresponding index of the given map. + * + * @param {!Object} uuidIndexMap The map with uuids as the key + * and the window index as the value. + * @return {!UuidComparator} The comparator for sorting. + */ +const createUuidComparator = function(uuidIndexMap) { + return (a, b) => { + const indexA = uuidIndexMap[a] || -1; + const indexB = uuidIndexMap[b] || -1; + return indexB - indexA; + }; +}; + +exports = { + log, + createUuidComparator, + UuidComparator, +}; diff --git a/cast_receiver_app/test/caf_bootstrap.js b/cast_receiver_app/test/caf_bootstrap.js new file mode 100644 index 0000000000..721360e8a7 --- /dev/null +++ b/cast_receiver_app/test/caf_bootstrap.js @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2019 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. + */ + +/** + * @fileoverview Declares constants which are provided by the CAF externs and + * are not included in uncompiled unit tests. + */ +cast = { + framework: { + system: { + EventType: { + SENDER_CONNECTED: 'sender_connected', + SENDER_DISCONNECTED: 'sender_disconnected', + }, + DisconnectReason: { + REQUESTED_BY_SENDER: 'requested_by_sender', + }, + }, + }, +}; diff --git a/cast_receiver_app/test/configuration_factory_test.js b/cast_receiver_app/test/configuration_factory_test.js new file mode 100644 index 0000000000..af9254c59e --- /dev/null +++ b/cast_receiver_app/test/configuration_factory_test.js @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2019 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. + */ + +goog.module('exoplayer.cast.test.configurationfactory'); +goog.setTestOnly(); + +const ConfigurationFactory = goog.require('exoplayer.cast.ConfigurationFactory'); +const testSuite = goog.require('goog.testing.testSuite'); +const util = goog.require('exoplayer.cast.test.util'); + +let configurationFactory; + +testSuite({ + setUp() { + configurationFactory = new ConfigurationFactory(); + }, + + /** Tests creating the most basic configuration. */ + testCreateBasicConfiguration() { + /** @type {!TrackSelectionParameters} */ + const selectionParameters = /** @type {!TrackSelectionParameters} */ ({ + preferredAudioLanguage: 'en', + preferredTextLanguage: 'it', + }); + const configuration = configurationFactory.createConfiguration( + util.queue.slice(0, 1), selectionParameters); + assertEquals('en', configuration.preferredAudioLanguage); + assertEquals('it', configuration.preferredTextLanguage); + // Assert empty drm configuration as default. + assertArrayEquals(['servers'], Object.keys(configuration.drm)); + assertArrayEquals([], Object.keys(configuration.drm.servers)); + }, + + /** Tests defaults for undefined audio and text languages. */ + testCreateBasicConfiguration_languagesUndefined() { + const configuration = configurationFactory.createConfiguration( + util.queue.slice(0, 1), /** @type {!TrackSelectionParameters} */ ({})); + assertEquals('', configuration.preferredAudioLanguage); + assertEquals('', configuration.preferredTextLanguage); + }, + + /** Tests creating a drm configuration */ + testCreateDrmConfiguration() { + /** @type {!MediaItem} */ + const mediaItem = util.queue[1]; + mediaItem.drmSchemes = [ + { + uuid: 'edef8ba9-79d6-4ace-a3c8-27dcd51d21ed', + licenseServer: { + uri: 'drm-uri0', + }, + }, + { + uuid: '9a04f079-9840-4286-ab92-e65be0885f95', + licenseServer: { + uri: 'drm-uri1', + }, + }, + { + uuid: 'unsupported-drm-uuid', + licenseServer: { + uri: 'drm-uri2', + }, + }, + ]; + const configuration = + configurationFactory.createConfiguration(mediaItem, {}); + assertEquals('drm-uri0', configuration.drm.servers['com.widevine.alpha']); + assertEquals( + 'drm-uri1', configuration.drm.servers['com.microsoft.playready']); + assertEquals(2, Object.entries(configuration.drm.servers).length); + } +}); diff --git a/cast_receiver_app/test/externs.js b/cast_receiver_app/test/externs.js new file mode 100644 index 0000000000..a90a367691 --- /dev/null +++ b/cast_receiver_app/test/externs.js @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2019 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. + */ + +/** + * Externs for unit tests to avoid renaming of properties. + * + * These externs are only required when building with bazel because the + * closure_js_test compiles tests as well. + * + * @externs + */ + +/** @record */ +function ValidationObject() {} + +/** @type {*} */ +ValidationObject.prototype.field; + +/** @record */ +function Uuids() {} + +/** @type {!Array} */ +Uuids.prototype.uuids; diff --git a/cast_receiver_app/test/message_dispatcher_test.js b/cast_receiver_app/test/message_dispatcher_test.js new file mode 100644 index 0000000000..3e7daaf573 --- /dev/null +++ b/cast_receiver_app/test/message_dispatcher_test.js @@ -0,0 +1,49 @@ +/** + * Copyright (C) 2019 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. + * + * @fileoverview Unit tests for the message dispatcher. + */ + +goog.module('exoplayer.cast.test.messagedispatcher'); +goog.setTestOnly(); + +const MessageDispatcher = goog.require('exoplayer.cast.MessageDispatcher'); +const mocks = goog.require('exoplayer.cast.test.mocks'); +const testSuite = goog.require('goog.testing.testSuite'); + +let contextMock; +let messageDispatcher; + +testSuite({ + setUp() { + mocks.setUp(); + contextMock = mocks.createCastReceiverContextFake(); + messageDispatcher = new MessageDispatcher( + 'urn:x-cast:com.google.exoplayer.cast', contextMock); + }, + + /** Test marshalling Infinity */ + testStringifyInfinity() { + const senderId = 'sender0'; + const name = 'Federico Vespucci'; + messageDispatcher.send(senderId, {name: name, duration: Infinity}); + + const msg = mocks.state().outputMessages[senderId][0]; + assertUndefined(msg.duration); + assertFalse(msg.hasOwnProperty('duration')); + assertEquals(name, msg.name); + assertTrue(msg.hasOwnProperty('name')); + } +}); diff --git a/cast_receiver_app/test/mocks.js b/cast_receiver_app/test/mocks.js new file mode 100644 index 0000000000..244ac72829 --- /dev/null +++ b/cast_receiver_app/test/mocks.js @@ -0,0 +1,277 @@ +/** + * Copyright (C) 2018 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. + * + * @fileoverview Mocks for testing cast components. + */ + +goog.module('exoplayer.cast.test.mocks'); +goog.setTestOnly(); + +const NetworkingEngine = goog.require('shaka.net.NetworkingEngine'); + +let mockState; +let manifest; + +/** + * Initializes the state of the mocks. Needs to be called in the setUp method of + * the unit test. + */ +const setUp = function() { + mockState = { + outputMessages: {}, + listeners: {}, + loadedUri: null, + preferredTextLanguage: '', + preferredAudioLanguage: '', + configuration: null, + responseFilter: null, + isSilent: false, + customMessageListener: undefined, + mediaElementState: { + removedAttributes: [], + }, + manifestState: { + isLive: false, + windowDuration: 20, + startTime: 0, + delay: 10, + }, + getManifest: () => manifest, + setManifest: (m) => { + manifest = m; + }, + shakaError: { + severity: /** CRITICAL */ 2, + code: /** not 7000 (LOAD_INTERUPTED) */ 3, + category: /** any */ 1, + }, + simulateLoad: simulateLoadSuccess, + /** @type {function(boolean)} */ + setShakaThrowsOnLoad: (doThrow) => { + mockState.simulateLoad = doThrow ? throwShakaError : simulateLoadSuccess; + }, + simulateUnload: simulateUnloadSuccess, + /** @type {function(boolean)} */ + setShakaThrowsOnUnload: (doThrow) => { + mockState.simulateUnload = + doThrow ? throwShakaError : simulateUnloadSuccess; + }, + onSenderConnected: undefined, + onSenderDisconnected: undefined, + }; + manifest = { + periods: [{startTime: mockState.manifestState.startTime}], + presentationTimeline: { + getDuration: () => mockState.manifestState.windowDuration, + isLive: () => mockState.manifestState.isLive, + getSegmentAvailabilityStart: () => 0, + getSegmentAvailabilityEnd: () => mockState.manifestState.windowDuration, + getSeekRangeStart: () => 0, + getSeekRangeEnd: () => mockState.manifestState.windowDuration - + mockState.manifestState.delay, + }, + }; +}; + +/** + * Simulates a successful `shakaPlayer.load` call. + * + * @param {string} uri The uri to load. + */ +const simulateLoadSuccess = (uri) => { + mockState.loadedUri = uri; + notifyListeners('streaming'); +}; + +/** Simulates a successful `shakaPlayer.unload` call. */ +const simulateUnloadSuccess = () => { + mockState.loadedUri = undefined; + notifyListeners('unloading'); +}; + +/** @throws {!ShakaError} Thrown in any case. */ +const throwShakaError = () => { + throw mockState.shakaError; +}; + + +/** + * Adds a fake event listener. + * + * @param {string} type The type of the listener. + * @param {function(!Object)} listener The callback listener. + */ +const addEventListener = function(type, listener) { + mockState.listeners[type] = mockState.listeners[type] || []; + mockState.listeners[type].push(listener); +}; + +/** + * Notifies the fake listeners of the given type. + * + * @param {string} type The type of the listener to notify. + */ +const notifyListeners = function(type) { + if (mockState.isSilent || !mockState.listeners[type]) { + return; + } + for (let i = 0; i < mockState.listeners[type].length; i++) { + mockState.listeners[type][i]({ + type: type + }); + } +}; + +/** + * Creates an observable for which listeners can be added. + * + * @return {!Object} An observable object. + */ +const createObservable = () => { + return { + addEventListener: (type, listener) => { + addEventListener(type, listener); + }, + }; +}; + +/** + * Creates a fake for the shaka player. + * + * @return {!shaka.Player} A shaka player mock object. + */ +const createShakaFake = () => { + const shakaFake = /** @type {!shaka.Player} */(createObservable()); + const mediaElement = createMediaElementFake(); + /** + * @return {!HTMLMediaElement} A media element. + */ + shakaFake.getMediaElement = () => mediaElement; + shakaFake.getAudioLanguages = () => []; + shakaFake.getVariantTracks = () => []; + shakaFake.configure = (configuration) => { + mockState.configuration = configuration; + return true; + }; + shakaFake.selectTextLanguage = (language) => { + mockState.preferredTextLanguage = language; + }; + shakaFake.selectAudioLanguage = (language) => { + mockState.preferredAudioLanguage = language; + }; + shakaFake.getManifest = () => manifest; + shakaFake.unload = async () => mockState.simulateUnload(); + shakaFake.load = async (uri) => mockState.simulateLoad(uri); + shakaFake.getNetworkingEngine = () => { + return /** @type {!NetworkingEngine} */ ({ + registerResponseFilter: (responseFilter) => { + mockState.responseFilter = responseFilter; + }, + unregisterResponseFilter: (responseFilter) => { + if (mockState.responseFilter !== responseFilter) { + throw new Error('unregistering invalid response filter'); + } else { + mockState.responseFilter = null; + } + }, + }); + }; + return shakaFake; +}; + +/** + * Creates a fake for a media element. + * + * @return {!HTMLMediaElement} A media element fake. + */ +const createMediaElementFake = () => { + const mediaElementFake = /** @type {!HTMLMediaElement} */(createObservable()); + mediaElementFake.load = () => { + // Do nothing. + }; + mediaElementFake.play = () => { + mediaElementFake.paused = false; + notifyListeners('playing'); + return Promise.resolve(); + }; + mediaElementFake.pause = () => { + mediaElementFake.paused = true; + notifyListeners('pause'); + }; + mediaElementFake.seekable = /** @type {!TimeRanges} */({ + length: 1, + start: (index) => mockState.manifestState.startTime, + end: (index) => mockState.manifestState.windowDuration, + }); + mediaElementFake.removeAttribute = (name) => { + mockState.mediaElementState.removedAttributes.push(name); + if (name === 'src') { + mockState.loadedUri = null; + } + }; + mediaElementFake.hasAttribute = (name) => { + return name === 'src' && !!mockState.loadedUri; + }; + mediaElementFake.buffered = /** @type {!TimeRanges} */ ({ + length: 0, + start: (index) => null, + end: (index) => null, + }); + mediaElementFake.paused = true; + return mediaElementFake; +}; + +/** + * Creates a cast receiver manager fake. + * + * @return {!Object} A cast receiver manager fake. + */ +const createCastReceiverContextFake = () => { + return { + addCustomMessageListener: (namespace, listener) => { + mockState.customMessageListener = listener; + }, + sendCustomMessage: (namespace, senderId, message) => { + mockState.outputMessages[senderId] = + mockState.outputMessages[senderId] || []; + mockState.outputMessages[senderId].push(message); + }, + addEventListener: (eventName, listener) => { + switch (eventName) { + case 'sender_connected': + mockState.onSenderConnected = listener; + break; + case 'sender_disconnected': + mockState.onSenderDisconnected = listener; + break; + } + }, + getSenders: () => [{id: 'sender0'}], + start: () => {}, + }; +}; + +/** + * Returns the state of the mocks. + * + * @return {?Object} + */ +const state = () => mockState; + +exports.createCastReceiverContextFake = createCastReceiverContextFake; +exports.createShakaFake = createShakaFake; +exports.notifyListeners = notifyListeners; +exports.setUp = setUp; +exports.state = state; diff --git a/cast_receiver_app/test/playback_info_view_test.js b/cast_receiver_app/test/playback_info_view_test.js new file mode 100644 index 0000000000..87cefe1884 --- /dev/null +++ b/cast_receiver_app/test/playback_info_view_test.js @@ -0,0 +1,242 @@ +/** + * Copyright (C) 2019 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. + * + * @fileoverview Unit tests for the playback info view. + */ + +goog.module('exoplayer.cast.test.PlaybackInfoView'); +goog.setTestOnly(); + +const PlaybackInfoView = goog.require('exoplayer.cast.PlaybackInfoView'); +const Player = goog.require('exoplayer.cast.Player'); +const testSuite = goog.require('goog.testing.testSuite'); + +/** The state of the player mock */ +let mockState; + +/** + * Initializes the state of the mock. Needs to be called in the setUp method of + * the unit test. + */ +const setUpMockState = function() { + mockState = { + playWhenReady: false, + currentPositionMs: 1000, + durationMs: 10 * 1000, + playbackState: 'READY', + discontinuityReason: undefined, + listeners: [], + currentMediaItem: { + mimeType: 'video/*', + }, + }; +}; + +/** Notifies registered listeners with the current player state. */ +const notifyListeners = function() { + if (!mockState) { + console.warn( + 'mock state not initialized. Did you call setUp ' + + 'when setting up the test case?'); + } + mockState.listeners.forEach((listener) => { + listener({ + playWhenReady: mockState.playWhenReady, + playbackState: mockState.playbackState, + playbackPosition: { + currentPositionMs: mockState.currentPositionMs, + discontinuityReason: mockState.discontinuityReason, + }, + }); + }); +}; + +/** + * Creates a sufficient mock of the Player. + * + * @return {!Player} + */ +const createPlayerMock = function() { + return /** @type {!Player} */ ({ + addPlayerListener: (listener) => { + mockState.listeners.push(listener); + }, + getPlayWhenReady: () => mockState.playWhenReady, + getPlaybackState: () => mockState.playbackState, + getCurrentPositionMs: () => mockState.currentPositionMs, + getDurationMs: () => mockState.durationMs, + getCurrentMediaItem: () => mockState.currentMediaItem, + }); +}; + +/** Inserts the DOM structure the playback info view needs. */ +const insertComponentDom = function() { + const container = appendChild(document.body, 'div', 'container-id'); + appendChild(container, 'div', 'exo_elapsed_time'); + appendChild(container, 'div', 'exo_elapsed_time_label'); + appendChild(container, 'div', 'exo_duration_label'); +}; + +/** + * Creates and appends a child to the parent element. + * + * @param {!Element} parent The parent element. + * @param {string} tagName The tag name of the child element. + * @param {string} id The id of the child element. + * @return {!Element} The appended child element. + */ +const appendChild = function(parent, tagName, id) { + const child = document.createElement(tagName); + child.id = id; + parent.appendChild(child); + return child; +}; + +/** Removes the inserted elements from the DOM again. */ +const removeComponentDom = function() { + const container = document.getElementById('container-id'); + if (container) { + container.parentNode.removeChild(container); + } +}; + +let playbackInfoView; + +testSuite({ + setUp() { + insertComponentDom(); + setUpMockState(); + playbackInfoView = new PlaybackInfoView( + createPlayerMock(), /** containerId= */ 'container-id'); + playbackInfoView.setShowTimeoutMs(1); + }, + + tearDown() { + removeComponentDom(); + }, + + /** Tests setting the show timeout. */ + testSetShowTimeout() { + assertEquals(1, playbackInfoView.showTimeoutMs_); + playbackInfoView.setShowTimeoutMs(10); + assertEquals(10, playbackInfoView.showTimeoutMs_); + }, + + /** Tests rendering the duration to the DOM. */ + testRenderDuration() { + const el = document.getElementById('exo_duration_label'); + assertEquals('00:10', el.firstChild.firstChild.nodeValue); + mockState.durationMs = 35 * 1000; + notifyListeners(); + assertEquals('00:35', el.firstChild.firstChild.nodeValue); + + mockState.durationMs = + (12 * 60 * 60 * 1000) + (20 * 60 * 1000) + (13 * 1000); + notifyListeners(); + assertEquals('12:20:13', el.firstChild.firstChild.nodeValue); + + mockState.durationMs = -1000; + notifyListeners(); + assertNull(el.nodeValue); + }, + + /** Tests rendering the playback position to the DOM. */ + testRenderPlaybackPosition() { + const el = document.getElementById('exo_elapsed_time_label'); + assertEquals('00:01', el.firstChild.firstChild.nodeValue); + mockState.currentPositionMs = 2000; + notifyListeners(); + assertEquals('00:02', el.firstChild.firstChild.nodeValue); + + mockState.currentPositionMs = + (12 * 60 * 60 * 1000) + (20 * 60 * 1000) + (13 * 1000); + notifyListeners(); + assertEquals('12:20:13', el.firstChild.firstChild.nodeValue); + + mockState.currentPositionMs = -1000; + notifyListeners(); + assertNull(el.nodeValue); + + mockState.currentPositionMs = 0; + notifyListeners(); + assertEquals('00:00', el.firstChild.firstChild.nodeValue); + }, + + /** Tests rendering the timebar width reflects position and duration. */ + testRenderTimebar() { + const el = document.getElementById('exo_elapsed_time'); + assertEquals('10%', el.style.width); + + mockState.currentPositionMs = 0; + notifyListeners(); + assertEquals('0px', el.style.width); + + mockState.currentPositionMs = 5 * 1000; + notifyListeners(); + assertEquals('50%', el.style.width); + + mockState.currentPositionMs = mockState.durationMs * 2; + notifyListeners(); + assertEquals('100%', el.style.width); + + mockState.currentPositionMs = -1; + notifyListeners(); + assertEquals('0px', el.style.width); + }, + + /** Tests whether the update timeout is set and removed. */ + testUpdateTimeout_setAndRemoved() { + assertFalse(playbackInfoView.updateTimeout_.isOngoing()); + + mockState.playWhenReady = true; + notifyListeners(); + assertTrue(playbackInfoView.updateTimeout_.isOngoing()); + + mockState.playWhenReady = false; + notifyListeners(); + assertFalse(playbackInfoView.updateTimeout_.isOngoing()); + }, + + /** Tests whether the show timeout is set when playback starts. */ + testHideTimeout_setAndRemoved() { + assertFalse(playbackInfoView.hideTimeout_.isOngoing()); + + mockState.playWhenReady = true; + notifyListeners(); + assertNotUndefined(playbackInfoView.hideTimeout_); + assertTrue(playbackInfoView.hideTimeout_.isOngoing()); + + mockState.playWhenReady = false; + notifyListeners(); + assertFalse(playbackInfoView.hideTimeout_.isOngoing()); + }, + + /** Test whether the view switches to always on for audio media. */ + testAlwaysOnForAudio() { + playbackInfoView.setShowTimeoutMs(50); + assertEquals(50, playbackInfoView.showTimeoutMs_); + // The player transitions from video to audio stream. + mockState.discontinuityReason = 'PERIOD_TRANSITION'; + mockState.currentMediaItem.mimeType = 'audio/*'; + notifyListeners(); + assertEquals(0, playbackInfoView.showTimeoutMs_); + + mockState.discontinuityReason = 'PERIOD_TRANSITION'; + mockState.currentMediaItem.mimeType = 'video/*'; + notifyListeners(); + assertEquals(50, playbackInfoView.showTimeoutMs_); + }, + +}); diff --git a/cast_receiver_app/test/player_test.js b/cast_receiver_app/test/player_test.js new file mode 100644 index 0000000000..96dfbf8614 --- /dev/null +++ b/cast_receiver_app/test/player_test.js @@ -0,0 +1,470 @@ +/** + * Copyright (C) 2018 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. + * + * @fileoverview Unit tests for playback methods. + */ + +goog.module('exoplayer.cast.test'); +goog.setTestOnly(); + +const ConfigurationFactory = goog.require('exoplayer.cast.ConfigurationFactory'); +const Player = goog.require('exoplayer.cast.Player'); +const mocks = goog.require('exoplayer.cast.test.mocks'); +const testSuite = goog.require('goog.testing.testSuite'); +const util = goog.require('exoplayer.cast.test.util'); + +let player; +let shakaFake; + +testSuite({ + setUp() { + mocks.setUp(); + shakaFake = mocks.createShakaFake(); + player = new Player(shakaFake, new ConfigurationFactory()); + }, + + /** Tests the player initialisation */ + testPlayerInitialisation() { + mocks.state().isSilent = true; + const states = []; + let stateCounter = 0; + let currentState; + player.addPlayerListener((playerState) => { + states.push(playerState); + }); + + // Dump the initial state manually. + player.invalidate(); + stateCounter++; + assertEquals(stateCounter, states.length); + currentState = states[stateCounter - 1]; + assertEquals(0, currentState.mediaQueue.length); + assertEquals(0, currentState.windowIndex); + assertNull(currentState.playbackPosition); + + // Seek with uuid to prepare with later + const uuid = 'uuid1'; + player.seekToUuid(uuid, 30 * 1000); + stateCounter++; + assertEquals(stateCounter, states.length); + currentState = states[stateCounter - 1]; + assertEquals(30 * 1000, player.getCurrentPositionMs()); + assertEquals(0, player.getCurrentWindowIndex()); + assertEquals(-1, player.windowIndex_); + assertEquals(1, currentState.playbackPosition.periodId); + assertEquals(uuid, currentState.playbackPosition.uuid); + assertEquals(uuid, player.uuidToPrepare_); + + // Add a DASH media item. + player.addQueueItems(0, util.queue.slice(0, 2)); + stateCounter++; + assertEquals(stateCounter, states.length); + currentState = states[stateCounter - 1]; + assertEquals('IDLE', currentState.playbackState); + assertNotNull(currentState.playbackPosition); + util.assertUuidIndexMap(player.queueUuidIndexMap_, currentState.mediaQueue); + + // Prepare. + player.prepare(); + stateCounter++; + assertEquals(stateCounter, states.length); + currentState = states[stateCounter - 1]; + assertEquals(2, currentState.mediaQueue.length); + assertEquals('BUFFERING', currentState.playbackState); + assertEquals( + Player.DUMMY_MEDIA_ITEM_INFO, currentState.mediaItemsInfo[uuid]); + assertNull(player.uuidToPrepare_); + + // The video element starts waiting. + mocks.state().isSilent = false; + mocks.notifyListeners('waiting'); + // Nothing happens, masked buffering state after preparing. + assertEquals(stateCounter, states.length); + + // The manifest arrives. + mocks.notifyListeners('streaming'); + stateCounter++; + assertEquals(stateCounter, states.length); + currentState = states[stateCounter - 1]; + assertEquals(2, currentState.mediaQueue.length); + assertEquals('BUFFERING', currentState.playbackState); + assertEquals(uuid, currentState.playbackPosition.uuid); + assertEquals(0, currentState.playbackPosition.periodId); + assertEquals(30 * 1000, currentState.playbackPosition.positionMs); + // The dummy media item info has been replaced by the real one. + assertEquals(20000000, currentState.mediaItemsInfo[uuid].windowDurationUs); + assertEquals(0, currentState.mediaItemsInfo[uuid].defaultStartPositionUs); + assertEquals(0, currentState.mediaItemsInfo[uuid].positionInFirstPeriodUs); + assertTrue(currentState.mediaItemsInfo[uuid].isSeekable); + assertFalse(currentState.mediaItemsInfo[uuid].isDynamic); + + // Tracks have initially changed. + mocks.notifyListeners('trackschanged'); + // Nothing happens because the media item info remains the same. + assertEquals(stateCounter, states.length); + + // The video element reports the first frame rendered. + mocks.notifyListeners('loadeddata'); + stateCounter++; + assertEquals(stateCounter, states.length); + currentState = states[stateCounter - 1]; + assertEquals(2, currentState.mediaQueue.length); + assertEquals('READY', currentState.playbackState); + assertEquals(uuid, currentState.playbackPosition.uuid); + assertEquals(0, currentState.playbackPosition.periodId); + assertEquals(30 * 1000, currentState.playbackPosition.positionMs); + + // Playback starts. + mocks.notifyListeners('playing'); + // Nothing happens; we are ready already. + assertEquals(stateCounter, states.length); + + // Add another queue item. + player.addQueueItems(1, util.queue.slice(3, 4)); + stateCounter++; + assertEquals(stateCounter, states.length); + mocks.state().isSilent = true; + // Seek to the next queue item. + player.seekToWindow(1, 0); + stateCounter++; + assertEquals(stateCounter, states.length); + currentState = states[stateCounter - 1]; + const uuid1 = currentState.mediaQueue[1].uuid; + assertEquals( + Player.DUMMY_MEDIA_ITEM_INFO, currentState.mediaItemsInfo[uuid1]); + util.assertUuidIndexMap(player.queueUuidIndexMap_, currentState.mediaQueue); + + // The video element starts waiting. + mocks.state().isSilent = false; + mocks.notifyListeners('waiting'); + // Nothing happens, masked buffering state after preparing. + assertEquals(stateCounter, states.length); + + // The manifest arrives. + mocks.notifyListeners('streaming'); + stateCounter++; + assertEquals(stateCounter, states.length); + currentState = states[stateCounter - 1]; + // The dummy media item info has been replaced by the real one. + assertEquals(20000000, currentState.mediaItemsInfo[uuid].windowDurationUs); + assertEquals(0, currentState.mediaItemsInfo[uuid].defaultStartPositionUs); + assertEquals(0, currentState.mediaItemsInfo[uuid].positionInFirstPeriodUs); + assertTrue(currentState.mediaItemsInfo[uuid].isSeekable); + assertFalse(currentState.mediaItemsInfo[uuid].isDynamic); + }, + + /** Tests next and previous window when not yet prepared. */ + testNextPreviousWindow_notPrepared() { + assertEquals(-1, player.getNextWindowIndex()); + assertEquals(-1, player.getPreviousWindowIndex()); + player.addQueueItems(0, util.queue.slice(0, 2)); + assertEquals(-1, player.getNextWindowIndex()); + assertEquals(-1, player.getPreviousWindowIndex()); + }, + + /** Tests setting play when ready. */ + testPlayWhenReady() { + player.addQueueItems(0, util.queue.slice(0, 3)); + let playWhenReady = false; + player.addPlayerListener((state) => { + playWhenReady = state.playWhenReady; + }); + + assertEquals(false, player.getPlayWhenReady()); + assertEquals(false, playWhenReady); + + player.setPlayWhenReady(true); + assertEquals(true, player.getPlayWhenReady()); + assertEquals(true, playWhenReady); + + player.setPlayWhenReady(false); + assertEquals(false, player.getPlayWhenReady()); + assertEquals(false, playWhenReady); + }, + + /** Tests seeking to another position in the actual window. */ + async testSeek_inWindow() { + player.addQueueItems(0, util.queue.slice(0, 3)); + await player.seekToWindow(0, 1000); + + assertEquals(1, shakaFake.getMediaElement().currentTime); + assertEquals(1000, player.getCurrentPositionMs()); + assertEquals(0, player.getCurrentWindowIndex()); + }, + + /** Tests seeking to another window. */ + async testSeek_nextWindow() { + player.addQueueItems(0, util.queue.slice(0, 3)); + await player.prepare(); + assertEquals(util.queue[0].media.uri, shakaFake.getMediaElement().src); + assertEquals(-1, player.getPreviousWindowIndex()); + assertEquals(1, player.getNextWindowIndex()); + + player.seekToWindow(1, 2000); + assertEquals(0, player.getPreviousWindowIndex()); + assertEquals(2, player.getNextWindowIndex()); + assertEquals(2000, player.getCurrentPositionMs()); + assertEquals(1, player.getCurrentWindowIndex()); + assertEquals(util.queue[1].media.uri, mocks.state().loadedUri); + }, + + /** Tests the repeat mode 'none' */ + testRepeatMode_none() { + player.addQueueItems(0, util.queue.slice(0, 3)); + player.prepare(); + assertEquals(Player.RepeatMode.OFF, player.getRepeatMode()); + assertEquals(-1, player.getPreviousWindowIndex()); + assertEquals(1, player.getNextWindowIndex()); + + player.seekToWindow(2, 0); + assertEquals(1, player.getPreviousWindowIndex()); + assertEquals(-1, player.getNextWindowIndex()); + }, + + /** Tests the repeat mode 'all'. */ + testRepeatMode_all() { + let repeatMode; + player.addQueueItems(0, util.queue.slice(0, 3)); + player.prepare(); + player.addPlayerListener((state) => { + repeatMode = state.repeatMode; + }); + player.setRepeatMode(Player.RepeatMode.ALL); + assertEquals(Player.RepeatMode.ALL, repeatMode); + + player.seekToWindow(0,0); + assertEquals(2, player.getPreviousWindowIndex()); + assertEquals(1, player.getNextWindowIndex()); + + player.seekToWindow(2, 0); + assertEquals(1, player.getPreviousWindowIndex()); + assertEquals(0, player.getNextWindowIndex()); + }, + + /** + * Tests navigation within the queue when repeat mode and shuffle mode is on. + */ + testRepeatMode_all_inShuffleMode() { + const initialOrder = [2, 1, 0]; + let shuffleOrder; + let windowIndex; + player.addQueueItems(0, util.queue.slice(0, 3), initialOrder); + player.prepare(); + player.addPlayerListener((state) => { + shuffleOrder = state.shuffleOrder; + windowIndex = state.windowIndex; + }); + player.setRepeatMode(Player.RepeatMode.ALL); + player.setShuffleModeEnabled(true); + assertEquals(windowIndex, player.shuffleOrder_[player.shuffleIndex_]); + assertArrayEquals(initialOrder, shuffleOrder); + + player.seekToWindow(shuffleOrder[2], 0); + assertEquals(shuffleOrder[2], windowIndex); + assertEquals(shuffleOrder[0], player.getNextWindowIndex()); + assertEquals(shuffleOrder[1], player.getPreviousWindowIndex()); + + player.seekToWindow(shuffleOrder[0], 0); + assertEquals(shuffleOrder[0], windowIndex); + }, + + /** Tests the repeat mode 'one' */ + testRepeatMode_one() { + let repeatMode; + player.addQueueItems(0, util.queue.slice(0, 3)); + player.prepare(); + player.addPlayerListener((state) => { + repeatMode = state.repeatMode; + }); + player.setRepeatMode(Player.RepeatMode.ONE); + assertEquals(Player.RepeatMode.ONE, repeatMode); + assertEquals(0, player.getPreviousWindowIndex()); + assertEquals(0, player.getNextWindowIndex()); + + player.seekToWindow(1, 0); + assertEquals(1, player.getPreviousWindowIndex()); + assertEquals(1, player.getNextWindowIndex()); + + player.setShuffleModeEnabled(true); + assertEquals(1, player.getPreviousWindowIndex()); + assertEquals(1, player.getNextWindowIndex()); + }, + + /** Tests building a media item info from the manifest. */ + testBuildMediaItemInfo_fromManifest() { + let mediaItemInfos = null; + player.addQueueItems(0, util.queue.slice(0, 3)); + player.addPlayerListener((state) => { + mediaItemInfos = state.mediaItemsInfo; + }); + player.seekToWindow(1, 0); + player.prepare(); + assertUndefined(mediaItemInfos['uuid0']); + const mediaItemInfo = mediaItemInfos['uuid1']; + assertNotUndefined(mediaItemInfo); + assertFalse(mediaItemInfo.isDynamic); + assertTrue(mediaItemInfo.isSeekable); + assertEquals(0, mediaItemInfo.defaultStartPositionUs); + assertEquals(20 * 1000 * 1000, mediaItemInfo.windowDurationUs); + assertEquals(1, mediaItemInfo.periods.length); + assertEquals(20 * 1000 * 1000, mediaItemInfo.periods[0].durationUs); + }, + + /** Tests building a media item info with multiple periods. */ + testBuildMediaItemInfo_fromManifest_multiPeriod() { + let mediaItemInfos = null; + player.addQueueItems(0, util.queue.slice(0, 3)); + player.addPlayerListener((state) => { + mediaItemInfos = state.mediaItemsInfo; + }); + // Setting manifest properties to emulate a multiperiod stream manifest. + mocks.state().getManifest().periods.push({startTime: 20}); + mocks.state().manifestState.windowDuration = 50; + player.seekToWindow(1, 0); + player.prepare(); + + const mediaItemInfo = mediaItemInfos['uuid1']; + assertNotUndefined(mediaItemInfo); + assertFalse(mediaItemInfo.isDynamic); + assertTrue(mediaItemInfo.isSeekable); + assertEquals(0, mediaItemInfo.defaultStartPositionUs); + assertEquals(50 * 1000 * 1000, mediaItemInfo.windowDurationUs); + assertEquals(2, mediaItemInfo.periods.length); + assertEquals(20 * 1000 * 1000, mediaItemInfo.periods[0].durationUs); + assertEquals(30 * 1000 * 1000, mediaItemInfo.periods[1].durationUs); + }, + + /** Tests building a media item info from a live manifest. */ + testBuildMediaItemInfo_fromManifest_live() { + let mediaItemInfos = null; + player.addQueueItems(0, util.queue.slice(0, 3)); + player.addPlayerListener((state) => { + mediaItemInfos = state.mediaItemsInfo; + }); + // Setting manifest properties to emulate a live stream manifest. + mocks.state().manifestState.isLive = true; + mocks.state().manifestState.windowDuration = 30; + mocks.state().manifestState.delay = 10; + mocks.state().getManifest().periods.push({startTime: 20}); + player.seekToWindow(1, 0); + player.prepare(); + + const mediaItemInfo = mediaItemInfos['uuid1']; + assertNotUndefined(mediaItemInfo); + assertTrue(mediaItemInfo.isDynamic); + assertTrue(mediaItemInfo.isSeekable); + assertEquals(20 * 1000 * 1000, mediaItemInfo.defaultStartPositionUs); + assertEquals(20 * 1000 * 1000, mediaItemInfo.windowDurationUs); + assertEquals(2, mediaItemInfo.periods.length); + assertEquals(20 * 1000 * 1000, mediaItemInfo.periods[0].durationUs); + assertEquals(Infinity, mediaItemInfo.periods[1].durationUs); + }, + + /** Tests whether the shaka request filter is set for life streams. */ + testRequestFilterIsSetAndRemovedForLive() { + player.addQueueItems(0, util.queue.slice(0, 3)); + + // Set manifest properties to emulate a live stream manifest. + mocks.state().manifestState.isLive = true; + mocks.state().manifestState.windowDuration = 30; + mocks.state().manifestState.delay = 10; + mocks.state().getManifest().periods.push({startTime: 20}); + + assertNull(mocks.state().responseFilter); + assertFalse(player.isManifestFilterRegistered_); + player.seekToWindow(1, 0); + player.prepare(); + assertNotNull(mocks.state().responseFilter); + assertTrue(player.isManifestFilterRegistered_); + + // Set manifest properties to emulate a non-live stream */ + mocks.state().manifestState.isLive = false; + mocks.state().manifestState.windowDuration = 20; + mocks.state().manifestState.delay = 0; + mocks.state().getManifest().periods.push({startTime: 20}); + + player.seekToWindow(0, 0); + assertNull(mocks.state().responseFilter); + assertFalse(player.isManifestFilterRegistered_); + }, + + /** Tests whether the media info is removed when queue item is removed. */ + testRemoveMediaItemInfo() { + let mediaItemInfos = null; + player.addQueueItems(0, util.queue.slice(0, 3)); + player.addPlayerListener((state) => { + mediaItemInfos = state.mediaItemsInfo; + }); + player.seekToWindow(1, 0); + player.prepare(); + assertNotUndefined(mediaItemInfos['uuid1']); + player.removeQueueItems(['uuid1']); + assertUndefined(mediaItemInfos['uuid1']); + }, + + /** Tests shuffling. */ + testSetShuffeModeEnabled() { + let shuffleModeEnabled = false; + player.addQueueItems(0, util.queue.slice(0, 3)); + player.addPlayerListener((state) => { + shuffleModeEnabled = state.shuffleModeEnabled; + }); + player.setShuffleModeEnabled(true); + assertTrue(shuffleModeEnabled); + + player.setShuffleModeEnabled(false); + assertFalse(shuffleModeEnabled); + }, + + /** Tests setting a new playback order. */ + async testSetShuffleOrder() { + const defaultOrder = [0, 1, 2]; + let shuffleOrder; + player.addPlayerListener((state) => { + shuffleOrder = state.shuffleOrder; + }); + await player.addQueueItems(0, util.queue.slice(0, 3), defaultOrder); + assertArrayEquals(defaultOrder, shuffleOrder); + + player.setShuffleOrder_([2, 1, 0]); + assertArrayEquals([2, 1, 0], player.shuffleOrder_); + }, + + /** Tests setting a new playback order with incorrect length. */ + async testSetShuffleOrder_incorrectLength() { + const defaultOrder = [0, 1, 2]; + let shuffleOrder; + player.addPlayerListener((state) => { + shuffleOrder = state.shuffleOrder; + }); + await player.addQueueItems(0, util.queue.slice(0, 3), defaultOrder); + assertArrayEquals(defaultOrder, shuffleOrder); + + shuffleOrder = undefined; + player.setShuffleOrder_([2, 1]); + assertUndefined(shuffleOrder); + }, + + /** Tests falling into ENDED when prepared with empty queue. */ + testPrepare_withEmptyQueue() { + player.seekToUuid('uuid1000', 1000); + assertEquals('uuid1000', player.uuidToPrepare_); + player.prepare(); + assertEquals('ENDED', player.getPlaybackState()); + assertNull(player.uuidToPrepare_); + player.seekToUuid('uuid1000', 1000); + assertNull(player.uuidToPrepare_); + }, +}); diff --git a/cast_receiver_app/test/queue_test.js b/cast_receiver_app/test/queue_test.js new file mode 100644 index 0000000000..b46361fb2e --- /dev/null +++ b/cast_receiver_app/test/queue_test.js @@ -0,0 +1,166 @@ +/** + * Copyright (C) 2018 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. + * + * @fileoverview Unit tests for queue manipulations. + */ + +goog.module('exoplayer.cast.test.queue'); +goog.setTestOnly(); + +const ConfigurationFactory = goog.require('exoplayer.cast.ConfigurationFactory'); +const Player = goog.require('exoplayer.cast.Player'); +const mocks = goog.require('exoplayer.cast.test.mocks'); +const testSuite = goog.require('goog.testing.testSuite'); +const util = goog.require('exoplayer.cast.test.util'); + +let player; + +testSuite({ + setUp() { + mocks.setUp(); + player = new Player(mocks.createShakaFake(), new ConfigurationFactory()); + }, + + /** Tests adding queue items. */ + testAddQueueItem() { + let queue = []; + player.addPlayerListener((state) => { + queue = state.mediaQueue; + }); + assertEquals(0, queue.length); + player.addQueueItems(0, util.queue.slice(0, 3)); + assertEquals(util.queue[0].media.uri, queue[0].media.uri); + assertEquals(util.queue[1].media.uri, queue[1].media.uri); + assertEquals(util.queue[2].media.uri, queue[2].media.uri); + util.assertUuidIndexMap(player.queueUuidIndexMap_, queue); + }, + + /** Tests that duplicate queue items are ignored. */ + testAddDuplicateQueueItem() { + let queue = []; + player.addPlayerListener((state) => { + queue = state.mediaQueue; + }); + assertEquals(0, queue.length); + // Insert three items. + player.addQueueItems(0, util.queue.slice(0, 3)); + // Insert two of which the first is a duplicate. + player.addQueueItems(1, util.queue.slice(2, 4)); + assertEquals(4, queue.length); + assertArrayEquals( + ['uuid0', 'uuid3', 'uuid1', 'uuid2'], queue.slice().map((i) => i.uuid)); + util.assertUuidIndexMap(player.queueUuidIndexMap_, queue); + }, + + /** Tests moving queue items. */ + testMoveQueueItem() { + const shuffleOrder = [0, 2, 1]; + let queue = []; + player.addPlayerListener((state) => { + queue = state.mediaQueue; + }); + player.addQueueItems(0, util.queue.slice(0, 3)); + player.moveQueueItem('uuid0', 1, shuffleOrder); + assertEquals(util.queue[1].media.uri, queue[0].media.uri); + assertEquals(util.queue[0].media.uri, queue[1].media.uri); + assertEquals(util.queue[2].media.uri, queue[2].media.uri); + util.assertUuidIndexMap(player.queueUuidIndexMap_, queue); + + queue = undefined; + // invalid to index + player.moveQueueItem('uuid0', 11, [0, 1, 2]); + assertTrue(typeof queue === 'undefined'); + assertArrayEquals(shuffleOrder, player.shuffleOrder_); + util.assertUuidIndexMap(player.queueUuidIndexMap_, player.queue_); + // negative to index + player.moveQueueItem('uuid0', -11, shuffleOrder); + assertTrue(typeof queue === 'undefined'); + assertArrayEquals(shuffleOrder, player.shuffleOrder_); + util.assertUuidIndexMap(player.queueUuidIndexMap_, player.queue_); + // unknown uuid + player.moveQueueItem('unknown', 1, shuffleOrder); + assertTrue(typeof queue === 'undefined'); + assertArrayEquals(shuffleOrder, player.shuffleOrder_); + util.assertUuidIndexMap(player.queueUuidIndexMap_, player.queue_); + }, + + /** Tests removing queue items. */ + testRemoveQueueItems() { + let queue = []; + player.addPlayerListener((state) => { + queue = state.mediaQueue; + }); + player.addQueueItems(0, util.queue.slice(0, 3), [0, 2, 1]); + player.prepare(); + player.seekToWindow(1, 0); + assertEquals(1, player.getCurrentWindowIndex()); + util.assertUuidIndexMap(player.queueUuidIndexMap_, player.queue_); + + // Remove the first item. + player.removeQueueItems(['uuid0']); + assertEquals(2, queue.length); + assertEquals(util.queue[1].media.uri, queue[0].media.uri); + assertEquals(util.queue[2].media.uri, queue[1].media.uri); + assertEquals(0, player.getCurrentWindowIndex()); + assertArrayEquals([1,0], player.shuffleOrder_); + util.assertUuidIndexMap(player.queueUuidIndexMap_, player.queue_); + + // Calling stop without reseting preserves the queue. + player.stop(false); + assertEquals('uuid1', player.uuidToPrepare_); + util.assertUuidIndexMap(player.queueUuidIndexMap_, player.queue_); + + // Remove the item at the end of the queue. + player.removeQueueItems(['uuid2']); + util.assertUuidIndexMap(player.queueUuidIndexMap_, player.queue_); + + // Remove the last remaining item in the queue. + player.removeQueueItems(['uuid1']); + assertEquals(0, queue.length); + assertEquals('IDLE', player.getPlaybackState()); + assertEquals(0, player.getCurrentWindowIndex()); + assertArrayEquals([], player.shuffleOrder_); + assertNull(player.uuidToPrepare_); + util.assertUuidIndexMap(player.queueUuidIndexMap_, player.queue_); + }, + + /** Tests removing multiple unordered queue items at once. */ + testRemoveQueueItems_multiple() { + let queue = []; + player.addPlayerListener((state) => { + queue = state.mediaQueue; + }); + player.addQueueItems(0, util.queue.slice(0, 6), []); + player.prepare(); + + assertEquals(6, queue.length); + player.removeQueueItems(['uuid1', 'uuid5', 'uuid3']); + assertArrayEquals(['uuid0', 'uuid2', 'uuid4'], queue.map((i) => i.uuid)); + util.assertUuidIndexMap(player.queueUuidIndexMap_, queue); + }, + + /** Tests whether stopping with reset=true resets queue and uuidToIndexMap */ + testStop_resetTrue() { + let queue = []; + player.addPlayerListener((state) => { + queue = state.mediaQueue; + }); + player.addQueueItems(0, util.queue.slice(0, 3), [0, 2, 1]); + player.prepare(); + player.stop(true); + assertEquals(0, player.queue_.length); + util.assertUuidIndexMap(player.queueUuidIndexMap_, queue); + }, +}); diff --git a/cast_receiver_app/test/receiver_test.js b/cast_receiver_app/test/receiver_test.js new file mode 100644 index 0000000000..303a1caf64 --- /dev/null +++ b/cast_receiver_app/test/receiver_test.js @@ -0,0 +1,1027 @@ +/** + * Copyright (C) 2018 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. + * + * @fileoverview Unit tests for receiver. + */ + +goog.module('exoplayer.cast.test.receiver'); +goog.setTestOnly(); + +const ConfigurationFactory = goog.require('exoplayer.cast.ConfigurationFactory'); +const MessageDispatcher = goog.require('exoplayer.cast.MessageDispatcher'); +const Player = goog.require('exoplayer.cast.Player'); +const Receiver = goog.require('exoplayer.cast.Receiver'); +const mocks = goog.require('exoplayer.cast.test.mocks'); +const testSuite = goog.require('goog.testing.testSuite'); +const util = goog.require('exoplayer.cast.test.util'); + +/** @type {?Player|undefined} */ +let player; +/** @type {!Array} */ +let queue = []; +let shakaFake; +let castContextMock; + +/** + * Sends a message to the receiver under test. + * + * @param {!Object} message The message to send as json. + */ +const sendMessage = function(message) { + mocks.state().customMessageListener({ + data: message, + senderId: 'sender0', + }); +}; + +/** + * Creates a valid media item with the suffix appended to each field. + * + * @param {string} suffix The suffix to append to the fields value. + * @return {!Object} The media item. + */ +const createMediaItem = function(suffix) { + return { + uuid: 'uuid' + suffix, + media: {uri: 'uri' + suffix}, + mimeType: 'application/dash+xml', + }; +}; + +let messageSequence = 0; + +/** + * Creates a message in the format sent bey the sender app. + * + * @param {string} method The name of the method. + * @param {?Object} args The arguments. + * @return {!Object} The message. + */ +const createMessage = function (method, args) { + return { + method: method, + args: args, + sequenceNumber: ++messageSequence, + }; +}; + +/** + * Asserts the `playerState` is in the same state as just after creation of the + * player. + * + * @param {!PlayerState} playerState The player state to assert. + * @param {string} playbackState The expected playback state. + */ +const assertInitialState = function(playerState, playbackState) { + assertEquals(playbackState, playerState.playbackState); + // Assert the state is in initial state. + assertArrayEquals([], queue); + assertEquals(0, playerState.windowCount); + assertEquals(0, playerState.windowIndex); + assertUndefined(playerState.playbackError); + assertNull(playerState.playbackPosition); + // Assert player properties. + assertEquals(0, player.getDurationMs()); + assertArrayEquals([], Object.entries(player.mediaItemInfoMap_)); + assertEquals(0, player.windowPeriodIndex_); + assertEquals(999, player.playbackType_); + assertEquals(0, player.getCurrentWindowIndex()); + assertEquals(Player.DUMMY_MEDIA_ITEM_INFO, player.windowMediaItemInfo_); +}; + + +testSuite({ + setUp() { + mocks.setUp(); + shakaFake = mocks.createShakaFake(); + castContextMock = mocks.createCastReceiverContextFake(); + player = new Player(shakaFake, new ConfigurationFactory()); + player.addPlayerListener((playerState) => { + queue = playerState.mediaQueue; + }); + const messageDispatcher = new MessageDispatcher( + 'urn:x-cast:com.google.exoplayer.cast', castContextMock); + new Receiver(player, castContextMock, messageDispatcher); + }, + + tearDown() { + queue = []; + }, + + /** Tests whether a status was sent to the sender on connect. */ + testNotifyClientConnected() { + assertUndefined(mocks.state().outputMessages['sender0']); + + sendMessage(createMessage('player.onClientConnected', {})); + const message = mocks.state().outputMessages['sender0'][0]; + assertEquals(messageSequence, message.sequenceNumber); + }, + + /** + * Tests whether a custom message listener has been registered after + * construction. + */ + testCustomMessageListener() { + assertTrue(goog.isFunction(mocks.state().customMessageListener)); + }, + + /** Tests set playWhenReady. */ + testSetPlayWhenReady() { + let playWhenReady; + player.addPlayerListener((playerState) => { + playWhenReady = playerState.playWhenReady; + }); + + sendMessage(createMessage( + 'player.setPlayWhenReady', + { playWhenReady: true } + )); + assertTrue(playWhenReady); + sendMessage(createMessage( + 'player.setPlayWhenReady', + { playWhenReady: false } + )); + assertFalse(playWhenReady); + }, + + /** Tests setting repeat modes. */ + testSetRepeatMode() { + let repeatMode; + player.addQueueItems(0, util.queue.slice(0, 3)); + player.prepare(); + player.addPlayerListener((playerState) => { + repeatMode = playerState.repeatMode; + }); + + sendMessage(createMessage( + 'player.setRepeatMode', + { repeatMode: Player.RepeatMode.ONE } + )); + assertEquals(Player.RepeatMode.ONE, repeatMode); + assertEquals(0, player.getNextWindowIndex()); + assertEquals(0, player.getPreviousWindowIndex()); + + sendMessage(createMessage( + 'player.setRepeatMode', + { repeatMode: Player.RepeatMode.ALL } + )); + assertEquals(Player.RepeatMode.ALL, repeatMode); + assertEquals(1, player.getNextWindowIndex()); + assertEquals(2, player.getPreviousWindowIndex()); + + sendMessage(createMessage( + 'player.setRepeatMode', + { repeatMode: Player.RepeatMode.OFF } + )); + assertEquals(Player.RepeatMode.OFF, repeatMode); + assertEquals(1, player.getNextWindowIndex()); + assertTrue(player.getPreviousWindowIndex() < 0); + }, + + /** Tests setting an invalid repeat mode value. */ + testSetRepeatMode_invalid_noStateChange() { + let repeatMode; + player.addPlayerListener((playerState) => { + repeatMode = playerState.repeatMode; + }); + + sendMessage(createMessage( + 'player.setRepeatMode', + { repeatMode: "UNKNOWN" } + )); + assertEquals(Player.RepeatMode.OFF, player.repeatMode_); + assertUndefined(repeatMode); + player.invalidate(); + assertEquals(Player.RepeatMode.OFF, repeatMode); + }, + + /** Tests enabling and disabling shuffle mode. */ + testSetShuffleModeEnabled() { + const enableMessage = createMessage('player.setShuffleModeEnabled', { + shuffleModeEnabled: true, + }); + const disableMessage = createMessage('player.setShuffleModeEnabled', { + shuffleModeEnabled: false, + }); + let shuffleModeEnabled; + player.addPlayerListener((state) => { + shuffleModeEnabled = state.shuffleModeEnabled; + }); + assertFalse(player.shuffleModeEnabled_); + sendMessage(enableMessage); + assertTrue(shuffleModeEnabled); + sendMessage(disableMessage); + assertFalse(shuffleModeEnabled); + }, + + /** Tests adding a single media item to the queue. */ + testAddMediaItem_single() { + const suffix = '0'; + const jsonMessage = createMessage('player.addItems', { + index: 0, + items: [ + createMediaItem(suffix), + ], + shuffleOrder: [0], + }); + + sendMessage(jsonMessage); + assertEquals(1, queue.length); + assertEquals('uuid0', queue[0].uuid); + assertEquals('uri0', queue[0].media.uri); + assertArrayEquals([0], player.shuffleOrder_); + }, + + /** Tests adding multiple media items to the queue. */ + testAddMediaItem_multiple() { + const shuffleOrder = [0, 2, 1]; + const jsonMessage = createMessage('player.addItems', { + index: 0, + items: [ + createMediaItem('0'), + createMediaItem('1'), + createMediaItem('2'), + ], + shuffleOrder: shuffleOrder, + }); + + sendMessage(jsonMessage); + assertArrayEquals(['uuid0', 'uuid1', 'uuid2'], queue.map((x) => x.uuid)); + assertArrayEquals(shuffleOrder, player.shuffleOrder_); + }, + + /** Tests adding a media item to end of the queue by omitting the index. */ + testAddMediaItem_noindex_addstoend() { + const shuffleOrder = [1, 3, 2, 0]; + const jsonMessage = createMessage('player.addItems', { + items: [createMediaItem('99')], + shuffleOrder: shuffleOrder, + }); + player.addQueueItems(0, util.queue.slice(0, 3), [0, 2, 1]); + let queue = []; + player.addPlayerListener((playerState) => { + queue = playerState.mediaQueue; + }); + sendMessage(jsonMessage); + assertEquals(4, queue.length); + assertEquals('uuid99', queue[3].uuid); + assertArrayEquals(shuffleOrder, player.shuffleOrder_); + }, + + /** Tests adding items with a shuffle order of invalid length. */ + testAddMediaItems_invalidShuffleOrderLength() { + const shuffleOrder = [1, 3, 2]; + const jsonMessage = createMessage('player.addItems', { + items: [createMediaItem('99')], + shuffleOrder: shuffleOrder, + }); + player.addQueueItems(0, util.queue.slice(0, 3), [0, 2, 1]); + let queue = []; + player.addPlayerListener((playerState) => { + queue = playerState.mediaQueue; + }); + sendMessage(jsonMessage); + assertEquals(4, queue.length); + assertEquals('uuid99', queue[3].uuid); + assertEquals(4, player.shuffleOrder_.length); + }, + + /** Tests inserting a media item to the queue. */ + testAddMediaItem_insert() { + const index = 1; + const shuffleOrder = [1, 0, 3, 2, 4]; + const firstInsertionMessage = createMessage('player.addItems', { + index, + items: [ + createMediaItem('99'), + createMediaItem('100'), + ], + shuffleOrder, + }); + const prepareMessage = createMessage('player.prepare', {}); + const secondInsertionMessage = createMessage('player.addItems', { + index, + items: [ + createMediaItem('199'), + createMediaItem('1100'), + ], + shuffleOrder, + }); + // fill with three items + player.addQueueItems(0, util.queue.slice(0, 3), [0, 2, 1]); + player.seekToUuid('uuid99', 0); + + sendMessage(firstInsertionMessage); + // The window index does not change when IDLE. + assertEquals(1, player.getCurrentWindowIndex()); + assertEquals(5, queue.length); + assertArrayEquals(shuffleOrder, player.shuffleOrder_); + util.assertUuidIndexMap(player.queueUuidIndexMap_, queue); + + // Prepare sets the index by the uuid to which we seeked. + sendMessage(prepareMessage); + assertEquals(1, player.getCurrentWindowIndex()); + util.assertUuidIndexMap(player.queueUuidIndexMap_, queue); + // Add two items at the current window index. + sendMessage(secondInsertionMessage); + // Current window index is adjusted. + assertEquals(3, player.getCurrentWindowIndex()); + assertEquals(7, queue.length); + assertEquals('uuid199', queue[index].uuid); + assertEquals(7, player.shuffleOrder_.length); + util.assertUuidIndexMap(player.queueUuidIndexMap_, queue); + }, + + /** Tests adding a media item with an index larger than the queue size. */ + testAddMediaItem_indexLargerThanQueueSize_addsToEnd() { + const index = 4; + const jsonMessage = createMessage('player.addItems', { + index: index, + items: [ + createMediaItem('99'), + createMediaItem('100'), + ], + shuffleOrder: [], + }); + player.addQueueItems(0, util.queue.slice(0, 3), [0, 2, 1]); + + assertArrayEquals(['uuid0', 'uuid1', 'uuid2'], queue.map((x) => x.uuid)); + sendMessage(jsonMessage); + assertArrayEquals(['uuid0', 'uuid1', 'uuid2', 'uuid99', 'uuid100'], + queue.map((x) => x.uuid)); + util.assertUuidIndexMap(player.queueUuidIndexMap_, queue); + }, + + /** Tests removing an item from the queue. */ + testRemoveMediaItem() { + const jsonMessage = + createMessage('player.removeItems', {uuids: ['uuid1', 'uuid0']}); + player.addQueueItems(0, util.queue.slice(0, 3), [0, 2, 1]); + assertArrayEquals(['uuid0', 'uuid1', 'uuid2'], queue.map((x) => x.uuid)); + + sendMessage(jsonMessage); + assertArrayEquals(['uuid2'], queue.map((x) => x.uuid)); + assertArrayEquals([0], player.shuffleOrder_); + util.assertUuidIndexMap(player.queueUuidIndexMap_, queue); + }, + + /** Tests removing the currently playing item from the queue. */ + async testRemoveMediaItem_currentItem() { + const jsonMessage = + createMessage('player.removeItems', {uuids: ['uuid1', 'uuid0']}); + player.addQueueItems(0, util.queue.slice(0, 3), [0, 2, 1]); + player.seekToWindow(1, 0); + player.prepare(); + + await sendMessage(jsonMessage); + assertArrayEquals(['uuid2'], queue.map((x) => x.uuid)); + assertEquals(0, player.getCurrentWindowIndex()); + assertEquals(util.queue[2].media.uri, shakaFake.getMediaElement().src); + assertArrayEquals([0], player.shuffleOrder_); + util.assertUuidIndexMap(player.queueUuidIndexMap_, queue); + }, + + /** Tests removing items which affect the current window index. */ + async testRemoveMediaItem_affectsWindowIndex() { + const jsonMessage = + createMessage('player.removeItems', {uuids: ['uuid1', 'uuid0']}); + const currentUri = util.queue[4].media.uri; + player.addQueueItems(0, util.queue.slice(0, 6), [3, 2, 1, 4, 0, 5]); + player.prepare(); + await player.seekToWindow(4, 2000); + assertEquals(currentUri, shakaFake.getMediaElement().src); + + sendMessage(jsonMessage); + assertEquals(4, queue.length); + assertEquals('uuid4', queue[player.getCurrentWindowIndex()].uuid); + assertEquals(2, player.getCurrentWindowIndex()); + assertEquals(currentUri, shakaFake.getMediaElement().src); + assertArrayEquals([1, 0, 2, 3], player.shuffleOrder_); + util.assertUuidIndexMap(player.queueUuidIndexMap_, queue); + }, + + /** Tests removing the last item of the queue. */ + testRemoveMediaItem_firstItem_windowIndexIsCorrect() { + const jsonMessage = + createMessage('player.removeItems', {uuids: ['uuid0']}); + player.addQueueItems(0, util.queue.slice(0, 3), [0, 2, 1]); + player.seekToWindow(1, 0); + + sendMessage(jsonMessage); + assertArrayEquals(['uuid1', 'uuid2'], queue.map((x) => x.uuid)); + assertEquals(0, player.getCurrentWindowIndex()); + assertArrayEquals([1, 0], player.shuffleOrder_); + util.assertUuidIndexMap(player.queueUuidIndexMap_, queue); + }, + + /** Tests removing the last item of the queue. */ + testRemoveMediaItem_lastItem_windowIndexIsCorrect() { + const jsonMessage = + createMessage('player.removeItems', {uuids: ['uuid2']}); + player.addQueueItems(0, util.queue.slice(0, 3), [0, 2, 1]); + player.seekToWindow(2, 0); + player.prepare(); + + mocks.state().isSilent = true; + const states = []; + player.addPlayerListener((playerState) => { + states.push(playerState); + }); + sendMessage(jsonMessage); + assertArrayEquals(['uuid0', 'uuid1'], queue.map((x) => x.uuid)); + assertEquals(1, player.getCurrentWindowIndex()); + assertArrayEquals([0, 1], player.shuffleOrder_); + assertEquals(1, states.length); + assertEquals(Player.PlaybackState.BUFFERING, states[0].playbackState); + assertEquals( + Player.DiscontinuityReason.PERIOD_TRANSITION, + states[0].playbackPosition.discontinuityReason); + assertEquals( + Player.DUMMY_MEDIA_ITEM_INFO, states[0].mediaItemsInfo['uuid1']); + util.assertUuidIndexMap(player.queueUuidIndexMap_, queue); + }, + + /** Tests removing items all items. */ + testRemoveMediaItem_removeAll() { + const jsonMessage = createMessage('player.removeItems', + {uuids: ['uuid1', 'uuid0', 'uuid2']}); + player.addQueueItems(0, util.queue.slice(0, 3)); + player.seekToWindow(2, 2000); + player.prepare(); + let playerState; + player.addPlayerListener((state) => { + playerState = state; + }); + + sendMessage(jsonMessage); + assertInitialState(playerState, 'ENDED'); + assertEquals(0, player.getCurrentWindowIndex()); + assertArrayEquals([], player.shuffleOrder_); + assertEquals(Player.PlaybackState.ENDED, player.getPlaybackState()); + util.assertUuidIndexMap(player.queueUuidIndexMap_, []); + }, + + /** Tests moving an item in the queue. */ + testMoveItem() { + let shuffleOrder = [0, 2, 1]; + const jsonMessage = createMessage('player.moveItem', { + uuid: 'uuid2', + index: 0, + shuffleOrder: shuffleOrder, + }); + player.addQueueItems(0, util.queue.slice(0, 3)); + + assertArrayEquals(['uuid0', 'uuid1', 'uuid2'], queue.map((x) => x.uuid)); + sendMessage(jsonMessage); + assertArrayEquals(['uuid2', 'uuid0', 'uuid1'], queue.map((x) => x.uuid)); + assertArrayEquals(shuffleOrder, player.shuffleOrder_); + util.assertUuidIndexMap(player.queueUuidIndexMap_, queue); + }, + + /** Tests moving the currently playing item in the queue. */ + testMoveItem_currentWindowIndex() { + let shuffleOrder = [0, 2, 1]; + const jsonMessage = createMessage('player.moveItem', { + uuid: 'uuid2', + index: 0, + shuffleOrder: shuffleOrder, + }); + player.addQueueItems(0, util.queue.slice(0, 3)); + player.prepare(); + player.seekToUuid('uuid2', 0); + assertEquals(2, player.getCurrentWindowIndex()); + + assertArrayEquals(['uuid0', 'uuid1', 'uuid2'], queue.map((x) => x.uuid)); + sendMessage(jsonMessage); + assertArrayEquals(['uuid2', 'uuid0', 'uuid1'], queue.map((x) => x.uuid)); + assertEquals(0, player.getCurrentWindowIndex()); + assertArrayEquals(shuffleOrder, player.shuffleOrder_); + util.assertUuidIndexMap(player.queueUuidIndexMap_, queue); + }, + + /** Tests moving an item from before to after the currently playing item. */ + testMoveItem_decreaseCurrentWindowIndex() { + const jsonMessage = createMessage('player.moveItem', { + uuid: 'uuid0', + index: 5, + shuffleOrder: [], + }); + player.addQueueItems(0, util.queue.slice(0, 6)); + player.prepare(); + player.seekToWindow(2, 0); + assertEquals(2, player.getCurrentWindowIndex()); + + assertArrayEquals(['uuid0', 'uuid1', 'uuid2', 'uuid3', 'uuid4', 'uuid5'], + queue.map((x) => x.uuid)); + sendMessage(jsonMessage); + assertArrayEquals(['uuid1', 'uuid2', 'uuid3', 'uuid4', 'uuid5', 'uuid0'], + queue.map((x) => x.uuid)); + assertEquals(1, player.getCurrentWindowIndex()); + util.assertUuidIndexMap(player.queueUuidIndexMap_, queue); + }, + + /** Tests moving an item from after to before the currently playing item. */ + testMoveItem_increaseCurrentWindowIndex() { + const jsonMessage = createMessage('player.moveItem', { + uuid: 'uuid5', + index: 0, + shuffleOrder: [], + }); + player.addQueueItems(0, util.queue.slice(0, 6)); + player.prepare(); + player.seekToWindow(2, 0); + assertEquals(2, player.getCurrentWindowIndex()); + + assertArrayEquals(['uuid0', 'uuid1', 'uuid2', 'uuid3', 'uuid4', 'uuid5'], + queue.map((x) => x.uuid)); + sendMessage(jsonMessage); + assertArrayEquals(['uuid5', 'uuid0', 'uuid1', 'uuid2', 'uuid3', 'uuid4'], + queue.map((x) => x.uuid)); + assertEquals(3, player.getCurrentWindowIndex()); + util.assertUuidIndexMap(player.queueUuidIndexMap_, queue); + }, + + /** Tests moving an item from after to the current window index. */ + testMoveItem_toCurrentWindowIndex_fromAfter() { + const jsonMessage = createMessage('player.moveItem', { + uuid: 'uuid5', + index: 2, + shuffleOrder: [], + }); + player.addQueueItems(0, util.queue.slice(0, 6)); + player.prepare(); + player.seekToWindow(2, 0); + assertEquals(2, player.getCurrentWindowIndex()); + + assertArrayEquals(['uuid0', 'uuid1', 'uuid2', 'uuid3', 'uuid4', 'uuid5'], + queue.map((x) => x.uuid)); + sendMessage(jsonMessage); + assertArrayEquals(['uuid0', 'uuid1', 'uuid5', 'uuid2', 'uuid3', 'uuid4'], + queue.map((x) => x.uuid)); + assertEquals(3, player.getCurrentWindowIndex()); + util.assertUuidIndexMap(player.queueUuidIndexMap_, queue); + }, + + /** Tests moving an item from before to the current window index. */ + testMoveItem_toCurrentWindowIndex_fromBefore() { + const jsonMessage = createMessage('player.moveItem', { + uuid: 'uuid0', + index: 2, + shuffleOrder: [], + }); + player.addQueueItems(0, util.queue.slice(0, 6)); + player.prepare(); + player.seekToWindow(2, 0); + assertEquals(2, player.getCurrentWindowIndex()); + + assertArrayEquals(['uuid0', 'uuid1', 'uuid2', 'uuid3', 'uuid4', 'uuid5'], + queue.map((x) => x.uuid)); + sendMessage(jsonMessage); + assertArrayEquals(['uuid1', 'uuid2', 'uuid0', 'uuid3', 'uuid4', 'uuid5'], + queue.map((x) => x.uuid)); + assertEquals(1, player.getCurrentWindowIndex()); + util.assertUuidIndexMap(player.queueUuidIndexMap_, queue); + }, + + /** Tests seekTo. */ + testSeekTo() { + const jsonMessage = createMessage('player.seekTo', + { + 'uuid': 'uuid1', + 'positionMs': 2000 + }); + player.addQueueItems(0, util.queue.slice(0, 3)); + player.prepare(); + sendMessage(jsonMessage); + assertEquals(2000, player.getCurrentPositionMs()); + assertEquals(1, player.getCurrentWindowIndex()); + }, + + /** Tests seekTo to unknown uuid. */ + testSeekTo_unknownUuid() { + const jsonMessage = createMessage('player.seekTo', + { + 'uuid': 'unknown', + }); + player.addQueueItems(0, util.queue.slice(0, 3)); + player.prepare(); + player.seekToWindow(1, 2000); + assertEquals(2000, player.getCurrentPositionMs()); + assertEquals(1, player.getCurrentWindowIndex()); + + sendMessage(jsonMessage); + assertEquals(2000, player.getCurrentPositionMs()); + assertEquals(1, player.getCurrentWindowIndex()); + }, + + /** Tests seekTo without position. */ + testSeekTo_noPosition_defaultsToZero() { + const jsonMessage = createMessage('player.seekTo', + { + 'uuid': 'uuid1', + }); + player.addQueueItems(0, util.queue.slice(0, 3)); + player.prepare(); + sendMessage(jsonMessage); + assertEquals(0, player.getCurrentPositionMs()); + assertEquals(1, player.getCurrentWindowIndex()); + }, + + /** Tests seekTo to negative position. */ + testSeekTo_negativePosition_defaultsToZero() { + const jsonMessage = createMessage('player.seekTo', + { + 'uuid': 'uuid2', + 'positionMs': -1, + }); + player.addQueueItems(0, util.queue.slice(0, 3)); + player.prepare(); + player.seekToWindow(1, 2000); + assertEquals(2000, player.getCurrentPositionMs()); + assertEquals(1, player.getCurrentWindowIndex()); + + sendMessage(jsonMessage); + assertEquals(0, player.getCurrentPositionMs()); + assertEquals(2, player.getCurrentWindowIndex()); + }, + + /** Tests whether validation is turned on. */ + testMediaItemValidation_isOn() { + const index = 0; + const mediaItem = createMediaItem('99'); + delete mediaItem.uuid; + const jsonMessage = createMessage('player.addItems', { + index: index, + items: [mediaItem], + shuffleOrder: [], + }); + + sendMessage(jsonMessage); + assertEquals(0, queue.length); + }, + + /** Tests whether the state is sent to sender apps on state transition. */ + testPlayerStateIsSent_withCorrectSequenceNumber() { + assertUndefined(mocks.state().outputMessages['sender0']); + const playMessage = + createMessage('player.setPlayWhenReady', {playWhenReady: true}); + sendMessage(playMessage); + + const playerState = mocks.state().outputMessages['sender0'][0]; + assertTrue(playerState.playWhenReady); + assertEquals(playMessage.sequenceNumber, playerState.sequenceNumber); + }, + + /** Tests whether a connect of a sender app sends the current player state. */ + testSenderConnection() { + const onSenderConnected = mocks.state().onSenderConnected; + assertTrue(goog.isFunction(onSenderConnected)); + onSenderConnected({senderId: 'sender0'}); + + const playerState = mocks.state().outputMessages['sender0'][0]; + assertEquals(Player.RepeatMode.OFF, playerState.repeatMode); + assertEquals('IDLE', playerState.playbackState); + assertArrayEquals([], playerState.mediaQueue); + assertEquals(-1, playerState.sequenceNumber); + }, + + /** Tests whether a disconnect of a sender notifies the message dispatcher. */ + testSenderDisconnection_callsMessageDispatcher() { + mocks.setUp(); + let notifiedSenderId; + const myPlayer = new Player(mocks.createShakaFake()); + const myManagerFake = mocks.createCastReceiverContextFake(); + new Receiver(myPlayer, myManagerFake, { + registerActionHandler() {}, + notifySenderDisconnected(senderId) { + notifiedSenderId = senderId; + }, + }); + + const onSenderDisconnected = mocks.state().onSenderDisconnected; + assertTrue(goog.isFunction(onSenderDisconnected)); + onSenderDisconnected({senderId: 'sender0'}); + assertEquals('sender0', notifiedSenderId); + }, + + /** + * Tests whether the state right after creation of the player matches + * expectations. + */ + testInitialState() { + mocks.state().isSilent = true; + let playerState; + player.addPlayerListener((state) => { + playerState = state; + }); + assertEquals(0, player.getCurrentPositionMs()); + // Dump a player state to the listener. + player.invalidate(); + // Asserts the state just after creation. + assertInitialState(playerState, 'IDLE'); + }, + + /** Tests whether user properties can be changed when in IDLE state */ + testChangingUserPropertiesWhenIdle() { + mocks.state().isSilent = true; + const states = []; + let counter = 0; + player.addPlayerListener((state) => { + states.push(state); + }); + // Adding items when IDLE. + player.addQueueItems(0, util.queue.slice(0, 3)); + counter++; + assertEquals(counter, states.length); + assertEquals(Player.PlaybackState.IDLE, states[counter - 1].playbackState); + assertArrayEquals( + ['uuid0', 'uuid1', 'uuid2'], + states[counter - 1].mediaQueue.map((i) => i.uuid)); + + // Set playWhenReady when IDLE. + assertFalse(player.getPlayWhenReady()); + player.setPlayWhenReady(true); + counter++; + assertTrue(player.getPlayWhenReady()); + assertEquals(counter, states.length); + assertEquals(Player.PlaybackState.IDLE, states[counter - 1].playbackState); + + // Seeking when IDLE. + player.seekToUuid('uuid2', 1000); + counter++; + // Window index not set when idle. + assertEquals(2, player.getCurrentWindowIndex()); + assertEquals(1000, player.getCurrentPositionMs()); + assertEquals(counter, states.length); + assertEquals(Player.PlaybackState.IDLE, states[counter - 1].playbackState); + // But window index is set when prepared. + player.prepare(); + assertEquals(2, player.getCurrentWindowIndex()); + }, + + /** Tests the state after calling prepare. */ + testPrepare() { + mocks.state().isSilent = true; + const states = []; + let counter = 0; + player.addPlayerListener((state) => { + states.push(state); + }); + const prepareMessage = createMessage('player.prepare', {}); + + player.addQueueItems(0, util.queue.slice(0, 3)); + player.seekToWindow(1, 1000); + counter += 2; + + // Sends prepare message. + sendMessage(prepareMessage); + counter++; + assertEquals(counter, states.length); + assertEquals('uuid1', states[counter - 1].playbackPosition.uuid); + assertEquals( + Player.PlaybackState.BUFFERING, states[counter - 1].playbackState); + + // Fakes Shaka events. + mocks.state().isSilent = false; + mocks.notifyListeners('streaming'); + mocks.notifyListeners('loadeddata'); + counter += 2; + assertEquals(counter, states.length); + assertEquals(Player.PlaybackState.READY, states[counter - 1].playbackState); + }, + + /** Tests stopping the player with `reset=true`. */ + testStop_resetTrue() { + mocks.state().isSilent = true; + let playerState; + player.addPlayerListener((state) => { + playerState = state; + }); + const stopMessage = createMessage('player.stop', {reset: true}); + + player.setRepeatMode(Player.RepeatMode.ALL); + player.setShuffleModeEnabled(true); + player.setPlayWhenReady(true); + player.addQueueItems(0, util.queue.slice(0, 3)); + player.prepare(); + mocks.state().isSilent = false; + mocks.notifyListeners('loadeddata'); + assertArrayEquals(['uuid0', 'uuid1', 'uuid2'], queue.map((i) => i.uuid)); + assertEquals(0, playerState.windowIndex); + assertNotEquals(Player.DUMMY_MEDIA_ITEM_INFO, player.windowMediaItemInfo_); + assertEquals(1, player.playbackType_); + // Stop the player. + sendMessage(stopMessage); + // Asserts the state looks the same as just after creation. + assertInitialState(playerState, 'IDLE'); + assertNull(playerState.playbackPosition); + // Assert player properties are preserved. + assertTrue(playerState.shuffleModeEnabled); + assertTrue(playerState.playWhenReady); + assertEquals(Player.RepeatMode.ALL, playerState.repeatMode); + }, + + /** Tests stopping the player with `reset=false`. */ + testStop_resetFalse() { + mocks.state().isSilent = true; + let playerState; + player.addPlayerListener((state) => { + playerState = state; + }); + const stopMessage = createMessage('player.stop', {reset: false}); + + player.addQueueItems(0, util.queue.slice(0, 3)); + player.prepare(); + player.seekToUuid('uuid1', 1000); + mocks.state().isSilent = false; + mocks.notifyListeners('streaming'); + mocks.notifyListeners('trackschanged'); + mocks.notifyListeners('loadeddata'); + assertArrayEquals(['uuid0', 'uuid1', 'uuid2'], queue.map((i) => i.uuid)); + assertEquals(1, playerState.windowIndex); + assertNotEquals(Player.DUMMY_MEDIA_ITEM_INFO, player.windowMediaItemInfo_); + assertEquals(2, player.playbackType_); + // Stop the player. + sendMessage(stopMessage); + assertEquals('IDLE', playerState.playbackState); + assertUndefined(playerState.playbackError); + // Assert the timeline is preserved. + assertEquals(3, queue.length); + assertEquals(3, playerState.windowCount); + assertEquals(1, player.windowIndex_); + assertEquals(1, playerState.windowIndex); + // Assert the playback position is correct. + assertEquals(1000, playerState.playbackPosition.positionMs); + assertEquals('uuid1', playerState.playbackPosition.uuid); + assertEquals(0, playerState.playbackPosition.periodId); + assertNull(playerState.playbackPosition.discontinuityReason); + assertEquals(1000, player.getCurrentPositionMs()); + // Assert player properties are preserved. + assertEquals(20000, player.getDurationMs()); + assertEquals(2, Object.entries(player.mediaItemInfoMap_).length); + assertEquals(0, player.windowPeriodIndex_); + assertEquals(1, player.getCurrentWindowIndex()); + assertEquals(1, player.windowIndex_); + assertNotEquals(Player.DUMMY_MEDIA_ITEM_INFO, player.windowMediaItemInfo_); + assertEquals(999, player.playbackType_); + assertEquals('uuid1', player.uuidToPrepare_); + }, + + /** + * Tests the state after having removed the last item in the queue. This + * resolves to the same state like calling `stop(true)` except that the state + * is ENDED and the queue is naturally empty and hence the windowIndex is + * unset. + */ + testRemoveLastQueueItem() { + mocks.state().isSilent = true; + let playerState; + player.addPlayerListener((state) => { + playerState = state; + }); + const removeAllItemsMessage = createMessage( + 'player.removeItems', {uuids: ['uuid0', 'uuid1', 'uuid2']}); + + player.addQueueItems(0, util.queue.slice(0, 3)); + player.seekToWindow(0, 1000); + player.prepare(); + mocks.state().isSilent = false; + mocks.notifyListeners('loadeddata'); + // Remove all items. + sendMessage(removeAllItemsMessage); + // Assert the state after removal of all items. + assertInitialState(playerState, 'ENDED'); + }, + + /** Tests whether a player state is sent when no item is added. */ + testAddItem_noop() { + mocks.state().isSilent = true; + let playerStates = []; + player.addPlayerListener((state) => { + playerStates.push(state); + }); + const noOpMessage = createMessage('player.addItems', { + index: 0, + items: [ + util.queue[0], + ], + shuffleOrder: [0], + }); + player.addQueueItems(0, [util.queue[0]], []); + player.prepare(); + assertEquals(2, playerStates.length); + assertEquals(2, mocks.state().outputMessages['sender0'].length); + sendMessage(noOpMessage); + assertEquals(2, playerStates.length); + assertEquals(3, mocks.state().outputMessages['sender0'].length); + }, + + /** Tests whether a player state is sent when no item is removed. */ + testRemoveItem_noop() { + mocks.state().isSilent = true; + let playerStates = []; + player.addPlayerListener((state) => { + playerStates.push(state); + }); + const noOpMessage = + createMessage('player.removeItems', {uuids: ['uuid00']}); + player.addQueueItems(0, util.queue.slice(0, 3)); + player.prepare(); + assertEquals(2, playerStates.length); + assertEquals(2, mocks.state().outputMessages['sender0'].length); + sendMessage(noOpMessage); + assertEquals(2, playerStates.length); + assertEquals(3, mocks.state().outputMessages['sender0'].length); + }, + + /** Tests whether a player state is sent when item is not moved. */ + testMoveItem_noop() { + mocks.state().isSilent = true; + let playerStates = []; + player.addPlayerListener((state) => { + playerStates.push(state); + }); + const noOpMessage = createMessage('player.moveItem', { + uuid: 'uuid00', + index: 0, + shuffleOrder: [], + }); + player.addQueueItems(0, util.queue.slice(0, 3)); + player.prepare(); + assertEquals(2, playerStates.length); + assertEquals(2, mocks.state().outputMessages['sender0'].length); + sendMessage(noOpMessage); + assertEquals(2, playerStates.length); + assertEquals(3, mocks.state().outputMessages['sender0'].length); + }, + + /** Tests whether playback actions send a state when no-op */ + testNoOpPlaybackActionsSendPlayerState() { + mocks.state().isSilent = true; + let playerStates = []; + let parsedMessage; + player.addPlayerListener((state) => { + playerStates.push(state); + }); + player.addQueueItems(0, util.queue.slice(0, 3)); + player.prepare(); + + const outputMessages = mocks.state().outputMessages['sender0']; + const setupMessageCount = playerStates.length; + let totalMessageCount = setupMessageCount; + assertEquals(setupMessageCount, playerStates.length); + assertEquals(totalMessageCount, outputMessages.length); + + const firstNoOpMessage = createMessage('player.setPlayWhenReady', { + playWhenReady: false, + }); + let expectedSequenceNumber = firstNoOpMessage.sequenceNumber; + + sendMessage(firstNoOpMessage); + totalMessageCount++; + assertEquals(setupMessageCount, playerStates.length); + assertEquals(totalMessageCount, outputMessages.length); + parsedMessage = outputMessages[totalMessageCount - 1]; + assertEquals(expectedSequenceNumber++, parsedMessage.sequenceNumber); + + sendMessage(createMessage('player.setRepeatMode', { + repeatMode: 'OFF', + })); + totalMessageCount++; + assertEquals(setupMessageCount, playerStates.length); + assertEquals(totalMessageCount, outputMessages.length); + parsedMessage = outputMessages[totalMessageCount - 1]; + assertEquals(expectedSequenceNumber++, parsedMessage.sequenceNumber); + + sendMessage(createMessage('player.setShuffleModeEnabled', { + shuffleModeEnabled: false, + })); + totalMessageCount++; + assertEquals(setupMessageCount, playerStates.length); + assertEquals(totalMessageCount, outputMessages.length); + parsedMessage = outputMessages[totalMessageCount - 1]; + assertEquals(expectedSequenceNumber++, parsedMessage.sequenceNumber); + + sendMessage(createMessage('player.seekTo', { + uuid: 'not_existing', + positionMs: 0, + })); + totalMessageCount++; + assertEquals(setupMessageCount, playerStates.length); + assertEquals(totalMessageCount, outputMessages.length); + parsedMessage = outputMessages[totalMessageCount - 1]; + assertEquals(expectedSequenceNumber++, parsedMessage.sequenceNumber); + }, +}); diff --git a/cast_receiver_app/test/shaka_error_handling_test.js b/cast_receiver_app/test/shaka_error_handling_test.js new file mode 100644 index 0000000000..a7dafd3176 --- /dev/null +++ b/cast_receiver_app/test/shaka_error_handling_test.js @@ -0,0 +1,84 @@ +/** + * Copyright (C) 2018 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. + * + * @fileoverview Unit tests for playback methods. + */ + +goog.module('exoplayer.cast.test.shaka'); +goog.setTestOnly(); + +const ConfigurationFactory = goog.require('exoplayer.cast.ConfigurationFactory'); +const Player = goog.require('exoplayer.cast.Player'); +const mocks = goog.require('exoplayer.cast.test.mocks'); +const testSuite = goog.require('goog.testing.testSuite'); +const util = goog.require('exoplayer.cast.test.util'); + +let player; +let shakaFake; + +testSuite({ + setUp() { + mocks.setUp(); + shakaFake = mocks.createShakaFake(); + player = new Player(shakaFake, new ConfigurationFactory()); + }, + + /** Tests Shaka critical error handling on load. */ + async testShakaCriticalError_onload() { + mocks.state().isSilent = true; + mocks.state().setShakaThrowsOnLoad(true); + let playerState; + player.addPlayerListener((state) => { + playerState = state; + }); + player.addQueueItems(0, util.queue.slice(0, 2)); + player.seekToUuid('uuid1', 2000); + player.setPlayWhenReady(true); + // Calling prepare triggers a critical Shaka error. + await player.prepare(); + // Assert player state after error. + assertEquals('IDLE', playerState.playbackState); + assertEquals(mocks.state().shakaError.category, playerState.error.category); + assertEquals(mocks.state().shakaError.code, playerState.error.code); + assertEquals( + 'loading failed for uri: http://example1.com', + playerState.error.message); + assertEquals(999, player.playbackType_); + // Assert player properties are preserved. + assertEquals(2000, player.getCurrentPositionMs()); + assertTrue(player.getPlayWhenReady()); + assertEquals(1, player.getCurrentWindowIndex()); + assertEquals(1, player.windowIndex_); + }, + + /** Tests Shaka critical error handling on unload. */ + async testShakaCriticalError_onunload() { + mocks.state().isSilent = true; + mocks.state().setShakaThrowsOnUnload(true); + let playerState; + player.addPlayerListener((state) => { + playerState = state; + }); + player.addQueueItems(0, util.queue.slice(0, 2)); + player.setPlayWhenReady(true); + assertUndefined(player.videoElement_.src); + // Calling prepare triggers a critical Shaka error. + await player.prepare(); + // Assert player state after caught and ignored error. + await assertEquals('BUFFERING', playerState.playbackState); + assertEquals('http://example.com', player.videoElement_.src); + assertEquals(1, player.playbackType_); + }, +}); diff --git a/cast_receiver_app/test/util.js b/cast_receiver_app/test/util.js new file mode 100644 index 0000000000..22244675b7 --- /dev/null +++ b/cast_receiver_app/test/util.js @@ -0,0 +1,87 @@ +/** + * Copyright (C) 2018 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. + * + * @fileoverview Description of this file. + */ + +goog.module('exoplayer.cast.test.util'); +goog.setTestOnly(); + +/** + * The queue of sample media items + * + * @type {!Array} + */ +const queue = [ + { + uuid: 'uuid0', + media: { + uri: 'http://example.com', + }, + mimeType: 'video/*', + }, + { + uuid: 'uuid1', + media: { + uri: 'http://example1.com', + }, + mimeType: 'application/dash+xml', + }, + { + uuid: 'uuid2', + media: { + uri: 'http://example2.com', + }, + mimeType: 'video/*', + }, + { + uuid: 'uuid3', + media: { + uri: 'http://example3.com', + }, + mimeType: 'application/dash+xml', + }, + { + uuid: 'uuid4', + media: { + uri: 'http://example4.com', + }, + mimeType: 'video/*', + }, + { + uuid: 'uuid5', + media: { + uri: 'http://example5.com', + }, + mimeType: 'application/dash+xml', + }, +]; + +/** + * Asserts whether the map of uuids is complete and points to the correct + * indices. + * + * @param {!Object} uuidIndexMap The uuid to index map. + * @param {!Array} queue The media item queue. + */ +const assertUuidIndexMap = (uuidIndexMap, queue) => { + assertEquals(queue.length, Object.entries(uuidIndexMap).length); + queue.forEach((mediaItem, index) => { + assertEquals(uuidIndexMap[mediaItem.uuid], index); + }); +}; + +exports.queue = queue; +exports.assertUuidIndexMap = assertUuidIndexMap; diff --git a/cast_receiver_app/test/validation_test.js b/cast_receiver_app/test/validation_test.js new file mode 100644 index 0000000000..8e58185cfa --- /dev/null +++ b/cast_receiver_app/test/validation_test.js @@ -0,0 +1,266 @@ +/** + * Copyright (C) 2018 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. + * + * @fileoverview Unit tests for queue manipulations. + */ + +goog.module('exoplayer.cast.test.validation'); +goog.setTestOnly(); + +const testSuite = goog.require('goog.testing.testSuite'); +const validation = goog.require('exoplayer.cast.validation'); + +/** + * Creates a sample drm media for validation tests. + * + * @return {!Object} A dummy media item with a drm scheme. + */ +const createDrmMedia = function() { + return { + uuid: 'string', + media: { + uri: 'string', + }, + mimeType: 'application/dash+xml', + drmSchemes: [ + { + uuid: 'string', + licenseServer: { + uri: 'string', + requestHeaders: { + 'string': 'string', + }, + }, + }, + ], + }; +}; + +testSuite({ + + /** Tests minimal valid media item. */ + testValidateMediaItem_minimal() { + const mediaItem = { + uuid: 'string', + media: { + uri: 'string', + }, + mimeType: 'application/dash+xml', + }; + assertTrue(validation.validateMediaItem(mediaItem)); + + const uuid = mediaItem.uuid; + delete mediaItem.uuid; + assertFalse(validation.validateMediaItem(mediaItem)); + mediaItem.uuid = uuid; + assertTrue(validation.validateMediaItem(mediaItem)); + + const mimeType = mediaItem.mimeType; + delete mediaItem.mimeType; + assertFalse(validation.validateMediaItem(mediaItem)); + mediaItem.mimeType = mimeType; + assertTrue(validation.validateMediaItem(mediaItem)); + + const media = mediaItem.media; + delete mediaItem.media; + assertFalse(validation.validateMediaItem(mediaItem)); + mediaItem.media = media; + assertTrue(validation.validateMediaItem(mediaItem)); + + const uri = mediaItem.media.uri; + delete mediaItem.media.uri; + assertFalse(validation.validateMediaItem(mediaItem)); + mediaItem.media.uri = uri; + assertTrue(validation.validateMediaItem(mediaItem)); + }, + + /** Tests media item drm property validation. */ + testValidateMediaItem_drmSchemes() { + const mediaItem = createDrmMedia(); + assertTrue(validation.validateMediaItem(mediaItem)); + + const uuid = mediaItem.drmSchemes[0].uuid; + delete mediaItem.drmSchemes[0].uuid; + assertFalse(validation.validateMediaItem(mediaItem)); + mediaItem.drmSchemes[0].uuid = uuid; + assertTrue(validation.validateMediaItem(mediaItem)); + + const licenseServer = mediaItem.drmSchemes[0].licenseServer; + delete mediaItem.drmSchemes[0].licenseServer; + assertFalse(validation.validateMediaItem(mediaItem)); + mediaItem.drmSchemes[0].licenseServer = licenseServer; + assertTrue(validation.validateMediaItem(mediaItem)); + + const uri = mediaItem.drmSchemes[0].licenseServer.uri; + delete mediaItem.drmSchemes[0].licenseServer.uri; + assertFalse(validation.validateMediaItem(mediaItem)); + mediaItem.drmSchemes[0].licenseServer.uri = uri; + assertTrue(validation.validateMediaItem(mediaItem)); + }, + + /** Tests validation of startPositionUs and endPositionUs. */ + testValidateMediaItem_endAndStartPositionUs() { + const mediaItem = createDrmMedia(); + + mediaItem.endPositionUs = 0; + mediaItem.startPositionUs = 120 * 1000; + assertTrue(validation.validateMediaItem(mediaItem)); + + mediaItem.endPositionUs = '0'; + assertFalse(validation.validateMediaItem(mediaItem)); + + mediaItem.endPositionUs = 0; + assertTrue(validation.validateMediaItem(mediaItem)); + + mediaItem.startPositionUs = true; + assertFalse(validation.validateMediaItem(mediaItem)); + }, + + /** Tests validation of the title. */ + testValidateMediaItem_title() { + const mediaItem = createDrmMedia(); + + mediaItem.title = '0'; + assertTrue(validation.validateMediaItem(mediaItem)); + + mediaItem.title = 0; + assertFalse(validation.validateMediaItem(mediaItem)); + }, + + /** Tests validation of the description. */ + testValidateMediaItem_description() { + const mediaItem = createDrmMedia(); + + mediaItem.description = '0'; + assertTrue(validation.validateMediaItem(mediaItem)); + + mediaItem.description = 0; + assertFalse(validation.validateMediaItem(mediaItem)); + }, + + /** Tests validating property of type string. */ + testValidateProperty_string() { + const obj = { + field: 'string', + }; + assertTrue(validation.validateProperty(obj, 'field', 'string')); + assertTrue(validation.validateProperty(obj, 'field', '?string')); + + obj.field = 0; + assertFalse(validation.validateProperty(obj, 'field', 'string')); + assertFalse(validation.validateProperty(obj, 'field', '?string')); + + obj.field = true; + assertFalse(validation.validateProperty(obj, 'field', 'string')); + assertFalse(validation.validateProperty(obj, 'field', '?string')); + + obj.field = {}; + assertFalse(validation.validateProperty(obj, 'field', 'string')); + assertFalse(validation.validateProperty(obj, 'field', '?string')); + + delete obj.field; + assertFalse(validation.validateProperty(obj, 'field', 'string')); + assertTrue(validation.validateProperty(obj, 'field', '?string')); + }, + + /** Tests validating property of type number. */ + testValidateProperty_number() { + const obj = { + field: 0, + }; + assertTrue(validation.validateProperty(obj, 'field', 'number')); + assertTrue(validation.validateProperty(obj, 'field', '?number')); + + obj.field = '0'; + assertFalse(validation.validateProperty(obj, 'field', 'number')); + assertFalse(validation.validateProperty(obj, 'field', '?number')); + + obj.field = true; + assertFalse(validation.validateProperty(obj, 'field', 'number')); + assertFalse(validation.validateProperty(obj, 'field', '?number')); + + obj.field = {}; + assertFalse(validation.validateProperty(obj, 'field', 'number')); + assertFalse(validation.validateProperty(obj, 'field', '?number')); + + delete obj.field; + assertFalse(validation.validateProperty(obj, 'field', 'number')); + assertTrue(validation.validateProperty(obj, 'field', '?number')); + }, + + /** Tests validating property of type boolean. */ + testValidateProperty_boolean() { + const obj = { + field: true, + }; + assertTrue(validation.validateProperty(obj, 'field', 'boolean')); + assertTrue(validation.validateProperty(obj, 'field', '?boolean')); + + obj.field = '0'; + assertFalse(validation.validateProperty(obj, 'field', 'boolean')); + assertFalse(validation.validateProperty(obj, 'field', '?boolean')); + + obj.field = 1000; + assertFalse(validation.validateProperty(obj, 'field', 'boolean')); + assertFalse(validation.validateProperty(obj, 'field', '?boolean')); + + obj.field = [true]; + assertFalse(validation.validateProperty(obj, 'field', 'boolean')); + assertFalse(validation.validateProperty(obj, 'field', '?boolean')); + + delete obj.field; + assertFalse(validation.validateProperty(obj, 'field', 'boolean')); + assertTrue(validation.validateProperty(obj, 'field', '?boolean')); + }, + + /** Tests validating property of type array. */ + testValidateProperty_array() { + const obj = { + field: [], + }; + assertTrue(validation.validateProperty(obj, 'field', 'Array')); + assertTrue(validation.validateProperty(obj, 'field', '?Array')); + + obj.field = '0'; + assertFalse(validation.validateProperty(obj, 'field', 'Array')); + assertFalse(validation.validateProperty(obj, 'field', '?Array')); + + obj.field = 1000; + assertFalse(validation.validateProperty(obj, 'field', 'Array')); + assertFalse(validation.validateProperty(obj, 'field', '?Array')); + + obj.field = true; + assertFalse(validation.validateProperty(obj, 'field', 'Array')); + assertFalse(validation.validateProperty(obj, 'field', '?Array')); + + delete obj.field; + assertFalse(validation.validateProperty(obj, 'field', 'Array')); + assertTrue(validation.validateProperty(obj, 'field', '?Array')); + }, + + /** Tests validating properties of type RepeatMode */ + testValidateProperty_repeatMode() { + const obj = { + off: 'OFF', + one: 'ONE', + all: 'ALL', + invalid: 'invalid', + }; + assertTrue(validation.validateProperty(obj, 'off', 'RepeatMode')); + assertTrue(validation.validateProperty(obj, 'one', 'RepeatMode')); + assertTrue(validation.validateProperty(obj, 'all', 'RepeatMode')); + assertFalse(validation.validateProperty(obj, 'invalid', 'RepeatMode')); + }, +}); diff --git a/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/ExoCastPlayerManager.java b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/ExoCastPlayerManager.java new file mode 100644 index 0000000000..e8ad2c1a0d --- /dev/null +++ b/demos/cast/src/main/java/com/google/android/exoplayer2/castdemo/ExoCastPlayerManager.java @@ -0,0 +1,421 @@ +/* + * Copyright (C) 2018 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 androidx.annotation.Nullable; +import android.view.KeyEvent; +import android.view.View; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.DefaultRenderersFactory; +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.ExoPlayerFactory; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.Player.DiscontinuityReason; +import com.google.android.exoplayer2.Player.EventListener; +import com.google.android.exoplayer2.Player.TimelineChangeReason; +import com.google.android.exoplayer2.RenderersFactory; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.ext.cast.DefaultCastSessionManager; +import com.google.android.exoplayer2.ext.cast.ExoCastPlayer; +import com.google.android.exoplayer2.ext.cast.MediaItem; +import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener; +import com.google.android.exoplayer2.source.ConcatenatingMediaSource; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.ProgressiveMediaSource; +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.ui.PlayerControlView; +import com.google.android.exoplayer2.ui.PlayerView; +import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Log; +import com.google.android.gms.cast.framework.CastContext; +import java.util.ArrayList; + +/** Manages players and an internal media queue for the Cast demo app. */ +/* package */ class ExoCastPlayerManager + implements PlayerManager, EventListener, SessionAvailabilityListener { + + private static final String TAG = "ExoCastPlayerManager"; + private static final String USER_AGENT = "ExoCastDemoPlayer"; + private static final DefaultHttpDataSourceFactory DATA_SOURCE_FACTORY = + new DefaultHttpDataSourceFactory(USER_AGENT); + + private final PlayerView localPlayerView; + private final PlayerControlView castControlView; + private final SimpleExoPlayer exoPlayer; + private final ExoCastPlayer exoCastPlayer; + private final ArrayList mediaQueue; + private final Listener listener; + private final ConcatenatingMediaSource concatenatingMediaSource; + + private int currentItemIndex; + private Player currentPlayer; + + /** + * Creates a new manager for {@link SimpleExoPlayer} and {@link ExoCastPlayer}. + * + * @param listener A {@link Listener}. + * @param localPlayerView The {@link PlayerView} for local playback. + * @param castControlView The {@link PlayerControlView} to control remote playback. + * @param context A {@link Context}. + * @param castContext The {@link CastContext}. + */ + public ExoCastPlayerManager( + Listener listener, + PlayerView localPlayerView, + PlayerControlView castControlView, + Context context, + CastContext castContext) { + this.listener = listener; + this.localPlayerView = localPlayerView; + this.castControlView = castControlView; + mediaQueue = new ArrayList<>(); + currentItemIndex = C.INDEX_UNSET; + concatenatingMediaSource = new ConcatenatingMediaSource(); + + DefaultTrackSelector trackSelector = new DefaultTrackSelector(); + RenderersFactory renderersFactory = new DefaultRenderersFactory(context); + exoPlayer = ExoPlayerFactory.newSimpleInstance(context, renderersFactory, trackSelector); + exoPlayer.addListener(this); + localPlayerView.setPlayer(exoPlayer); + + exoCastPlayer = + new ExoCastPlayer( + sessionManagerListener -> + new DefaultCastSessionManager(castContext, sessionManagerListener)); + exoCastPlayer.addListener(this); + exoCastPlayer.setSessionAvailabilityListener(this); + castControlView.setPlayer(exoCastPlayer); + + setCurrentPlayer(exoCastPlayer.isCastSessionAvailable() ? exoCastPlayer : exoPlayer); + } + + // Queue manipulation methods. + + /** + * Plays a specified queue item in the current player. + * + * @param itemIndex The index of the item to play. + */ + @Override + public void selectQueueItem(int itemIndex) { + setCurrentItem(itemIndex, C.TIME_UNSET, true); + } + + /** Returns the index of the currently played item. */ + @Override + public int getCurrentItemIndex() { + return currentItemIndex; + } + + /** + * Appends {@code item} to the media queue. + * + * @param item The {@link MediaItem} to append. + */ + @Override + public void addItem(MediaItem item) { + mediaQueue.add(item); + concatenatingMediaSource.addMediaSource(buildMediaSource(item)); + if (currentPlayer == exoCastPlayer) { + exoCastPlayer.addItemsToQueue(item); + } + } + + /** Returns the size of the media queue. */ + @Override + public int getMediaQueueSize() { + return mediaQueue.size(); + } + + /** + * Returns the item at the given index in the media queue. + * + * @param position The index of the item. + * @return The item at the given index in the media queue. + */ + @Override + public MediaItem getItem(int position) { + return mediaQueue.get(position); + } + + /** + * Removes the item at the given index from the media queue. + * + * @param item The item to remove. + * @return Whether the removal was successful. + */ + @Override + public boolean removeItem(MediaItem item) { + int itemIndex = mediaQueue.indexOf(item); + if (itemIndex == -1) { + // This may happen if another sender app removes items while this sender app is in "swiping + // an item" state. + return false; + } + concatenatingMediaSource.removeMediaSource(itemIndex); + mediaQueue.remove(itemIndex); + if (currentPlayer == exoCastPlayer) { + exoCastPlayer.removeItemFromQueue(itemIndex); + } + if (itemIndex == currentItemIndex && itemIndex == mediaQueue.size()) { + maybeSetCurrentItemAndNotify(C.INDEX_UNSET); + } else if (itemIndex < currentItemIndex) { + maybeSetCurrentItemAndNotify(currentItemIndex - 1); + } + return true; + } + + /** + * Moves an item within the queue. + * + * @param item The item to move. This method does nothing if {@code item} is not contained in the + * queue. + * @param toIndex The target index of the item in the queue. If {@code toIndex} exceeds the last + * position in the queue, {@code toIndex} is clamped to match the largest possible value. + * @return True if {@code item} was contained in the queue, and {@code toIndex} was a valid + * position. False otherwise. + */ + @Override + public boolean moveItem(MediaItem item, int toIndex) { + int indexOfItem = mediaQueue.indexOf(item); + if (indexOfItem == -1) { + // This may happen if another sender app removes items while this sender app is in "dragging + // an item" state. + return false; + } + int clampedToIndex = Math.min(toIndex, mediaQueue.size() - 1); + mediaQueue.add(clampedToIndex, mediaQueue.remove(indexOfItem)); + concatenatingMediaSource.moveMediaSource(indexOfItem, clampedToIndex); + if (currentPlayer == exoCastPlayer) { + exoCastPlayer.moveItemInQueue(indexOfItem, clampedToIndex); + } + // Index update. + maybeSetCurrentItemAndNotify(currentPlayer.getCurrentWindowIndex()); + return clampedToIndex == toIndex; + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + if (currentPlayer == exoPlayer) { + return localPlayerView.dispatchKeyEvent(event); + } else /* currentPlayer == exoCastPlayer */ { + return castControlView.dispatchKeyEvent(event); + } + } + + /** Releases the manager and the players that it holds. */ + @Override + public void release() { + currentItemIndex = C.INDEX_UNSET; + mediaQueue.clear(); + concatenatingMediaSource.clear(); + exoCastPlayer.setSessionAvailabilityListener(null); + exoCastPlayer.release(); + localPlayerView.setPlayer(null); + exoPlayer.release(); + } + + // Player.EventListener implementation. + + @Override + public void onPlayerStateChanged(boolean playWhenReady, @Player.State int playbackState) { + updateCurrentItemIndex(); + } + + @Override + public void onPositionDiscontinuity(@DiscontinuityReason int reason) { + updateCurrentItemIndex(); + } + + @Override + public void onTimelineChanged( + Timeline timeline, @Nullable Object manifest, @TimelineChangeReason int reason) { + if (currentPlayer == exoCastPlayer && reason != Player.TIMELINE_CHANGE_REASON_RESET) { + maybeUpdateLocalQueueWithRemoteQueueAndNotify(); + } + updateCurrentItemIndex(); + } + + @Override + public void onPlayerError(ExoPlaybackException error) { + Log.e(TAG, "The player encountered an error.", error); + listener.onPlayerError(); + } + + // CastPlayer.SessionAvailabilityListener implementation. + + @Override + public void onCastSessionAvailable() { + setCurrentPlayer(exoCastPlayer); + } + + @Override + public void onCastSessionUnavailable() { + setCurrentPlayer(exoPlayer); + } + + // Internal methods. + + private void maybeUpdateLocalQueueWithRemoteQueueAndNotify() { + Assertions.checkState(currentPlayer == exoCastPlayer); + boolean mediaQueuesMatch = mediaQueue.size() == exoCastPlayer.getQueueSize(); + for (int i = 0; mediaQueuesMatch && i < mediaQueue.size(); i++) { + mediaQueuesMatch = mediaQueue.get(i).uuid.equals(exoCastPlayer.getQueueItem(i).uuid); + } + if (mediaQueuesMatch) { + // The media queues match. Do nothing. + return; + } + mediaQueue.clear(); + concatenatingMediaSource.clear(); + for (int i = 0; i < exoCastPlayer.getQueueSize(); i++) { + MediaItem item = exoCastPlayer.getQueueItem(i); + mediaQueue.add(item); + concatenatingMediaSource.addMediaSource(buildMediaSource(item)); + } + listener.onQueueContentsExternallyChanged(); + } + + private void updateCurrentItemIndex() { + int playbackState = currentPlayer.getPlaybackState(); + maybeSetCurrentItemAndNotify( + playbackState != Player.STATE_IDLE && playbackState != Player.STATE_ENDED + ? currentPlayer.getCurrentWindowIndex() + : C.INDEX_UNSET); + } + + private void setCurrentPlayer(Player currentPlayer) { + if (this.currentPlayer == currentPlayer) { + return; + } + + // View management. + if (currentPlayer == exoPlayer) { + localPlayerView.setVisibility(View.VISIBLE); + castControlView.hide(); + } else /* currentPlayer == exoCastPlayer */ { + localPlayerView.setVisibility(View.GONE); + castControlView.show(); + } + + // Player state management. + long playbackPositionMs = C.TIME_UNSET; + int windowIndex = C.INDEX_UNSET; + boolean playWhenReady = false; + if (this.currentPlayer != null) { + int playbackState = this.currentPlayer.getPlaybackState(); + if (playbackState != Player.STATE_ENDED) { + playbackPositionMs = this.currentPlayer.getCurrentPosition(); + playWhenReady = this.currentPlayer.getPlayWhenReady(); + windowIndex = this.currentPlayer.getCurrentWindowIndex(); + if (windowIndex != currentItemIndex) { + playbackPositionMs = C.TIME_UNSET; + windowIndex = currentItemIndex; + } + } + this.currentPlayer.stop(true); + } else { + // This is the initial setup. No need to save any state. + } + + this.currentPlayer = currentPlayer; + + // Media queue management. + boolean shouldSeekInNewCurrentPlayer; + if (currentPlayer == exoPlayer) { + exoPlayer.prepare(concatenatingMediaSource); + shouldSeekInNewCurrentPlayer = true; + } else /* currentPlayer == exoCastPlayer */ { + if (exoCastPlayer.getPlaybackState() == Player.STATE_IDLE) { + exoCastPlayer.prepare(); + } + if (mediaQueue.isEmpty()) { + // Casting started with no local queue. We take the receiver app's queue as our own. + maybeUpdateLocalQueueWithRemoteQueueAndNotify(); + shouldSeekInNewCurrentPlayer = false; + } else { + // Casting started when the sender app had no queue. We just load our items into the + // receiver app's queue. If the receiver had no items in its queue, we also seek to wherever + // the sender app was playing. + int currentExoCastPlayerState = exoCastPlayer.getPlaybackState(); + shouldSeekInNewCurrentPlayer = + currentExoCastPlayerState == Player.STATE_IDLE + || currentExoCastPlayerState == Player.STATE_ENDED; + exoCastPlayer.addItemsToQueue(mediaQueue.toArray(new MediaItem[0])); + } + } + + // Playback transition. + if (shouldSeekInNewCurrentPlayer && windowIndex != C.INDEX_UNSET) { + setCurrentItem(windowIndex, playbackPositionMs, playWhenReady); + } else if (getMediaQueueSize() > 0) { + maybeSetCurrentItemAndNotify(currentPlayer.getCurrentWindowIndex()); + } + } + + /** + * Starts playback of the item at the given position. + * + * @param itemIndex The index of the item to play. + * @param positionMs The position at which playback should start. + * @param playWhenReady Whether the player should proceed when ready to do so. + */ + private void setCurrentItem(int itemIndex, long positionMs, boolean playWhenReady) { + maybeSetCurrentItemAndNotify(itemIndex); + currentPlayer.seekTo(itemIndex, positionMs); + if (currentPlayer.getPlaybackState() == Player.STATE_IDLE) { + if (currentPlayer == exoCastPlayer) { + exoCastPlayer.prepare(); + } else { + exoPlayer.prepare(concatenatingMediaSource); + } + } + currentPlayer.setPlayWhenReady(playWhenReady); + } + + private void maybeSetCurrentItemAndNotify(int currentItemIndex) { + if (this.currentItemIndex != currentItemIndex) { + int oldIndex = this.currentItemIndex; + this.currentItemIndex = currentItemIndex; + listener.onQueuePositionChanged(oldIndex, currentItemIndex); + } + } + + private static MediaSource buildMediaSource(MediaItem item) { + Uri uri = item.media.uri; + switch (item.mimeType) { + case DemoUtil.MIME_TYPE_SS: + return new SsMediaSource.Factory(DATA_SOURCE_FACTORY).createMediaSource(uri); + case DemoUtil.MIME_TYPE_DASH: + return new DashMediaSource.Factory(DATA_SOURCE_FACTORY).createMediaSource(uri); + case DemoUtil.MIME_TYPE_HLS: + return new HlsMediaSource.Factory(DATA_SOURCE_FACTORY).createMediaSource(uri); + case DemoUtil.MIME_TYPE_VIDEO_MP4: + return new ProgressiveMediaSource.Factory(DATA_SOURCE_FACTORY).createMediaSource(uri); + default: + { + throw new IllegalStateException("Unsupported type: " + item.mimeType); + } + } + } +} diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastSessionManager.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastSessionManager.java new file mode 100644 index 0000000000..7c1f06e8d2 --- /dev/null +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastSessionManager.java @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2018 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; + +/** Handles communication with the receiver app using a cast session. */ +public interface CastSessionManager { + + /** Factory for {@link CastSessionManager} instances. */ + interface Factory { + + /** + * Creates a {@link CastSessionManager} instance with the given listener. + * + * @param listener The listener to notify on receiver app and session state updates. + * @return The created instance. + */ + CastSessionManager create(StateListener listener); + } + + /** + * Extends {@link SessionAvailabilityListener} by adding receiver app state notifications. + * + *

Receiver app state notifications contain a sequence number that matches the sequence number + * of the last {@link ExoCastMessage} sent (using {@link #send(ExoCastMessage)}) by this session + * manager and processed by the receiver app. Sequence numbers are non-negative numbers. + */ + interface StateListener extends SessionAvailabilityListener { + + /** + * Called when a status update is received from the Cast Receiver app. + * + * @param stateUpdate A {@link ReceiverAppStateUpdate} containing the fields included in the + * message. + */ + void onStateUpdateFromReceiverApp(ReceiverAppStateUpdate stateUpdate); + } + + /** + * Special constant representing an unset sequence number. It is guaranteed to be a negative + * value. + */ + long SEQUENCE_NUMBER_UNSET = Long.MIN_VALUE; + + /** + * Connects the session manager to the cast message bus and starts listening for session + * availability changes. Also announces that this sender app is connected to the message bus. + */ + void start(); + + /** Stops tracking the state of the cast session and closes any existing session. */ + void stopTrackingSession(); + + /** + * Same as {@link #stopTrackingSession()}, but also stops the receiver app if a session is + * currently available. + */ + void stopTrackingSessionAndCasting(); + + /** Whether a cast session is available. */ + boolean isCastSessionAvailable(); + + /** + * Sends an {@link ExoCastMessage} to the receiver app. + * + *

A sequence number is assigned to every sent message. Message senders may mask the local + * state until a status update from the receiver app (see {@link StateListener}) is received with + * a greater or equal sequence number. + * + * @param message The message to send. + * @return The sequence number assigned to the message. + */ + long send(ExoCastMessage message); +} diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/DefaultCastSessionManager.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/DefaultCastSessionManager.java new file mode 100644 index 0000000000..c08a9bc352 --- /dev/null +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/DefaultCastSessionManager.java @@ -0,0 +1,187 @@ +/* + * Copyright (C) 2018 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 androidx.annotation.Nullable; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Log; +import com.google.android.gms.cast.Cast; +import com.google.android.gms.cast.CastDevice; +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 java.io.IOException; +import org.json.JSONException; + +/** Implements {@link CastSessionManager} by using JSON message passing. */ +public class DefaultCastSessionManager implements CastSessionManager { + + private static final String TAG = "DefaultCastSessionManager"; + private static final String EXOPLAYER_CAST_NAMESPACE = "urn:x-cast:com.google.exoplayer.cast"; + + private final SessionManager sessionManager; + private final CastSessionListener castSessionListener; + private final StateListener stateListener; + private final Cast.MessageReceivedCallback messageReceivedCallback; + + private boolean started; + private long sequenceNumber; + private long expectedInitialStateUpdateSequence; + @Nullable private CastSession currentSession; + + /** + * @param context The Cast context from which the cast session is obtained. + * @param stateListener The listener to notify of state changes. + */ + public DefaultCastSessionManager(CastContext context, StateListener stateListener) { + this.stateListener = stateListener; + sessionManager = context.getSessionManager(); + currentSession = sessionManager.getCurrentCastSession(); + castSessionListener = new CastSessionListener(); + messageReceivedCallback = new CastMessageCallback(); + expectedInitialStateUpdateSequence = SEQUENCE_NUMBER_UNSET; + } + + @Override + public void start() { + started = true; + sessionManager.addSessionManagerListener(castSessionListener, CastSession.class); + currentSession = sessionManager.getCurrentCastSession(); + if (currentSession != null) { + setMessageCallbackOnSession(); + } + } + + @Override + public void stopTrackingSession() { + stop(/* stopCasting= */ false); + } + + @Override + public void stopTrackingSessionAndCasting() { + stop(/* stopCasting= */ true); + } + + @Override + public boolean isCastSessionAvailable() { + return currentSession != null && expectedInitialStateUpdateSequence == SEQUENCE_NUMBER_UNSET; + } + + @Override + public long send(ExoCastMessage message) { + if (currentSession != null) { + currentSession.sendMessage(EXOPLAYER_CAST_NAMESPACE, message.toJsonString(sequenceNumber)); + } else { + Log.w(TAG, "Tried to send a message with no established session. Method: " + message.method); + } + return sequenceNumber++; + } + + private void stop(boolean stopCasting) { + sessionManager.removeSessionManagerListener(castSessionListener, CastSession.class); + if (currentSession != null) { + sessionManager.endCurrentSession(stopCasting); + } + currentSession = null; + started = false; + } + + private void setCastSession(@Nullable CastSession session) { + Assertions.checkState(started); + boolean hadSession = currentSession != null; + currentSession = session; + if (!hadSession && session != null) { + setMessageCallbackOnSession(); + } else if (hadSession && session == null) { + stateListener.onCastSessionUnavailable(); + } + } + + private void setMessageCallbackOnSession() { + try { + Assertions.checkNotNull(currentSession) + .setMessageReceivedCallbacks(EXOPLAYER_CAST_NAMESPACE, messageReceivedCallback); + expectedInitialStateUpdateSequence = send(new ExoCastMessage.OnClientConnected()); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + + /** Listens for Cast session state changes. */ + private class CastSessionListener implements SessionManagerListener { + + @Override + public void onSessionStarting(CastSession castSession) {} + + @Override + public void onSessionStarted(CastSession castSession, String sessionId) { + setCastSession(castSession); + } + + @Override + public void onSessionStartFailed(CastSession castSession, int error) {} + + @Override + public void onSessionEnding(CastSession castSession) {} + + @Override + public void onSessionEnded(CastSession castSession, int error) { + setCastSession(null); + } + + @Override + public void onSessionResuming(CastSession castSession, String sessionId) {} + + @Override + public void onSessionResumed(CastSession castSession, boolean wasSuspended) { + setCastSession(castSession); + } + + @Override + public void onSessionResumeFailed(CastSession castSession, int error) {} + + @Override + public void onSessionSuspended(CastSession castSession, int reason) { + setCastSession(null); + } + } + + private class CastMessageCallback implements Cast.MessageReceivedCallback { + + @Override + public void onMessageReceived(CastDevice castDevice, String namespace, String message) { + if (!EXOPLAYER_CAST_NAMESPACE.equals(namespace)) { + // Non-matching namespace. Ignore. + Log.e(TAG, String.format("Unrecognized namespace: '%s'.", namespace)); + return; + } + try { + ReceiverAppStateUpdate receivedUpdate = ReceiverAppStateUpdate.fromJsonMessage(message); + if (expectedInitialStateUpdateSequence == SEQUENCE_NUMBER_UNSET + || receivedUpdate.sequenceNumber >= expectedInitialStateUpdateSequence) { + stateListener.onStateUpdateFromReceiverApp(receivedUpdate); + if (expectedInitialStateUpdateSequence != SEQUENCE_NUMBER_UNSET) { + expectedInitialStateUpdateSequence = SEQUENCE_NUMBER_UNSET; + stateListener.onCastSessionAvailable(); + } + } + } catch (JSONException e) { + Log.e(TAG, "Error while parsing state update from receiver: ", e); + } + } + } +} diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/ExoCastConstants.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/ExoCastConstants.java new file mode 100644 index 0000000000..36173bfc5d --- /dev/null +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/ExoCastConstants.java @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2018 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; + +/** Defines constants used by the Cast extension. */ +public final class ExoCastConstants { + + private ExoCastConstants() {} + + public static final int PROTOCOL_VERSION = 0; + + // String representations. + + public static final String STR_STATE_IDLE = "IDLE"; + public static final String STR_STATE_BUFFERING = "BUFFERING"; + public static final String STR_STATE_READY = "READY"; + public static final String STR_STATE_ENDED = "ENDED"; + + public static final String STR_REPEAT_MODE_OFF = "OFF"; + public static final String STR_REPEAT_MODE_ONE = "ONE"; + public static final String STR_REPEAT_MODE_ALL = "ALL"; + + public static final String STR_DISCONTINUITY_REASON_PERIOD_TRANSITION = "PERIOD_TRANSITION"; + public static final String STR_DISCONTINUITY_REASON_SEEK = "SEEK"; + public static final String STR_DISCONTINUITY_REASON_SEEK_ADJUSTMENT = "SEEK_ADJUSTMENT"; + public static final String STR_DISCONTINUITY_REASON_AD_INSERTION = "AD_INSERTION"; + public static final String STR_DISCONTINUITY_REASON_INTERNAL = "INTERNAL"; + + public static final String STR_SELECTION_FLAG_DEFAULT = "DEFAULT"; + public static final String STR_SELECTION_FLAG_FORCED = "FORCED"; + public static final String STR_SELECTION_FLAG_AUTOSELECT = "AUTOSELECT"; + + // Methods. + + public static final String METHOD_BASE = "player."; + + public static final String METHOD_ON_CLIENT_CONNECTED = METHOD_BASE + "onClientConnected"; + public static final String METHOD_ADD_ITEMS = METHOD_BASE + "addItems"; + public static final String METHOD_MOVE_ITEM = METHOD_BASE + "moveItem"; + public static final String METHOD_PREPARE = METHOD_BASE + "prepare"; + public static final String METHOD_REMOVE_ITEMS = METHOD_BASE + "removeItems"; + public static final String METHOD_SET_PLAY_WHEN_READY = METHOD_BASE + "setPlayWhenReady"; + public static final String METHOD_SET_REPEAT_MODE = METHOD_BASE + "setRepeatMode"; + public static final String METHOD_SET_SHUFFLE_MODE_ENABLED = + METHOD_BASE + "setShuffleModeEnabled"; + public static final String METHOD_SEEK_TO = METHOD_BASE + "seekTo"; + public static final String METHOD_SET_PLAYBACK_PARAMETERS = METHOD_BASE + "setPlaybackParameters"; + public static final String METHOD_SET_TRACK_SELECTION_PARAMETERS = + METHOD_BASE + ".setTrackSelectionParameters"; + public static final String METHOD_STOP = METHOD_BASE + "stop"; + + // JSON message keys. + + public static final String KEY_ARGS = "args"; + public static final String KEY_DEFAULT_START_POSITION_US = "defaultStartPositionUs"; + public static final String KEY_DESCRIPTION = "description"; + public static final String KEY_DISABLED_TEXT_TRACK_SELECTION_FLAGS = + "disabledTextTrackSelectionFlags"; + public static final String KEY_DISCONTINUITY_REASON = "discontinuityReason"; + public static final String KEY_DRM_SCHEMES = "drmSchemes"; + public static final String KEY_DURATION_US = "durationUs"; + public static final String KEY_END_POSITION_US = "endPositionUs"; + public static final String KEY_ERROR_MESSAGE = "error"; + public static final String KEY_ID = "id"; + public static final String KEY_INDEX = "index"; + public static final String KEY_IS_DYNAMIC = "isDynamic"; + public static final String KEY_IS_LOADING = "isLoading"; + public static final String KEY_IS_SEEKABLE = "isSeekable"; + public static final String KEY_ITEMS = "items"; + public static final String KEY_LICENSE_SERVER = "licenseServer"; + public static final String KEY_MEDIA = "media"; + public static final String KEY_MEDIA_ITEMS_INFO = "mediaItemsInfo"; + public static final String KEY_MEDIA_QUEUE = "mediaQueue"; + public static final String KEY_METHOD = "method"; + public static final String KEY_MIME_TYPE = "mimeType"; + public static final String KEY_PERIOD_ID = "periodId"; + public static final String KEY_PERIODS = "periods"; + public static final String KEY_PITCH = "pitch"; + public static final String KEY_PLAY_WHEN_READY = "playWhenReady"; + public static final String KEY_PLAYBACK_PARAMETERS = "playbackParameters"; + public static final String KEY_PLAYBACK_POSITION = "playbackPosition"; + public static final String KEY_PLAYBACK_STATE = "playbackState"; + public static final String KEY_POSITION_IN_FIRST_PERIOD_US = "positionInFirstPeriodUs"; + public static final String KEY_POSITION_MS = "positionMs"; + public static final String KEY_PREFERRED_AUDIO_LANGUAGE = "preferredAudioLanguage"; + public static final String KEY_PREFERRED_TEXT_LANGUAGE = "preferredTextLanguage"; + public static final String KEY_PROTOCOL_VERSION = "protocolVersion"; + public static final String KEY_REPEAT_MODE = "repeatMode"; + public static final String KEY_REQUEST_HEADERS = "requestHeaders"; + public static final String KEY_RESET = "reset"; + public static final String KEY_SELECT_UNDETERMINED_TEXT_LANGUAGE = + "selectUndeterminedTextLanguage"; + public static final String KEY_SEQUENCE_NUMBER = "sequenceNumber"; + public static final String KEY_SHUFFLE_MODE_ENABLED = "shuffleModeEnabled"; + public static final String KEY_SHUFFLE_ORDER = "shuffleOrder"; + public static final String KEY_SKIP_SILENCE = "skipSilence"; + public static final String KEY_SPEED = "speed"; + public static final String KEY_START_POSITION_US = "startPositionUs"; + public static final String KEY_TITLE = "title"; + public static final String KEY_TRACK_SELECTION_PARAMETERS = "trackSelectionParameters"; + public static final String KEY_URI = "uri"; + public static final String KEY_UUID = "uuid"; + public static final String KEY_UUIDS = "uuids"; + public static final String KEY_WINDOW_DURATION_US = "windowDurationUs"; +} diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/ExoCastMessage.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/ExoCastMessage.java new file mode 100644 index 0000000000..1529e9f5ac --- /dev/null +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/ExoCastMessage.java @@ -0,0 +1,474 @@ +/* + * Copyright (C) 2018 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 static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL; +import static com.google.android.exoplayer2.Player.REPEAT_MODE_OFF; +import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_ARGS; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_DESCRIPTION; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_DISABLED_TEXT_TRACK_SELECTION_FLAGS; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_DRM_SCHEMES; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_END_POSITION_US; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_INDEX; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_ITEMS; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_LICENSE_SERVER; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_MEDIA; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_METHOD; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_MIME_TYPE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_PITCH; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_PLAY_WHEN_READY; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_POSITION_MS; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_PREFERRED_AUDIO_LANGUAGE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_PREFERRED_TEXT_LANGUAGE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_PROTOCOL_VERSION; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_REPEAT_MODE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_REQUEST_HEADERS; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_RESET; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_SELECT_UNDETERMINED_TEXT_LANGUAGE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_SEQUENCE_NUMBER; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_SHUFFLE_MODE_ENABLED; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_SHUFFLE_ORDER; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_SKIP_SILENCE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_SPEED; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_START_POSITION_US; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_TITLE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_URI; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_UUID; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_UUIDS; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.METHOD_ADD_ITEMS; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.METHOD_MOVE_ITEM; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.METHOD_ON_CLIENT_CONNECTED; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.METHOD_PREPARE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.METHOD_REMOVE_ITEMS; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.METHOD_SEEK_TO; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.METHOD_SET_PLAYBACK_PARAMETERS; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.METHOD_SET_PLAY_WHEN_READY; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.METHOD_SET_REPEAT_MODE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.METHOD_SET_SHUFFLE_MODE_ENABLED; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.METHOD_SET_TRACK_SELECTION_PARAMETERS; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.METHOD_STOP; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.PROTOCOL_VERSION; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.STR_REPEAT_MODE_ALL; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.STR_REPEAT_MODE_OFF; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.STR_REPEAT_MODE_ONE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.STR_SELECTION_FLAG_AUTOSELECT; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.STR_SELECTION_FLAG_DEFAULT; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.STR_SELECTION_FLAG_FORCED; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.PlaybackParameters; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.ext.cast.MediaItem.UriBundle; +import com.google.android.exoplayer2.source.ShuffleOrder; +import com.google.android.exoplayer2.trackselection.TrackSelectionParameters; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +// TODO(Internal b/118432277): Evaluate using a proto for sending to the receiver app. +/** A serializable message for operating a media player. */ +public abstract class ExoCastMessage { + + /** Notifies the receiver app of the connection of a sender app to the message bus. */ + public static final class OnClientConnected extends ExoCastMessage { + + public OnClientConnected() { + super(METHOD_ON_CLIENT_CONNECTED); + } + + @Override + protected JSONObject getArgumentsAsJsonObject() { + // No arguments needed. + return new JSONObject(); + } + } + + /** Transitions the player out of {@link Player#STATE_IDLE}. */ + public static final class Prepare extends ExoCastMessage { + + public Prepare() { + super(METHOD_PREPARE); + } + + @Override + protected JSONObject getArgumentsAsJsonObject() { + // No arguments needed. + return new JSONObject(); + } + } + + /** Transitions the player to {@link Player#STATE_IDLE} and optionally resets its state. */ + public static final class Stop extends ExoCastMessage { + + /** Whether the player state should be reset. */ + public final boolean reset; + + public Stop(boolean reset) { + super(METHOD_STOP); + this.reset = reset; + } + + @Override + protected JSONObject getArgumentsAsJsonObject() throws JSONException { + return new JSONObject().put(KEY_RESET, reset); + } + } + + /** Adds items to a media player queue. */ + public static final class AddItems extends ExoCastMessage { + + /** + * The index at which the {@link #items} should be inserted. If {@link C#INDEX_UNSET}, the items + * are appended to the queue. + */ + public final int index; + /** The {@link MediaItem items} to add to the media queue. */ + public final List items; + /** + * The shuffle order to use for the media queue that results of adding the items to the queue. + */ + public final ShuffleOrder shuffleOrder; + + /** + * @param index See {@link #index}. + * @param items See {@link #items}. + * @param shuffleOrder See {@link #shuffleOrder}. + */ + public AddItems(int index, List items, ShuffleOrder shuffleOrder) { + super(METHOD_ADD_ITEMS); + this.index = index; + this.items = Collections.unmodifiableList(new ArrayList<>(items)); + this.shuffleOrder = shuffleOrder; + } + + @Override + protected JSONObject getArgumentsAsJsonObject() throws JSONException { + JSONObject arguments = + new JSONObject() + .put(KEY_ITEMS, getItemsAsJsonArray()) + .put(KEY_SHUFFLE_ORDER, getShuffleOrderAsJson(shuffleOrder)); + maybePutValue(arguments, KEY_INDEX, index, C.INDEX_UNSET); + return arguments; + } + + private JSONArray getItemsAsJsonArray() throws JSONException { + JSONArray result = new JSONArray(); + for (MediaItem item : items) { + result.put(mediaItemAsJsonObject(item)); + } + return result; + } + } + + /** Moves an item in a player media queue. */ + public static final class MoveItem extends ExoCastMessage { + + /** The {@link MediaItem#uuid} of the item to move. */ + public final UUID uuid; + /** The index in the queue to which the item should be moved. */ + public final int index; + /** The shuffle order to use for the media queue that results of moving the item. */ + public ShuffleOrder shuffleOrder; + + /** + * @param uuid See {@link #uuid}. + * @param index See {@link #index}. + * @param shuffleOrder See {@link #shuffleOrder}. + */ + public MoveItem(UUID uuid, int index, ShuffleOrder shuffleOrder) { + super(METHOD_MOVE_ITEM); + this.uuid = uuid; + this.index = index; + this.shuffleOrder = shuffleOrder; + } + + @Override + protected JSONObject getArgumentsAsJsonObject() throws JSONException { + return new JSONObject() + .put(KEY_UUID, uuid) + .put(KEY_INDEX, index) + .put(KEY_SHUFFLE_ORDER, getShuffleOrderAsJson(shuffleOrder)); + } + } + + /** Removes items from a player queue. */ + public static final class RemoveItems extends ExoCastMessage { + + /** The {@link MediaItem#uuid} of the items to remove from the queue. */ + public final List uuids; + + /** @param uuids See {@link #uuids}. */ + public RemoveItems(List uuids) { + super(METHOD_REMOVE_ITEMS); + this.uuids = Collections.unmodifiableList(new ArrayList<>(uuids)); + } + + @Override + protected JSONObject getArgumentsAsJsonObject() throws JSONException { + return new JSONObject().put(KEY_UUIDS, new JSONArray(uuids)); + } + } + + /** See {@link Player#setPlayWhenReady(boolean)}. */ + public static final class SetPlayWhenReady extends ExoCastMessage { + + /** The {@link Player#setPlayWhenReady(boolean) playWhenReady} value to set. */ + public final boolean playWhenReady; + + /** @param playWhenReady See {@link #playWhenReady}. */ + public SetPlayWhenReady(boolean playWhenReady) { + super(METHOD_SET_PLAY_WHEN_READY); + this.playWhenReady = playWhenReady; + } + + @Override + protected JSONObject getArgumentsAsJsonObject() throws JSONException { + return new JSONObject().put(KEY_PLAY_WHEN_READY, playWhenReady); + } + } + + /** + * Sets the repeat mode of the media player. + * + * @see Player#setRepeatMode(int) + */ + public static final class SetRepeatMode extends ExoCastMessage { + + /** The {@link Player#setRepeatMode(int) repeatMode} to set. */ + @Player.RepeatMode public final int repeatMode; + + /** @param repeatMode See {@link #repeatMode}. */ + public SetRepeatMode(@Player.RepeatMode int repeatMode) { + super(METHOD_SET_REPEAT_MODE); + this.repeatMode = repeatMode; + } + + @Override + protected JSONObject getArgumentsAsJsonObject() throws JSONException { + return new JSONObject().put(KEY_REPEAT_MODE, repeatModeToString(repeatMode)); + } + + private static String repeatModeToString(@Player.RepeatMode int repeatMode) { + switch (repeatMode) { + case REPEAT_MODE_OFF: + return STR_REPEAT_MODE_OFF; + case REPEAT_MODE_ONE: + return STR_REPEAT_MODE_ONE; + case REPEAT_MODE_ALL: + return STR_REPEAT_MODE_ALL; + default: + throw new AssertionError("Illegal repeat mode: " + repeatMode); + } + } + } + + /** + * Enables and disables shuffle mode in the media player. + * + * @see Player#setShuffleModeEnabled(boolean) + */ + public static final class SetShuffleModeEnabled extends ExoCastMessage { + + /** The {@link Player#setShuffleModeEnabled(boolean) shuffleModeEnabled} value to set. */ + public boolean shuffleModeEnabled; + + /** @param shuffleModeEnabled See {@link #shuffleModeEnabled}. */ + public SetShuffleModeEnabled(boolean shuffleModeEnabled) { + super(METHOD_SET_SHUFFLE_MODE_ENABLED); + this.shuffleModeEnabled = shuffleModeEnabled; + } + + @Override + protected JSONObject getArgumentsAsJsonObject() throws JSONException { + return new JSONObject().put(KEY_SHUFFLE_MODE_ENABLED, shuffleModeEnabled); + } + } + + /** See {@link Player#seekTo(int, long)}. */ + public static final class SeekTo extends ExoCastMessage { + + /** The {@link MediaItem#uuid} of the item to seek to. */ + public final UUID uuid; + /** + * The seek position in milliseconds in the specified item. If {@link C#TIME_UNSET}, the target + * position is the item's default position. + */ + public final long positionMs; + + /** + * @param uuid See {@link #uuid}. + * @param positionMs See {@link #positionMs}. + */ + public SeekTo(UUID uuid, long positionMs) { + super(METHOD_SEEK_TO); + this.uuid = uuid; + this.positionMs = positionMs; + } + + @Override + protected JSONObject getArgumentsAsJsonObject() throws JSONException { + JSONObject result = new JSONObject().put(KEY_UUID, uuid); + ExoCastMessage.maybePutValue(result, KEY_POSITION_MS, positionMs, C.TIME_UNSET); + return result; + } + } + + /** See {@link Player#setPlaybackParameters(PlaybackParameters)}. */ + public static final class SetPlaybackParameters extends ExoCastMessage { + + /** The {@link Player#setPlaybackParameters(PlaybackParameters) parameters} to set. */ + public final PlaybackParameters playbackParameters; + + /** @param playbackParameters See {@link #playbackParameters}. */ + public SetPlaybackParameters(PlaybackParameters playbackParameters) { + super(METHOD_SET_PLAYBACK_PARAMETERS); + this.playbackParameters = playbackParameters; + } + + @Override + protected JSONObject getArgumentsAsJsonObject() throws JSONException { + return new JSONObject() + .put(KEY_SPEED, playbackParameters.speed) + .put(KEY_PITCH, playbackParameters.pitch) + .put(KEY_SKIP_SILENCE, playbackParameters.skipSilence); + } + } + + /** See {@link ExoCastPlayer#setTrackSelectionParameters(TrackSelectionParameters)}. */ + public static final class SetTrackSelectionParameters extends ExoCastMessage { + + /** + * The {@link ExoCastPlayer#setTrackSelectionParameters(TrackSelectionParameters) parameters} to + * set + */ + public final TrackSelectionParameters trackSelectionParameters; + + public SetTrackSelectionParameters(TrackSelectionParameters trackSelectionParameters) { + super(METHOD_SET_TRACK_SELECTION_PARAMETERS); + this.trackSelectionParameters = trackSelectionParameters; + } + + @Override + protected JSONObject getArgumentsAsJsonObject() throws JSONException { + JSONArray disabledTextSelectionFlagsJson = new JSONArray(); + int disabledSelectionFlags = trackSelectionParameters.disabledTextTrackSelectionFlags; + if ((disabledSelectionFlags & C.SELECTION_FLAG_AUTOSELECT) != 0) { + disabledTextSelectionFlagsJson.put(STR_SELECTION_FLAG_AUTOSELECT); + } + if ((disabledSelectionFlags & C.SELECTION_FLAG_FORCED) != 0) { + disabledTextSelectionFlagsJson.put(STR_SELECTION_FLAG_FORCED); + } + if ((disabledSelectionFlags & C.SELECTION_FLAG_DEFAULT) != 0) { + disabledTextSelectionFlagsJson.put(STR_SELECTION_FLAG_DEFAULT); + } + return new JSONObject() + .put(KEY_PREFERRED_AUDIO_LANGUAGE, trackSelectionParameters.preferredAudioLanguage) + .put(KEY_PREFERRED_TEXT_LANGUAGE, trackSelectionParameters.preferredTextLanguage) + .put(KEY_DISABLED_TEXT_TRACK_SELECTION_FLAGS, disabledTextSelectionFlagsJson) + .put( + KEY_SELECT_UNDETERMINED_TEXT_LANGUAGE, + trackSelectionParameters.selectUndeterminedTextLanguage); + } + } + + public final String method; + + /** + * Creates a message with the given method. + * + * @param method The method of the message. + */ + protected ExoCastMessage(String method) { + this.method = method; + } + + /** + * Returns a string containing a JSON representation of this message. + * + * @param sequenceNumber The sequence number to associate with this message. + * @return A string containing a JSON representation of this message. + */ + public final String toJsonString(long sequenceNumber) { + try { + JSONObject message = + new JSONObject() + .put(KEY_PROTOCOL_VERSION, PROTOCOL_VERSION) + .put(KEY_METHOD, method) + .put(KEY_SEQUENCE_NUMBER, sequenceNumber) + .put(KEY_ARGS, getArgumentsAsJsonObject()); + return message.toString(); + } catch (JSONException e) { + throw new AssertionError(e); + } + } + + /** Returns a {@link JSONObject} representation of the given item. */ + protected static JSONObject mediaItemAsJsonObject(MediaItem item) throws JSONException { + JSONObject itemAsJson = new JSONObject(); + itemAsJson.put(KEY_UUID, item.uuid); + itemAsJson.put(KEY_TITLE, item.title); + itemAsJson.put(KEY_DESCRIPTION, item.description); + itemAsJson.put(KEY_MEDIA, uriBundleAsJsonObject(item.media)); + // TODO(Internal b/118431961): Add attachment management. + + JSONArray drmSchemesAsJson = new JSONArray(); + for (MediaItem.DrmScheme drmScheme : item.drmSchemes) { + JSONObject drmSchemeAsJson = new JSONObject(); + drmSchemeAsJson.put(KEY_UUID, drmScheme.uuid); + if (drmScheme.licenseServer != null) { + drmSchemeAsJson.put(KEY_LICENSE_SERVER, uriBundleAsJsonObject(drmScheme.licenseServer)); + } + drmSchemesAsJson.put(drmSchemeAsJson); + } + itemAsJson.put(KEY_DRM_SCHEMES, drmSchemesAsJson); + maybePutValue(itemAsJson, KEY_START_POSITION_US, item.startPositionUs, C.TIME_UNSET); + maybePutValue(itemAsJson, KEY_END_POSITION_US, item.endPositionUs, C.TIME_UNSET); + itemAsJson.put(KEY_MIME_TYPE, item.mimeType); + return itemAsJson; + } + + /** Returns a {@link JSONObject JSON object} containing the arguments of the message. */ + protected abstract JSONObject getArgumentsAsJsonObject() throws JSONException; + + /** Returns a JSON representation of the given {@link UriBundle}. */ + protected static JSONObject uriBundleAsJsonObject(UriBundle uriBundle) throws JSONException { + return new JSONObject() + .put(KEY_URI, uriBundle.uri) + .put(KEY_REQUEST_HEADERS, new JSONObject(uriBundle.requestHeaders)); + } + + private static JSONArray getShuffleOrderAsJson(ShuffleOrder shuffleOrder) { + JSONArray shuffleOrderJson = new JSONArray(); + int index = shuffleOrder.getFirstIndex(); + while (index != C.INDEX_UNSET) { + shuffleOrderJson.put(index); + index = shuffleOrder.getNextIndex(index); + } + return shuffleOrderJson; + } + + private static void maybePutValue(JSONObject target, String key, long value, long unsetValue) + throws JSONException { + if (value != unsetValue) { + target.put(key, value); + } + } +} diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/ExoCastOptionsProvider.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/ExoCastOptionsProvider.java new file mode 100644 index 0000000000..56b5d3cc8c --- /dev/null +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/ExoCastOptionsProvider.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2018 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 androidx.annotation.Nullable; +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; + +/** Cast options provider to target ExoPlayer's custom receiver app. */ +public final class ExoCastOptionsProvider implements OptionsProvider { + + public static final String RECEIVER_ID = "365DCC88"; + + @Override + public CastOptions getCastOptions(Context context) { + return new CastOptions.Builder().setReceiverApplicationId(RECEIVER_ID).build(); + } + + @Override + @Nullable + public List getAdditionalSessionProviders(Context context) { + return null; + } +} diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/ExoCastPlayer.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/ExoCastPlayer.java new file mode 100644 index 0000000000..e24970ba0d --- /dev/null +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/ExoCastPlayer.java @@ -0,0 +1,958 @@ +/* + * Copyright (C) 2018 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.os.Looper; +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.BasePlayer; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.IllegalSeekPositionException; +import com.google.android.exoplayer2.PlaybackParameters; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.ext.cast.ExoCastMessage.AddItems; +import com.google.android.exoplayer2.ext.cast.ExoCastMessage.MoveItem; +import com.google.android.exoplayer2.ext.cast.ExoCastMessage.RemoveItems; +import com.google.android.exoplayer2.ext.cast.ExoCastMessage.SetRepeatMode; +import com.google.android.exoplayer2.ext.cast.ExoCastMessage.SetShuffleModeEnabled; +import com.google.android.exoplayer2.ext.cast.ExoCastMessage.SetTrackSelectionParameters; +import com.google.android.exoplayer2.ext.cast.ExoCastTimeline.PeriodUid; +import com.google.android.exoplayer2.source.ShuffleOrder; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.android.exoplayer2.trackselection.TrackSelectionParameters; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Clock; +import com.google.android.exoplayer2.util.Util; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; +import org.checkerframework.checker.nullness.compatqual.NullableType; + +/** + * Plays media in a Cast receiver app that implements the ExoCast message protocol. + * + *

The ExoCast communication protocol consists in exchanging serialized {@link ExoCastMessage + * ExoCastMessages} and {@link ReceiverAppStateUpdate receiver app state updates}. + * + *

All methods in this class must be invoked on the main thread. Operations that change the state + * of the receiver app are masked locally as if their effect was immediate in the receiver app. + * + *

Methods that change the state of the player must only be invoked when a session is available, + * according to {@link CastSessionManager#isCastSessionAvailable()}. + */ +public final class ExoCastPlayer extends BasePlayer { + + private static final String TAG = "ExoCastPlayer"; + + private static final int RENDERER_COUNT = 4; + 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 int RENDERER_INDEX_METADATA = 3; + + private final Clock clock; + private final CastSessionManager castSessionManager; + private final CopyOnWriteArrayList listeners; + private final ArrayList notificationsBatch; + private final ArrayDeque ongoingNotificationsTasks; + private final Timeline.Period scratchPeriod; + @Nullable private SessionAvailabilityListener sessionAvailabilityListener; + + // Player state. + + private final List mediaItems; + private final StateHolder currentTimeline; + private ShuffleOrder currentShuffleOrder; + + private final StateHolder playbackState; + private final StateHolder playWhenReady; + private final StateHolder repeatMode; + private final StateHolder shuffleModeEnabled; + private final StateHolder isLoading; + private final StateHolder playbackParameters; + private final StateHolder trackselectionParameters; + private final StateHolder currentTrackGroups; + private final StateHolder currentTrackSelections; + private final StateHolder<@NullableType Object> currentManifest; + private final StateHolder<@NullableType PeriodUid> currentPeriodUid; + private final StateHolder playbackPositionMs; + private final HashMap currentMediaItemInfoMap; + private long lastPlaybackPositionChangeTimeMs; + @Nullable private ExoPlaybackException playbackError; + + /** + * Creates an instance using the system clock for calculating time deltas. + * + * @param castSessionManagerFactory Factory to create the {@link CastSessionManager}. + */ + public ExoCastPlayer(CastSessionManager.Factory castSessionManagerFactory) { + this(castSessionManagerFactory, Clock.DEFAULT); + } + + /** + * Creates an instance using a custom {@link Clock} implementation. + * + * @param castSessionManagerFactory Factory to create the {@link CastSessionManager}. + * @param clock The clock to use for time delta calculations. + */ + public ExoCastPlayer(CastSessionManager.Factory castSessionManagerFactory, Clock clock) { + this.clock = clock; + castSessionManager = castSessionManagerFactory.create(new SessionManagerStateListener()); + listeners = new CopyOnWriteArrayList<>(); + notificationsBatch = new ArrayList<>(); + ongoingNotificationsTasks = new ArrayDeque<>(); + scratchPeriod = new Timeline.Period(); + mediaItems = new ArrayList<>(); + currentShuffleOrder = new ShuffleOrder.DefaultShuffleOrder(/* length= */ mediaItems.size()); + playbackState = new StateHolder<>(STATE_IDLE); + playWhenReady = new StateHolder<>(false); + repeatMode = new StateHolder<>(REPEAT_MODE_OFF); + shuffleModeEnabled = new StateHolder<>(false); + isLoading = new StateHolder<>(false); + playbackParameters = new StateHolder<>(PlaybackParameters.DEFAULT); + trackselectionParameters = new StateHolder<>(TrackSelectionParameters.DEFAULT); + currentTrackGroups = new StateHolder<>(TrackGroupArray.EMPTY); + currentTrackSelections = new StateHolder<>(new TrackSelectionArray(null, null, null, null)); + currentManifest = new StateHolder<>(null); + currentTimeline = new StateHolder<>(ExoCastTimeline.EMPTY); + playbackPositionMs = new StateHolder<>(0L); + currentPeriodUid = new StateHolder<>(null); + currentMediaItemInfoMap = new HashMap<>(); + castSessionManager.start(); + } + + /** Returns whether a Cast session is available. */ + public boolean isCastSessionAvailable() { + return castSessionManager.isCastSessionAvailable(); + } + + /** + * Sets a listener for updates on the Cast session availability. + * + * @param listener The {@link SessionAvailabilityListener}. + */ + public void setSessionAvailabilityListener(@Nullable SessionAvailabilityListener listener) { + sessionAvailabilityListener = listener; + } + + /** + * Prepares the player for playback. + * + *

Sends a preparation message to the receiver. If the player is in {@link #STATE_IDLE}, + * updates the timeline with the media queue contents. + */ + public void prepare() { + long sequence = castSessionManager.send(new ExoCastMessage.Prepare()); + if (playbackState.value == STATE_IDLE) { + playbackState.sequence = sequence; + setPlaybackStateInternal(mediaItems.isEmpty() ? STATE_ENDED : STATE_BUFFERING); + if (!currentTimeline.value.representsMediaQueue( + mediaItems, currentMediaItemInfoMap, currentShuffleOrder)) { + updateTimelineInternal(TIMELINE_CHANGE_REASON_PREPARED); + } + } + flushNotifications(); + } + + /** + * Returns the item at the given index. + * + * @param index The index of the item to retrieve. + * @return The item at the given index. + */ + public MediaItem getQueueItem(int index) { + return mediaItems.get(index); + } + + /** + * Equivalent to {@link #addItemsToQueue(int, MediaItem...) addItemsToQueue(C.INDEX_UNSET, + * items)}. + */ + public void addItemsToQueue(MediaItem... items) { + addItemsToQueue(C.INDEX_UNSET, items); + } + + /** + * Adds the given sequence of items to the queue at the given position, so that the first of + * {@code items} is placed at the given index. + * + *

This method discards {@code items} with a uuid that already appears in the media queue. This + * method does nothing if {@code items} contains no new items. + * + * @param optionalIndex The index at which {@code items} will be inserted. If {@link + * C#INDEX_UNSET} is passed, the items are appended to the media queue. + * @param items The sequence of items to append. {@code items} must not contain items with + * matching uuids. + * @throws IllegalArgumentException If two or more elements in {@code items} contain matching + * uuids. + */ + public void addItemsToQueue(int optionalIndex, MediaItem... items) { + // Filter out items whose uuid already appears in the queue. + ArrayList itemsToAdd = new ArrayList<>(); + HashSet addedUuids = new HashSet<>(); + for (MediaItem item : items) { + Assertions.checkArgument( + addedUuids.add(item.uuid), "Added items must contain distinct uuids"); + if (playbackState.value == STATE_IDLE + || currentTimeline.value.getWindowIndexFromUuid(item.uuid) == C.INDEX_UNSET) { + // Prevent adding items that exist in the timeline. If the player is not yet prepared, + // ignore this check, since the timeline may not reflect the current media queue. + // Preparation will filter any duplicates. + itemsToAdd.add(item); + } + } + if (itemsToAdd.isEmpty()) { + return; + } + + int normalizedIndex; + if (optionalIndex != C.INDEX_UNSET) { + normalizedIndex = optionalIndex; + mediaItems.addAll(optionalIndex, itemsToAdd); + } else { + normalizedIndex = mediaItems.size(); + mediaItems.addAll(itemsToAdd); + } + currentShuffleOrder = currentShuffleOrder.cloneAndInsert(normalizedIndex, itemsToAdd.size()); + long sequence = + castSessionManager.send(new AddItems(optionalIndex, itemsToAdd, currentShuffleOrder)); + if (playbackState.value != STATE_IDLE) { + currentTimeline.sequence = sequence; + updateTimelineInternal(TIMELINE_CHANGE_REASON_DYNAMIC); + } + flushNotifications(); + } + + /** + * Moves an existing item within the queue. + * + *

Calling this method is equivalent to removing the item at position {@code indexFrom} and + * immediately inserting it at position {@code indexTo}. If the moved item is being played at the + * moment of the invocation, playback will stick with the moved item. + * + * @param index The index of the item to move. + * @param newIndex The index at which the item will be placed after this operation. + */ + public void moveItemInQueue(int index, int newIndex) { + MediaItem movedItem = mediaItems.remove(index); + mediaItems.add(newIndex, movedItem); + currentShuffleOrder = + currentShuffleOrder + .cloneAndRemove(index, index + 1) + .cloneAndInsert(newIndex, /* insertionCount= */ 1); + long sequence = + castSessionManager.send(new MoveItem(movedItem.uuid, newIndex, currentShuffleOrder)); + if (playbackState.value != STATE_IDLE) { + currentTimeline.sequence = sequence; + updateTimelineInternal(TIMELINE_CHANGE_REASON_DYNAMIC); + } + flushNotifications(); + } + + /** + * Removes an item from the queue. + * + * @param index The index of the item to remove from the queue. + */ + public void removeItemFromQueue(int index) { + removeRangeFromQueue(index, index + 1); + } + + /** + * Removes a range of items from the queue. + * + *

If the currently-playing item is removed, the playback position moves to the item following + * the removed range. If no item follows the removed range, the position is set to the last item + * in the queue and the player state transitions to {@link #STATE_ENDED}. Does nothing if an empty + * range ({@code from == exclusiveTo}) is passed. + * + * @param indexFrom The inclusive index at which the range to remove starts. + * @param indexExclusiveTo The exclusive index at which the range to remove ends. + */ + public void removeRangeFromQueue(int indexFrom, int indexExclusiveTo) { + UUID[] uuidsToRemove = new UUID[indexExclusiveTo - indexFrom]; + for (int i = 0; i < uuidsToRemove.length; i++) { + uuidsToRemove[i] = mediaItems.get(i + indexFrom).uuid; + } + + int windowIndexBeforeRemoval = getCurrentWindowIndex(); + boolean currentItemWasRemoved = + windowIndexBeforeRemoval >= indexFrom && windowIndexBeforeRemoval < indexExclusiveTo; + boolean shouldTransitionToEnded = + currentItemWasRemoved && indexExclusiveTo == mediaItems.size(); + + Util.removeRange(mediaItems, indexFrom, indexExclusiveTo); + long sequence = castSessionManager.send(new RemoveItems(Arrays.asList(uuidsToRemove))); + currentShuffleOrder = currentShuffleOrder.cloneAndRemove(indexFrom, indexExclusiveTo); + + if (playbackState.value != STATE_IDLE) { + currentTimeline.sequence = sequence; + updateTimelineInternal(TIMELINE_CHANGE_REASON_DYNAMIC); + if (currentItemWasRemoved) { + int newWindowIndex = Math.max(0, indexFrom - (shouldTransitionToEnded ? 1 : 0)); + PeriodUid periodUid = + currentTimeline.value.isEmpty() + ? null + : (PeriodUid) + currentTimeline.value.getPeriodPosition( + window, + scratchPeriod, + newWindowIndex, + /* windowPositionUs= */ C.TIME_UNSET) + .first; + currentPeriodUid.sequence = sequence; + playbackPositionMs.sequence = sequence; + setPlaybackPositionInternal( + periodUid, + /* positionMs= */ C.TIME_UNSET, + /* discontinuityReason= */ DISCONTINUITY_REASON_SEEK); + } + playbackState.sequence = sequence; + setPlaybackStateInternal(shouldTransitionToEnded ? STATE_ENDED : STATE_BUFFERING); + } + flushNotifications(); + } + + /** Removes all items in the queue. */ + public void clearQueue() { + removeRangeFromQueue(0, getQueueSize()); + } + + /** Returns the number of items in this queue. */ + public int getQueueSize() { + return mediaItems.size(); + } + + // Track selection. + + /** + * Provides a set of constrains for the receiver app to execute track selection. + * + *

{@link TrackSelectionParameters} passed to this method may be {@link + * TrackSelectionParameters#buildUpon() built upon} by this player as a result of a remote + * operation, which means {@link TrackSelectionParameters} obtained from {@link + * #getTrackSelectionParameters()} may have field differences with {@code parameters} passed to + * this method. However, only fields modified remotely will present differences. Other fields will + * remain unchanged. + */ + public void setTrackSelectionParameters(TrackSelectionParameters trackselectionParameters) { + this.trackselectionParameters.value = trackselectionParameters; + this.trackselectionParameters.sequence = + castSessionManager.send(new SetTrackSelectionParameters(trackselectionParameters)); + } + + /** + * Retrieves the current {@link TrackSelectionParameters}. See {@link + * #setTrackSelectionParameters(TrackSelectionParameters)}. + */ + public TrackSelectionParameters getTrackSelectionParameters() { + return trackselectionParameters.value; + } + + // Player Implementation. + + @Override + @Nullable + public AudioComponent getAudioComponent() { + // TODO: Implement volume controls using the audio component. + return null; + } + + @Override + @Nullable + public VideoComponent getVideoComponent() { + return null; + } + + @Override + @Nullable + public TextComponent getTextComponent() { + return null; + } + + @Override + @Nullable + public MetadataComponent getMetadataComponent() { + return null; + } + + @Override + public Looper getApplicationLooper() { + return Looper.getMainLooper(); + } + + @Override + public void addListener(EventListener listener) { + listeners.addIfAbsent(new ListenerHolder(listener)); + } + + @Override + public void removeListener(EventListener listener) { + for (ListenerHolder listenerHolder : listeners) { + if (listenerHolder.listener.equals(listener)) { + listenerHolder.release(); + listeners.remove(listenerHolder); + } + } + } + + @Override + @Player.State + public int getPlaybackState() { + return playbackState.value; + } + + @Nullable + @Override + public ExoPlaybackException getPlaybackError() { + return playbackError; + } + + @Override + public void setPlayWhenReady(boolean playWhenReady) { + this.playWhenReady.sequence = + castSessionManager.send(new ExoCastMessage.SetPlayWhenReady(playWhenReady)); + // Take a snapshot of the playback position before pausing to ensure future calculations are + // correct. + setPlaybackPositionInternal( + currentPeriodUid.value, getCurrentPosition(), /* discontinuityReason= */ null); + setPlayWhenReadyInternal(playWhenReady); + flushNotifications(); + } + + @Override + public boolean getPlayWhenReady() { + return playWhenReady.value; + } + + @Override + public void setRepeatMode(@RepeatMode int repeatMode) { + this.repeatMode.sequence = castSessionManager.send(new SetRepeatMode(repeatMode)); + setRepeatModeInternal(repeatMode); + flushNotifications(); + } + + @Override + @RepeatMode + public int getRepeatMode() { + return repeatMode.value; + } + + @Override + public void setShuffleModeEnabled(boolean shuffleModeEnabled) { + this.shuffleModeEnabled.sequence = + castSessionManager.send(new SetShuffleModeEnabled(shuffleModeEnabled)); + setShuffleModeEnabledInternal(shuffleModeEnabled); + flushNotifications(); + } + + @Override + public boolean getShuffleModeEnabled() { + return shuffleModeEnabled.value; + } + + @Override + public boolean isLoading() { + return isLoading.value; + } + + @Override + public void seekTo(int windowIndex, long positionMs) { + if (mediaItems.isEmpty()) { + // TODO: Handle seeking in empty timeline. + setPlaybackPositionInternal(/* periodUid= */ null, 0, DISCONTINUITY_REASON_SEEK); + return; + } else if (windowIndex >= mediaItems.size()) { + throw new IllegalSeekPositionException(currentTimeline.value, windowIndex, positionMs); + } + long sequence = + castSessionManager.send( + new ExoCastMessage.SeekTo(mediaItems.get(windowIndex).uuid, positionMs)); + + currentPeriodUid.sequence = sequence; + playbackPositionMs.sequence = sequence; + + PeriodUid periodUid = + (PeriodUid) + currentTimeline.value.getPeriodPosition( + window, scratchPeriod, windowIndex, C.msToUs(positionMs)) + .first; + setPlaybackPositionInternal(periodUid, positionMs, DISCONTINUITY_REASON_SEEK); + if (playbackState.value != STATE_IDLE) { + playbackState.sequence = sequence; + setPlaybackStateInternal(STATE_BUFFERING); + } + flushNotifications(); + } + + @Override + public void setPlaybackParameters(@Nullable PlaybackParameters playbackParameters) { + playbackParameters = + playbackParameters != null ? playbackParameters : PlaybackParameters.DEFAULT; + this.playbackParameters.value = playbackParameters; + this.playbackParameters.sequence = + castSessionManager.send(new ExoCastMessage.SetPlaybackParameters(playbackParameters)); + this.playbackParameters.value = playbackParameters; + // Note: This method, unlike others, does not immediately notify the change. See the Player + // interface for more information. + } + + @Override + public PlaybackParameters getPlaybackParameters() { + return playbackParameters.value; + } + + @Override + public void stop(boolean reset) { + long sequence = castSessionManager.send(new ExoCastMessage.Stop(reset)); + playbackState.sequence = sequence; + setPlaybackStateInternal(STATE_IDLE); + if (reset) { + currentTimeline.sequence = sequence; + mediaItems.clear(); + currentShuffleOrder = new ShuffleOrder.DefaultShuffleOrder(/* length =*/ 0); + setPlaybackPositionInternal( + /* periodUid= */ null, /* positionMs= */ 0, DISCONTINUITY_REASON_INTERNAL); + updateTimelineInternal(TIMELINE_CHANGE_REASON_RESET); + } + flushNotifications(); + } + + @Override + public void release() { + setSessionAvailabilityListener(null); + castSessionManager.stopTrackingSession(); + flushNotifications(); + } + + @Override + public int getRendererCount() { + 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; + case RENDERER_INDEX_METADATA: + return C.TRACK_TYPE_METADATA; + default: + throw new IndexOutOfBoundsException(); + } + } + + @Override + public TrackGroupArray getCurrentTrackGroups() { + // TODO (Internal b/62080507): Implement using track information from currentMediaItemInfoMap. + return currentTrackGroups.value; + } + + @Override + public TrackSelectionArray getCurrentTrackSelections() { + // TODO (Internal b/62080507): Implement using track information from currentMediaItemInfoMap. + return currentTrackSelections.value; + } + + @Override + @Nullable + public Object getCurrentManifest() { + // TODO (Internal b/62080507): Implement using track information from currentMediaItemInfoMap. + return currentManifest.value; + } + + @Override + public Timeline getCurrentTimeline() { + return currentTimeline.value; + } + + @Override + public int getCurrentPeriodIndex() { + int periodIndex = + currentPeriodUid.value == null + ? C.INDEX_UNSET + : currentTimeline.value.getIndexOfPeriod(currentPeriodUid.value); + return periodIndex != C.INDEX_UNSET ? periodIndex : 0; + } + + @Override + public int getCurrentWindowIndex() { + int windowIndex = + currentPeriodUid.value == null + ? C.INDEX_UNSET + : currentTimeline.value.getWindowIndexContainingPeriod(currentPeriodUid.value); + return windowIndex != C.INDEX_UNSET ? windowIndex : 0; + } + + @Override + public long getDuration() { + return getContentDuration(); + } + + @Override + public long getCurrentPosition() { + return playbackPositionMs.value + + (getPlaybackState() == STATE_READY && getPlayWhenReady() + ? projectPlaybackTimeElapsedMs() + : 0L); + } + + @Override + public long getBufferedPosition() { + return getCurrentPosition(); + } + + @Override + public long getTotalBufferedDuration() { + return 0; + } + + @Override + public boolean isPlayingAd() { + // TODO (Internal b/119293631): Add support for ads. + return false; + } + + @Override + public int getCurrentAdGroupIndex() { + return C.INDEX_UNSET; + } + + @Override + public int getCurrentAdIndexInAdGroup() { + return C.INDEX_UNSET; + } + + @Override + public long getContentPosition() { + return getCurrentPosition(); + } + + @Override + public long getContentBufferedPosition() { + return getCurrentPosition(); + } + + // Local state modifications. + + private void setPlayWhenReadyInternal(boolean playWhenReady) { + if (this.playWhenReady.value != playWhenReady) { + this.playWhenReady.value = playWhenReady; + notificationsBatch.add( + new ListenerNotificationTask( + listener -> listener.onPlayerStateChanged(playWhenReady, playbackState.value))); + } + } + + private void setPlaybackStateInternal(int playbackState) { + if (this.playbackState.value != playbackState) { + if (this.playbackState.value == STATE_IDLE) { + // We are transitioning out of STATE_IDLE. We clear any errors. + setPlaybackErrorInternal(null); + } + this.playbackState.value = playbackState; + notificationsBatch.add( + new ListenerNotificationTask( + listener -> listener.onPlayerStateChanged(playWhenReady.value, playbackState))); + } + } + + private void setRepeatModeInternal(int repeatMode) { + if (this.repeatMode.value != repeatMode) { + this.repeatMode.value = repeatMode; + notificationsBatch.add( + new ListenerNotificationTask(listener -> listener.onRepeatModeChanged(repeatMode))); + } + } + + private void setShuffleModeEnabledInternal(boolean shuffleModeEnabled) { + if (this.shuffleModeEnabled.value != shuffleModeEnabled) { + this.shuffleModeEnabled.value = shuffleModeEnabled; + notificationsBatch.add( + new ListenerNotificationTask( + listener -> listener.onShuffleModeEnabledChanged(shuffleModeEnabled))); + } + } + + private void setIsLoadingInternal(boolean isLoading) { + if (this.isLoading.value != isLoading) { + this.isLoading.value = isLoading; + notificationsBatch.add( + new ListenerNotificationTask(listener -> listener.onLoadingChanged(isLoading))); + } + } + + private void setPlaybackParametersInternal(PlaybackParameters playbackParameters) { + if (!this.playbackParameters.value.equals(playbackParameters)) { + this.playbackParameters.value = playbackParameters; + notificationsBatch.add( + new ListenerNotificationTask( + listener -> listener.onPlaybackParametersChanged(playbackParameters))); + } + } + + private void setPlaybackErrorInternal(@Nullable String errorMessage) { + if (errorMessage != null) { + playbackError = ExoPlaybackException.createForRemote(errorMessage); + notificationsBatch.add( + new ListenerNotificationTask( + listener -> listener.onPlayerError(Assertions.checkNotNull(playbackError)))); + } else { + playbackError = null; + } + } + + private void setPlaybackPositionInternal( + @Nullable PeriodUid periodUid, long positionMs, @Nullable Integer discontinuityReason) { + currentPeriodUid.value = periodUid; + if (periodUid == null) { + positionMs = 0L; + } else if (positionMs == C.TIME_UNSET) { + int windowIndex = currentTimeline.value.getWindowIndexContainingPeriod(periodUid); + if (windowIndex == C.INDEX_UNSET) { + positionMs = 0; + } else { + positionMs = + C.usToMs( + currentTimeline.value.getWindow(windowIndex, window, /* setTag= */ false) + .defaultPositionUs); + } + } + playbackPositionMs.value = positionMs; + lastPlaybackPositionChangeTimeMs = clock.elapsedRealtime(); + if (discontinuityReason != null) { + notificationsBatch.add( + new ListenerNotificationTask( + listener -> listener.onPositionDiscontinuity(discontinuityReason))); + } + } + + // Internal methods. + + private void updateTimelineInternal(@TimelineChangeReason int changeReason) { + currentTimeline.value = + ExoCastTimeline.createTimelineFor(mediaItems, currentMediaItemInfoMap, currentShuffleOrder); + removeStaleMediaItemInfo(); + notificationsBatch.add( + new ListenerNotificationTask( + listener -> + listener.onTimelineChanged( + currentTimeline.value, /* manifest= */ null, changeReason))); + } + + private long projectPlaybackTimeElapsedMs() { + return (long) + ((clock.elapsedRealtime() - lastPlaybackPositionChangeTimeMs) + * playbackParameters.value.speed); + } + + private void flushNotifications() { + boolean recursiveNotification = !ongoingNotificationsTasks.isEmpty(); + ongoingNotificationsTasks.addAll(notificationsBatch); + notificationsBatch.clear(); + if (recursiveNotification) { + // This will be handled once the current notification task is finished. + return; + } + while (!ongoingNotificationsTasks.isEmpty()) { + ongoingNotificationsTasks.peekFirst().execute(); + ongoingNotificationsTasks.removeFirst(); + } + } + + /** + * Updates the current media item information by including any extra entries received from the + * receiver app. + * + * @param mediaItemsInformation A map of media item information received from the receiver app. + */ + private void updateMediaItemsInfo(Map mediaItemsInformation) { + for (Map.Entry entry : mediaItemsInformation.entrySet()) { + MediaItemInfo currentInfoForEntry = currentMediaItemInfoMap.get(entry.getKey()); + boolean shouldPutEntry = + currentInfoForEntry == null || !currentInfoForEntry.equals(entry.getValue()); + if (shouldPutEntry) { + currentMediaItemInfoMap.put(entry.getKey(), entry.getValue()); + } + } + } + + /** + * Removes stale media info entries. An entry is considered stale when the corresponding media + * item is not present in the current media queue. + */ + private void removeStaleMediaItemInfo() { + for (Iterator iterator = currentMediaItemInfoMap.keySet().iterator(); + iterator.hasNext(); ) { + UUID uuid = iterator.next(); + if (currentTimeline.value.getWindowIndexFromUuid(uuid) == C.INDEX_UNSET) { + iterator.remove(); + } + } + } + + // Internal classes. + + private class SessionManagerStateListener implements CastSessionManager.StateListener { + + @Override + public void onCastSessionAvailable() { + if (sessionAvailabilityListener != null) { + sessionAvailabilityListener.onCastSessionAvailable(); + } + } + + @Override + public void onCastSessionUnavailable() { + if (sessionAvailabilityListener != null) { + sessionAvailabilityListener.onCastSessionUnavailable(); + } + } + + @Override + public void onStateUpdateFromReceiverApp(ReceiverAppStateUpdate stateUpdate) { + long sequence = stateUpdate.sequenceNumber; + + if (stateUpdate.errorMessage != null) { + setPlaybackErrorInternal(stateUpdate.errorMessage); + } + + if (sequence >= playbackState.sequence && stateUpdate.playbackState != null) { + setPlaybackStateInternal(stateUpdate.playbackState); + } + + if (sequence >= currentTimeline.sequence) { + if (stateUpdate.items != null) { + mediaItems.clear(); + mediaItems.addAll(stateUpdate.items); + } + + currentShuffleOrder = + stateUpdate.shuffleOrder != null + ? new ShuffleOrder.DefaultShuffleOrder( + Util.toArray(stateUpdate.shuffleOrder), clock.elapsedRealtime()) + : currentShuffleOrder; + updateMediaItemsInfo(stateUpdate.mediaItemsInformation); + + if (playbackState.value != STATE_IDLE + && !currentTimeline.value.representsMediaQueue( + mediaItems, currentMediaItemInfoMap, currentShuffleOrder)) { + updateTimelineInternal(TIMELINE_CHANGE_REASON_DYNAMIC); + } + } + + if (sequence >= currentPeriodUid.sequence + && stateUpdate.currentPlayingItemUuid != null + && stateUpdate.currentPlaybackPositionMs != null) { + PeriodUid periodUid; + if (stateUpdate.currentPlayingPeriodId == null) { + int windowIndex = + currentTimeline.value.getWindowIndexFromUuid(stateUpdate.currentPlayingItemUuid); + periodUid = + (PeriodUid) + currentTimeline.value.getPeriodPosition( + window, + scratchPeriod, + windowIndex, + C.msToUs(stateUpdate.currentPlaybackPositionMs)) + .first; + } else { + periodUid = + ExoCastTimeline.createPeriodUid( + stateUpdate.currentPlayingItemUuid, stateUpdate.currentPlayingPeriodId); + } + setPlaybackPositionInternal( + periodUid, stateUpdate.currentPlaybackPositionMs, stateUpdate.discontinuityReason); + } + + if (sequence >= isLoading.sequence && stateUpdate.isLoading != null) { + setIsLoadingInternal(stateUpdate.isLoading); + } + + if (sequence >= playWhenReady.sequence && stateUpdate.playWhenReady != null) { + setPlayWhenReadyInternal(stateUpdate.playWhenReady); + } + + if (sequence >= shuffleModeEnabled.sequence && stateUpdate.shuffleModeEnabled != null) { + setShuffleModeEnabledInternal(stateUpdate.shuffleModeEnabled); + } + + if (sequence >= repeatMode.sequence && stateUpdate.repeatMode != null) { + setRepeatModeInternal(stateUpdate.repeatMode); + } + + if (sequence >= playbackParameters.sequence && stateUpdate.playbackParameters != null) { + setPlaybackParametersInternal(stateUpdate.playbackParameters); + } + + TrackSelectionParameters parameters = stateUpdate.trackSelectionParameters; + if (sequence >= trackselectionParameters.sequence && parameters != null) { + trackselectionParameters.value = + trackselectionParameters + .value + .buildUpon() + .setDisabledTextTrackSelectionFlags(parameters.disabledTextTrackSelectionFlags) + .setPreferredAudioLanguage(parameters.preferredAudioLanguage) + .setPreferredTextLanguage(parameters.preferredTextLanguage) + .setSelectUndeterminedTextLanguage(parameters.selectUndeterminedTextLanguage) + .build(); + } + + flushNotifications(); + } + } + + private static final class StateHolder { + + public T value; + public long sequence; + + public StateHolder(T initialValue) { + value = initialValue; + sequence = CastSessionManager.SEQUENCE_NUMBER_UNSET; + } + } + + private final class ListenerNotificationTask { + + private final Iterator listenersSnapshot; + private final ListenerInvocation listenerInvocation; + + private ListenerNotificationTask(ListenerInvocation listenerInvocation) { + this.listenersSnapshot = listeners.iterator(); + this.listenerInvocation = listenerInvocation; + } + + public void execute() { + while (listenersSnapshot.hasNext()) { + listenersSnapshot.next().invoke(listenerInvocation); + } + } + } +} diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/ExoCastTimeline.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/ExoCastTimeline.java new file mode 100644 index 0000000000..115536ac4c --- /dev/null +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/ExoCastTimeline.java @@ -0,0 +1,342 @@ +/* + * Copyright (C) 2018 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 androidx.annotation.Nullable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.source.ShuffleOrder; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Util; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * A {@link Timeline} for Cast receiver app media queues. + * + *

Each {@link MediaItem} in the timeline is exposed as a window. Unprepared media items are + * exposed as an unset-duration {@link Window}, with a single unset-duration {@link Period}. + */ +/* package */ final class ExoCastTimeline extends Timeline { + + /** Opaque object that uniquely identifies a period across timeline changes. */ + public interface PeriodUid {} + + /** A timeline for an empty media queue. */ + public static final ExoCastTimeline EMPTY = + createTimelineFor( + Collections.emptyList(), Collections.emptyMap(), new ShuffleOrder.DefaultShuffleOrder(0)); + + /** + * Creates {@link PeriodUid} from the given arguments. + * + * @param itemUuid The UUID that identifies the item. + * @param periodId The id of the period for which the unique identifier is required. + * @return An opaque unique identifier for a period. + */ + public static PeriodUid createPeriodUid(UUID itemUuid, Object periodId) { + return new PeriodUidImpl(itemUuid, periodId); + } + + /** + * Returns a new timeline representing the given media queue information. + * + * @param mediaItems The media items conforming the timeline. + * @param mediaItemInfoMap Maps {@link MediaItem media items} in {@code mediaItems} to a {@link + * MediaItemInfo} through their {@link MediaItem#uuid}. Media items may not have a {@link + * MediaItemInfo} mapped to them. + * @param shuffleOrder The {@link ShuffleOrder} of the timeline. {@link ShuffleOrder#getLength()} + * must be equal to {@code mediaItems.size()}. + * @return A new timeline representing the given media queue information. + */ + public static ExoCastTimeline createTimelineFor( + List mediaItems, + Map mediaItemInfoMap, + ShuffleOrder shuffleOrder) { + Assertions.checkArgument(mediaItems.size() == shuffleOrder.getLength()); + int[] accumulativePeriodCount = new int[mediaItems.size()]; + int periodCount = 0; + for (int i = 0; i < accumulativePeriodCount.length; i++) { + periodCount += getInfoOrEmpty(mediaItemInfoMap, mediaItems.get(i).uuid).periods.size(); + accumulativePeriodCount[i] = periodCount; + } + HashMap uuidToIndex = new HashMap<>(); + for (int i = 0; i < mediaItems.size(); i++) { + uuidToIndex.put(mediaItems.get(i).uuid, i); + } + return new ExoCastTimeline( + Collections.unmodifiableList(new ArrayList<>(mediaItems)), + Collections.unmodifiableMap(new HashMap<>(mediaItemInfoMap)), + Collections.unmodifiableMap(new HashMap<>(uuidToIndex)), + shuffleOrder, + accumulativePeriodCount); + } + + // Timeline backing information. + private final List mediaItems; + private final Map mediaItemInfoMap; + private final ShuffleOrder shuffleOrder; + + // Precomputed for quick access. + private final Map uuidToIndex; + private final int[] accumulativePeriodCount; + + private ExoCastTimeline( + List mediaItems, + Map mediaItemInfoMap, + Map uuidToIndex, + ShuffleOrder shuffleOrder, + int[] accumulativePeriodCount) { + this.mediaItems = mediaItems; + this.mediaItemInfoMap = mediaItemInfoMap; + this.uuidToIndex = uuidToIndex; + this.shuffleOrder = shuffleOrder; + this.accumulativePeriodCount = accumulativePeriodCount; + } + + /** + * Returns whether the given media queue information would produce a timeline equivalent to this + * one. + * + * @see ExoCastTimeline#createTimelineFor(List, Map, ShuffleOrder) + */ + public boolean representsMediaQueue( + List mediaItems, + Map mediaItemInfoMap, + ShuffleOrder shuffleOrder) { + if (this.shuffleOrder.getLength() != shuffleOrder.getLength()) { + return false; + } + + int index = shuffleOrder.getFirstIndex(); + if (this.shuffleOrder.getFirstIndex() != index) { + return false; + } + while (index != C.INDEX_UNSET) { + int nextIndex = shuffleOrder.getNextIndex(index); + if (nextIndex != this.shuffleOrder.getNextIndex(index)) { + return false; + } + index = nextIndex; + } + + if (mediaItems.size() != this.mediaItems.size()) { + return false; + } + for (int i = 0; i < mediaItems.size(); i++) { + UUID uuid = mediaItems.get(i).uuid; + MediaItemInfo mediaItemInfo = getInfoOrEmpty(mediaItemInfoMap, uuid); + if (!uuid.equals(this.mediaItems.get(i).uuid) + || !mediaItemInfo.equals(getInfoOrEmpty(this.mediaItemInfoMap, uuid))) { + return false; + } + } + return true; + } + + /** + * Returns the index of the window that contains the period identified by the given {@code + * periodUid} or {@link C#INDEX_UNSET} if this timeline does not contain any period with the given + * {@code periodUid}. + */ + public int getWindowIndexContainingPeriod(PeriodUid periodUid) { + if (!(periodUid instanceof PeriodUidImpl)) { + return C.INDEX_UNSET; + } + return getWindowIndexFromUuid(((PeriodUidImpl) periodUid).itemUuid); + } + + /** + * Returns the index of the window that represents the media item with the given {@code uuid} or + * {@link C#INDEX_UNSET} if no item in this timeline has the given {@code uuid}. + */ + public int getWindowIndexFromUuid(UUID uuid) { + Integer index = uuidToIndex.get(uuid); + return index != null ? index : C.INDEX_UNSET; + } + + // Timeline implementation. + + @Override + public int getWindowCount() { + return mediaItems.size(); + } + + @Override + public Window getWindow( + int windowIndex, Window window, boolean setTag, long defaultPositionProjectionUs) { + MediaItem mediaItem = mediaItems.get(windowIndex); + MediaItemInfo mediaItemInfo = getInfoOrEmpty(mediaItemInfoMap, mediaItem.uuid); + return window.set( + /* tag= */ setTag ? mediaItem.attachment : null, + /* presentationStartTimeMs= */ C.TIME_UNSET, + /* windowStartTimeMs= */ C.TIME_UNSET, + /* isSeekable= */ mediaItemInfo.isSeekable, + /* isDynamic= */ mediaItemInfo.isDynamic, + /* defaultPositionUs= */ mediaItemInfo.defaultStartPositionUs, + /* durationUs= */ mediaItemInfo.windowDurationUs, + /* firstPeriodIndex= */ windowIndex == 0 ? 0 : accumulativePeriodCount[windowIndex - 1], + /* lastPeriodIndex= */ accumulativePeriodCount[windowIndex] - 1, + mediaItemInfo.positionInFirstPeriodUs); + } + + @Override + public int getPeriodCount() { + return mediaItems.isEmpty() ? 0 : accumulativePeriodCount[accumulativePeriodCount.length - 1]; + } + + @Override + public Period getPeriodByUid(Object periodUidObject, Period period) { + return getPeriodInternal((PeriodUidImpl) periodUidObject, period, /* setIds= */ true); + } + + @Override + public Period getPeriod(int periodIndex, Period period, boolean setIds) { + return getPeriodInternal((PeriodUidImpl) getUidOfPeriod(periodIndex), period, setIds); + } + + @Override + public int getIndexOfPeriod(Object uid) { + if (!(uid instanceof PeriodUidImpl)) { + return C.INDEX_UNSET; + } + PeriodUidImpl periodUid = (PeriodUidImpl) uid; + UUID uuid = periodUid.itemUuid; + Integer itemIndex = uuidToIndex.get(uuid); + if (itemIndex == null) { + return C.INDEX_UNSET; + } + int indexOfPeriodInItem = + getInfoOrEmpty(mediaItemInfoMap, uuid).getIndexOfPeriod(periodUid.periodId); + if (indexOfPeriodInItem == C.INDEX_UNSET) { + return C.INDEX_UNSET; + } + return indexOfPeriodInItem + (itemIndex == 0 ? 0 : accumulativePeriodCount[itemIndex - 1]); + } + + @Override + public PeriodUid getUidOfPeriod(int periodIndex) { + int mediaItemIndex = getMediaItemIndexForPeriodIndex(periodIndex); + int periodIndexInMediaItem = + periodIndex - (mediaItemIndex > 0 ? accumulativePeriodCount[mediaItemIndex - 1] : 0); + UUID uuid = mediaItems.get(mediaItemIndex).uuid; + MediaItemInfo mediaItemInfo = getInfoOrEmpty(mediaItemInfoMap, uuid); + return new PeriodUidImpl(uuid, mediaItemInfo.periods.get(periodIndexInMediaItem).id); + } + + @Override + public int getFirstWindowIndex(boolean shuffleModeEnabled) { + return shuffleModeEnabled ? shuffleOrder.getFirstIndex() : 0; + } + + @Override + public int getLastWindowIndex(boolean shuffleModeEnabled) { + return shuffleModeEnabled ? shuffleOrder.getLastIndex() : mediaItems.size() - 1; + } + + @Override + public int getPreviousWindowIndex(int windowIndex, int repeatMode, boolean shuffleModeEnabled) { + if (repeatMode == Player.REPEAT_MODE_ONE) { + return windowIndex; + } else if (windowIndex == getFirstWindowIndex(shuffleModeEnabled)) { + return repeatMode == Player.REPEAT_MODE_OFF + ? C.INDEX_UNSET + : getLastWindowIndex(shuffleModeEnabled); + } else if (shuffleModeEnabled) { + return shuffleOrder.getPreviousIndex(windowIndex); + } else { + return windowIndex - 1; + } + } + + @Override + public int getNextWindowIndex(int windowIndex, int repeatMode, boolean shuffleModeEnabled) { + if (repeatMode == Player.REPEAT_MODE_ONE) { + return windowIndex; + } else if (windowIndex == getLastWindowIndex(shuffleModeEnabled)) { + return repeatMode == Player.REPEAT_MODE_OFF + ? C.INDEX_UNSET + : getFirstWindowIndex(shuffleModeEnabled); + } else if (shuffleModeEnabled) { + return shuffleOrder.getNextIndex(windowIndex); + } else { + return windowIndex + 1; + } + } + + // Internal methods. + + private Period getPeriodInternal(PeriodUidImpl uid, Period period, boolean setIds) { + UUID uuid = uid.itemUuid; + int itemIndex = Assertions.checkNotNull(uuidToIndex.get(uuid)); + MediaItemInfo mediaItemInfo = getInfoOrEmpty(mediaItemInfoMap, uuid); + MediaItemInfo.Period mediaInfoPeriod = + mediaItemInfo.periods.get(mediaItemInfo.getIndexOfPeriod(uid.periodId)); + return period.set( + setIds ? mediaInfoPeriod.id : null, + setIds ? uid : null, + /* windowIndex= */ itemIndex, + mediaInfoPeriod.durationUs, + mediaInfoPeriod.positionInWindowUs); + } + + private int getMediaItemIndexForPeriodIndex(int periodIndex) { + return Util.binarySearchCeil( + accumulativePeriodCount, periodIndex, /* inclusive= */ false, /* stayInBounds= */ false); + } + + private static MediaItemInfo getInfoOrEmpty(Map map, UUID uuid) { + MediaItemInfo info = map.get(uuid); + return info != null ? info : MediaItemInfo.EMPTY; + } + + // Internal classes. + + private static final class PeriodUidImpl implements PeriodUid { + + public final UUID itemUuid; + public final Object periodId; + + private PeriodUidImpl(UUID itemUuid, Object periodId) { + this.itemUuid = itemUuid; + this.periodId = periodId; + } + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + PeriodUidImpl periodUid = (PeriodUidImpl) other; + return itemUuid.equals(periodUid.itemUuid) && periodId.equals(periodUid.periodId); + } + + @Override + public int hashCode() { + int result = itemUuid.hashCode(); + result = 31 * result + periodId.hashCode(); + return result; + } + } +} diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/MediaItemInfo.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/MediaItemInfo.java new file mode 100644 index 0000000000..cb5eff4f37 --- /dev/null +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/MediaItemInfo.java @@ -0,0 +1,160 @@ +/* + * Copyright (C) 2018 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 androidx.annotation.Nullable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.util.Util; +import java.util.Collections; +import java.util.List; + +// TODO (Internal b/119293631): Add ad playback state info. +/** + * Holds dynamic information for a {@link MediaItem}. + * + *

Holds information related to preparation for a specific {@link MediaItem}. Unprepared items + * are associated with an {@link #EMPTY} info object until prepared. + */ +public final class MediaItemInfo { + + /** Placeholder information for media items that have not yet been prepared by the player. */ + public static final MediaItemInfo EMPTY = + new MediaItemInfo( + /* windowDurationUs= */ C.TIME_UNSET, + /* defaultStartPositionUs= */ 0L, + Collections.singletonList( + new Period( + /* id= */ new Object(), + /* durationUs= */ C.TIME_UNSET, + /* positionInWindowUs= */ 0L)), + /* positionInFirstPeriodUs= */ 0L, + /* isSeekable= */ false, + /* isDynamic= */ true); + + /** Holds the information of one of the periods of a {@link MediaItem}. */ + public static final class Period { + + /** + * The id of the period. Must be unique within the {@link MediaItem} but may match with periods + * in other items. + */ + public final Object id; + /** The duration of the period in microseconds. */ + public final long durationUs; + /** The position of this period in the window in microseconds. */ + public final long positionInWindowUs; + // TODO: Add track information. + + public Period(Object id, long durationUs, long positionInWindowUs) { + this.id = id; + this.durationUs = durationUs; + this.positionInWindowUs = positionInWindowUs; + } + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + + Period period = (Period) other; + return durationUs == period.durationUs + && positionInWindowUs == period.positionInWindowUs + && id.equals(period.id); + } + + @Override + public int hashCode() { + int result = id.hashCode(); + result = 31 * result + (int) (durationUs ^ (durationUs >>> 32)); + result = 31 * result + (int) (positionInWindowUs ^ (positionInWindowUs >>> 32)); + return result; + } + } + + /** The duration of the window in microseconds. */ + public final long windowDurationUs; + /** The default start position relative to the start of the window, in microseconds. */ + public final long defaultStartPositionUs; + /** The periods conforming the media item. */ + public final List periods; + /** The position of the window in the first period in microseconds. */ + public final long positionInFirstPeriodUs; + /** Whether it is possible to seek within the window. */ + public final boolean isSeekable; + /** Whether the window may change when the timeline is updated. */ + public final boolean isDynamic; + + public MediaItemInfo( + long windowDurationUs, + long defaultStartPositionUs, + List periods, + long positionInFirstPeriodUs, + boolean isSeekable, + boolean isDynamic) { + this.windowDurationUs = windowDurationUs; + this.defaultStartPositionUs = defaultStartPositionUs; + this.periods = Collections.unmodifiableList(periods); + this.positionInFirstPeriodUs = positionInFirstPeriodUs; + this.isSeekable = isSeekable; + this.isDynamic = isDynamic; + } + + /** + * Returns the index of the period with {@link Period#id} equal to {@code periodId}, or {@link + * C#INDEX_UNSET} if none of the periods has the given id. + */ + public int getIndexOfPeriod(Object periodId) { + for (int i = 0; i < periods.size(); i++) { + if (Util.areEqual(periods.get(i).id, periodId)) { + return i; + } + } + return C.INDEX_UNSET; + } + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + + MediaItemInfo that = (MediaItemInfo) other; + return windowDurationUs == that.windowDurationUs + && defaultStartPositionUs == that.defaultStartPositionUs + && positionInFirstPeriodUs == that.positionInFirstPeriodUs + && isSeekable == that.isSeekable + && isDynamic == that.isDynamic + && periods.equals(that.periods); + } + + @Override + public int hashCode() { + int result = (int) (windowDurationUs ^ (windowDurationUs >>> 32)); + result = 31 * result + (int) (defaultStartPositionUs ^ (defaultStartPositionUs >>> 32)); + result = 31 * result + periods.hashCode(); + result = 31 * result + (int) (positionInFirstPeriodUs ^ (positionInFirstPeriodUs >>> 32)); + result = 31 * result + (isSeekable ? 1 : 0); + result = 31 * result + (isDynamic ? 1 : 0); + return result; + } +} diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/ReceiverAppStateUpdate.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/ReceiverAppStateUpdate.java new file mode 100644 index 0000000000..8cb6056340 --- /dev/null +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/ReceiverAppStateUpdate.java @@ -0,0 +1,633 @@ +/* + * Copyright (C) 2016 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 static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_AD_INSERTION; +import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_INTERNAL; +import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_PERIOD_TRANSITION; +import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SEEK; +import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SEEK_ADJUSTMENT; +import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL; +import static com.google.android.exoplayer2.Player.REPEAT_MODE_OFF; +import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE; +import static com.google.android.exoplayer2.Player.STATE_BUFFERING; +import static com.google.android.exoplayer2.Player.STATE_ENDED; +import static com.google.android.exoplayer2.Player.STATE_IDLE; +import static com.google.android.exoplayer2.Player.STATE_READY; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_DEFAULT_START_POSITION_US; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_DESCRIPTION; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_DISABLED_TEXT_TRACK_SELECTION_FLAGS; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_DISCONTINUITY_REASON; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_DRM_SCHEMES; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_DURATION_US; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_END_POSITION_US; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_ERROR_MESSAGE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_ID; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_IS_DYNAMIC; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_IS_LOADING; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_IS_SEEKABLE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_LICENSE_SERVER; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_MEDIA; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_MEDIA_ITEMS_INFO; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_MEDIA_QUEUE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_MIME_TYPE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_PERIODS; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_PERIOD_ID; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_PITCH; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_PLAYBACK_PARAMETERS; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_PLAYBACK_POSITION; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_PLAYBACK_STATE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_PLAY_WHEN_READY; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_POSITION_IN_FIRST_PERIOD_US; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_POSITION_MS; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_PREFERRED_AUDIO_LANGUAGE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_PREFERRED_TEXT_LANGUAGE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_REPEAT_MODE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_REQUEST_HEADERS; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_SELECT_UNDETERMINED_TEXT_LANGUAGE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_SEQUENCE_NUMBER; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_SHUFFLE_MODE_ENABLED; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_SHUFFLE_ORDER; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_SKIP_SILENCE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_SPEED; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_START_POSITION_US; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_TITLE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_TRACK_SELECTION_PARAMETERS; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_URI; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_UUID; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_WINDOW_DURATION_US; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.STR_DISCONTINUITY_REASON_AD_INSERTION; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.STR_DISCONTINUITY_REASON_INTERNAL; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.STR_DISCONTINUITY_REASON_PERIOD_TRANSITION; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.STR_DISCONTINUITY_REASON_SEEK; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.STR_DISCONTINUITY_REASON_SEEK_ADJUSTMENT; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.STR_REPEAT_MODE_ALL; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.STR_REPEAT_MODE_OFF; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.STR_REPEAT_MODE_ONE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.STR_STATE_BUFFERING; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.STR_STATE_ENDED; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.STR_STATE_IDLE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.STR_STATE_READY; + +import android.net.Uri; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.PlaybackParameters; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.trackselection.TrackSelectionParameters; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Util; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +/** Holds a playback state update from the receiver app. */ +public final class ReceiverAppStateUpdate { + + /** Builder for {@link ReceiverAppStateUpdate}. */ + public static final class Builder { + + private final long sequenceNumber; + @MonotonicNonNull private Boolean playWhenReady; + @MonotonicNonNull private Integer playbackState; + @MonotonicNonNull private List items; + @MonotonicNonNull private Integer repeatMode; + @MonotonicNonNull private Boolean shuffleModeEnabled; + @MonotonicNonNull private Boolean isLoading; + @MonotonicNonNull private PlaybackParameters playbackParameters; + @MonotonicNonNull private TrackSelectionParameters trackSelectionParameters; + @MonotonicNonNull private String errorMessage; + @MonotonicNonNull private Integer discontinuityReason; + @MonotonicNonNull private UUID currentPlayingItemUuid; + @MonotonicNonNull private String currentPlayingPeriodId; + @MonotonicNonNull private Long currentPlaybackPositionMs; + @MonotonicNonNull private List shuffleOrder; + private Map mediaItemsInformation; + + private Builder(long sequenceNumber) { + this.sequenceNumber = sequenceNumber; + mediaItemsInformation = Collections.emptyMap(); + } + + /** See {@link ReceiverAppStateUpdate#playWhenReady}. */ + public Builder setPlayWhenReady(Boolean playWhenReady) { + this.playWhenReady = playWhenReady; + return this; + } + + /** See {@link ReceiverAppStateUpdate#playbackState}. */ + public Builder setPlaybackState(Integer playbackState) { + this.playbackState = playbackState; + return this; + } + + /** See {@link ReceiverAppStateUpdate#items}. */ + public Builder setItems(List items) { + this.items = Collections.unmodifiableList(items); + return this; + } + + /** See {@link ReceiverAppStateUpdate#repeatMode}. */ + public Builder setRepeatMode(Integer repeatMode) { + this.repeatMode = repeatMode; + return this; + } + + /** See {@link ReceiverAppStateUpdate#shuffleModeEnabled}. */ + public Builder setShuffleModeEnabled(Boolean shuffleModeEnabled) { + this.shuffleModeEnabled = shuffleModeEnabled; + return this; + } + + /** See {@link ReceiverAppStateUpdate#isLoading}. */ + public Builder setIsLoading(Boolean isLoading) { + this.isLoading = isLoading; + return this; + } + + /** See {@link ReceiverAppStateUpdate#playbackParameters}. */ + public Builder setPlaybackParameters(PlaybackParameters playbackParameters) { + this.playbackParameters = playbackParameters; + return this; + } + + /** See {@link ReceiverAppStateUpdate#trackSelectionParameters} */ + public Builder setTrackSelectionParameters(TrackSelectionParameters trackSelectionParameters) { + this.trackSelectionParameters = trackSelectionParameters; + return this; + } + + /** See {@link ReceiverAppStateUpdate#errorMessage}. */ + public Builder setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + return this; + } + + /** See {@link ReceiverAppStateUpdate#discontinuityReason}. */ + public Builder setDiscontinuityReason(Integer discontinuityReason) { + this.discontinuityReason = discontinuityReason; + return this; + } + + /** + * See {@link ReceiverAppStateUpdate#currentPlayingItemUuid} and {@link + * ReceiverAppStateUpdate#currentPlaybackPositionMs}. + */ + public Builder setPlaybackPosition( + UUID currentPlayingItemUuid, + String currentPlayingPeriodId, + Long currentPlaybackPositionMs) { + this.currentPlayingItemUuid = currentPlayingItemUuid; + this.currentPlayingPeriodId = currentPlayingPeriodId; + this.currentPlaybackPositionMs = currentPlaybackPositionMs; + return this; + } + + /** + * See {@link ReceiverAppStateUpdate#currentPlayingItemUuid} and {@link + * ReceiverAppStateUpdate#currentPlaybackPositionMs}. + */ + public Builder setMediaItemsInformation(Map mediaItemsInformation) { + this.mediaItemsInformation = Collections.unmodifiableMap(mediaItemsInformation); + return this; + } + + /** See {@link ReceiverAppStateUpdate#shuffleOrder}. */ + public Builder setShuffleOrder(List shuffleOrder) { + this.shuffleOrder = Collections.unmodifiableList(shuffleOrder); + return this; + } + + /** + * Returns a new {@link ReceiverAppStateUpdate} instance with the current values in this + * builder. + */ + public ReceiverAppStateUpdate build() { + return new ReceiverAppStateUpdate( + sequenceNumber, + playWhenReady, + playbackState, + items, + repeatMode, + shuffleModeEnabled, + isLoading, + playbackParameters, + trackSelectionParameters, + errorMessage, + discontinuityReason, + currentPlayingItemUuid, + currentPlayingPeriodId, + currentPlaybackPositionMs, + mediaItemsInformation, + shuffleOrder); + } + } + + /** Returns a {@link ReceiverAppStateUpdate} builder. */ + public static Builder builder(long sequenceNumber) { + return new Builder(sequenceNumber); + } + + /** + * Creates an instance from parsing a state update received from the Receiver App. + * + * @param jsonMessage The state update encoded as a JSON string. + * @return The parsed state update. + * @throws JSONException If an error is encountered when parsing the {@code jsonMessage}. + */ + public static ReceiverAppStateUpdate fromJsonMessage(String jsonMessage) throws JSONException { + JSONObject stateAsJson = new JSONObject(jsonMessage); + Builder builder = builder(stateAsJson.getLong(KEY_SEQUENCE_NUMBER)); + + if (stateAsJson.has(KEY_PLAY_WHEN_READY)) { + builder.setPlayWhenReady(stateAsJson.getBoolean(KEY_PLAY_WHEN_READY)); + } + + if (stateAsJson.has(KEY_PLAYBACK_STATE)) { + builder.setPlaybackState( + playbackStateStringToConstant(stateAsJson.getString(KEY_PLAYBACK_STATE))); + } + + if (stateAsJson.has(KEY_MEDIA_QUEUE)) { + builder.setItems( + toMediaItemArrayList(Assertions.checkNotNull(stateAsJson.optJSONArray(KEY_MEDIA_QUEUE)))); + } + + if (stateAsJson.has(KEY_REPEAT_MODE)) { + builder.setRepeatMode(stringToRepeatMode(stateAsJson.getString(KEY_REPEAT_MODE))); + } + + if (stateAsJson.has(KEY_SHUFFLE_MODE_ENABLED)) { + builder.setShuffleModeEnabled(stateAsJson.getBoolean(KEY_SHUFFLE_MODE_ENABLED)); + } + + if (stateAsJson.has(KEY_IS_LOADING)) { + builder.setIsLoading(stateAsJson.getBoolean(KEY_IS_LOADING)); + } + + if (stateAsJson.has(KEY_PLAYBACK_PARAMETERS)) { + builder.setPlaybackParameters( + toPlaybackParameters( + Assertions.checkNotNull(stateAsJson.optJSONObject(KEY_PLAYBACK_PARAMETERS)))); + } + + if (stateAsJson.has(KEY_TRACK_SELECTION_PARAMETERS)) { + JSONObject trackSelectionParametersJson = + stateAsJson.getJSONObject(KEY_TRACK_SELECTION_PARAMETERS); + TrackSelectionParameters parameters = + TrackSelectionParameters.DEFAULT + .buildUpon() + .setPreferredTextLanguage( + trackSelectionParametersJson.getString(KEY_PREFERRED_TEXT_LANGUAGE)) + .setPreferredAudioLanguage( + trackSelectionParametersJson.getString(KEY_PREFERRED_AUDIO_LANGUAGE)) + .setSelectUndeterminedTextLanguage( + trackSelectionParametersJson.getBoolean(KEY_SELECT_UNDETERMINED_TEXT_LANGUAGE)) + .setDisabledTextTrackSelectionFlags( + jsonArrayToSelectionFlags( + trackSelectionParametersJson.getJSONArray( + KEY_DISABLED_TEXT_TRACK_SELECTION_FLAGS))) + .build(); + builder.setTrackSelectionParameters(parameters); + } + + if (stateAsJson.has(KEY_ERROR_MESSAGE)) { + builder.setErrorMessage(stateAsJson.getString(KEY_ERROR_MESSAGE)); + } + + if (stateAsJson.has(KEY_PLAYBACK_POSITION)) { + JSONObject playbackPosition = stateAsJson.getJSONObject(KEY_PLAYBACK_POSITION); + String discontinuityReason = playbackPosition.optString(KEY_DISCONTINUITY_REASON); + if (!discontinuityReason.isEmpty()) { + builder.setDiscontinuityReason(stringToDiscontinuityReason(discontinuityReason)); + } + UUID currentPlayingItemUuid = UUID.fromString(playbackPosition.getString(KEY_UUID)); + String currentPlayingPeriodId = playbackPosition.getString(KEY_PERIOD_ID); + Long currentPlaybackPositionMs = playbackPosition.getLong(KEY_POSITION_MS); + builder.setPlaybackPosition( + currentPlayingItemUuid, currentPlayingPeriodId, currentPlaybackPositionMs); + } + + if (stateAsJson.has(KEY_MEDIA_ITEMS_INFO)) { + HashMap mediaItemInformation = new HashMap<>(); + JSONObject mediaItemsInfo = stateAsJson.getJSONObject(KEY_MEDIA_ITEMS_INFO); + for (Iterator i = mediaItemsInfo.keys(); i.hasNext(); ) { + String key = i.next(); + mediaItemInformation.put( + UUID.fromString(key), jsonToMediaitemInfo(mediaItemsInfo.getJSONObject(key))); + } + builder.setMediaItemsInformation(mediaItemInformation); + } + + if (stateAsJson.has(KEY_SHUFFLE_ORDER)) { + ArrayList shuffleOrder = new ArrayList<>(); + JSONArray shuffleOrderJson = stateAsJson.getJSONArray(KEY_SHUFFLE_ORDER); + for (int i = 0; i < shuffleOrderJson.length(); i++) { + shuffleOrder.add(shuffleOrderJson.getInt(i)); + } + builder.setShuffleOrder(shuffleOrder); + } + + return builder.build(); + } + + /** The sequence number of the status update. */ + public final long sequenceNumber; + /** Optional {@link Player#getPlayWhenReady playWhenReady} value. */ + @Nullable public final Boolean playWhenReady; + /** Optional {@link Player#getPlaybackState() playbackState}. */ + @Nullable public final Integer playbackState; + /** Optional list of media items. */ + @Nullable public final List items; + /** Optional {@link Player#getRepeatMode() repeatMode}. */ + @Nullable public final Integer repeatMode; + /** Optional {@link Player#getShuffleModeEnabled() shuffleMode}. */ + @Nullable public final Boolean shuffleModeEnabled; + /** Optional {@link Player#isLoading() isLoading} value. */ + @Nullable public final Boolean isLoading; + /** Optional {@link Player#getPlaybackParameters() playbackParameters}. */ + @Nullable public final PlaybackParameters playbackParameters; + /** Optional {@link TrackSelectionParameters}. */ + @Nullable public final TrackSelectionParameters trackSelectionParameters; + /** Optional error message string. */ + @Nullable public final String errorMessage; + /** + * Optional reason for a {@link Player.EventListener#onPositionDiscontinuity(int) discontinuity } + * in the playback position. + */ + @Nullable public final Integer discontinuityReason; + /** Optional {@link UUID} of the {@link Player#getCurrentWindowIndex() currently played item}. */ + @Nullable public final UUID currentPlayingItemUuid; + /** Optional id of the current {@link Player#getCurrentPeriodIndex() period being played}. */ + @Nullable public final String currentPlayingPeriodId; + /** Optional {@link Player#getCurrentPosition() playbackPosition} in milliseconds. */ + @Nullable public final Long currentPlaybackPositionMs; + /** Holds information about the {@link MediaItem media items} in the media queue. */ + public final Map mediaItemsInformation; + /** Holds the indices of the media queue items in shuffle order. */ + @Nullable public final List shuffleOrder; + + /** Creates an instance with the given values. */ + private ReceiverAppStateUpdate( + long sequenceNumber, + @Nullable Boolean playWhenReady, + @Nullable Integer playbackState, + @Nullable List items, + @Nullable Integer repeatMode, + @Nullable Boolean shuffleModeEnabled, + @Nullable Boolean isLoading, + @Nullable PlaybackParameters playbackParameters, + @Nullable TrackSelectionParameters trackSelectionParameters, + @Nullable String errorMessage, + @Nullable Integer discontinuityReason, + @Nullable UUID currentPlayingItemUuid, + @Nullable String currentPlayingPeriodId, + @Nullable Long currentPlaybackPositionMs, + Map mediaItemsInformation, + @Nullable List shuffleOrder) { + this.sequenceNumber = sequenceNumber; + this.playWhenReady = playWhenReady; + this.playbackState = playbackState; + this.items = items; + this.repeatMode = repeatMode; + this.shuffleModeEnabled = shuffleModeEnabled; + this.isLoading = isLoading; + this.playbackParameters = playbackParameters; + this.trackSelectionParameters = trackSelectionParameters; + this.errorMessage = errorMessage; + this.discontinuityReason = discontinuityReason; + this.currentPlayingItemUuid = currentPlayingItemUuid; + this.currentPlayingPeriodId = currentPlayingPeriodId; + this.currentPlaybackPositionMs = currentPlaybackPositionMs; + this.mediaItemsInformation = mediaItemsInformation; + this.shuffleOrder = shuffleOrder; + } + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + ReceiverAppStateUpdate that = (ReceiverAppStateUpdate) other; + + return sequenceNumber == that.sequenceNumber + && Util.areEqual(playWhenReady, that.playWhenReady) + && Util.areEqual(playbackState, that.playbackState) + && Util.areEqual(items, that.items) + && Util.areEqual(repeatMode, that.repeatMode) + && Util.areEqual(shuffleModeEnabled, that.shuffleModeEnabled) + && Util.areEqual(isLoading, that.isLoading) + && Util.areEqual(playbackParameters, that.playbackParameters) + && Util.areEqual(trackSelectionParameters, that.trackSelectionParameters) + && Util.areEqual(errorMessage, that.errorMessage) + && Util.areEqual(discontinuityReason, that.discontinuityReason) + && Util.areEqual(currentPlayingItemUuid, that.currentPlayingItemUuid) + && Util.areEqual(currentPlayingPeriodId, that.currentPlayingPeriodId) + && Util.areEqual(currentPlaybackPositionMs, that.currentPlaybackPositionMs) + && Util.areEqual(mediaItemsInformation, that.mediaItemsInformation) + && Util.areEqual(shuffleOrder, that.shuffleOrder); + } + + @Override + public int hashCode() { + int result = (int) (sequenceNumber ^ (sequenceNumber >>> 32)); + result = 31 * result + (playWhenReady != null ? playWhenReady.hashCode() : 0); + result = 31 * result + (playbackState != null ? playbackState.hashCode() : 0); + result = 31 * result + (items != null ? items.hashCode() : 0); + result = 31 * result + (repeatMode != null ? repeatMode.hashCode() : 0); + result = 31 * result + (shuffleModeEnabled != null ? shuffleModeEnabled.hashCode() : 0); + result = 31 * result + (isLoading != null ? isLoading.hashCode() : 0); + result = 31 * result + (playbackParameters != null ? playbackParameters.hashCode() : 0); + result = + 31 * result + (trackSelectionParameters != null ? trackSelectionParameters.hashCode() : 0); + result = 31 * result + (errorMessage != null ? errorMessage.hashCode() : 0); + result = 31 * result + (discontinuityReason != null ? discontinuityReason.hashCode() : 0); + result = 31 * result + (currentPlayingItemUuid != null ? currentPlayingItemUuid.hashCode() : 0); + result = 31 * result + (currentPlayingPeriodId != null ? currentPlayingPeriodId.hashCode() : 0); + result = + 31 * result + + (currentPlaybackPositionMs != null ? currentPlaybackPositionMs.hashCode() : 0); + result = 31 * result + mediaItemsInformation.hashCode(); + result = 31 * result + (shuffleOrder != null ? shuffleOrder.hashCode() : 0); + return result; + } + + // Internal methods. + + @VisibleForTesting + /* package */ static List toMediaItemArrayList(JSONArray mediaItemsAsJson) + throws JSONException { + ArrayList mediaItems = new ArrayList<>(); + for (int i = 0; i < mediaItemsAsJson.length(); i++) { + mediaItems.add(toMediaItem(mediaItemsAsJson.getJSONObject(i))); + } + return mediaItems; + } + + private static MediaItem toMediaItem(JSONObject mediaItemAsJson) throws JSONException { + MediaItem.Builder builder = new MediaItem.Builder(); + builder.setUuid(UUID.fromString(mediaItemAsJson.getString(KEY_UUID))); + builder.setTitle(mediaItemAsJson.getString(KEY_TITLE)); + builder.setDescription(mediaItemAsJson.getString(KEY_DESCRIPTION)); + builder.setMedia(jsonToUriBundle(mediaItemAsJson.getJSONObject(KEY_MEDIA))); + // TODO(Internal b/118431961): Add attachment management. + + builder.setDrmSchemes(jsonArrayToDrmSchemes(mediaItemAsJson.getJSONArray(KEY_DRM_SCHEMES))); + if (mediaItemAsJson.has(KEY_START_POSITION_US)) { + builder.setStartPositionUs(mediaItemAsJson.getLong(KEY_START_POSITION_US)); + } + if (mediaItemAsJson.has(KEY_END_POSITION_US)) { + builder.setEndPositionUs(mediaItemAsJson.getLong(KEY_END_POSITION_US)); + } + builder.setMimeType(mediaItemAsJson.getString(KEY_MIME_TYPE)); + return builder.build(); + } + + private static PlaybackParameters toPlaybackParameters(JSONObject parameters) + throws JSONException { + float speed = (float) parameters.getDouble(KEY_SPEED); + float pitch = (float) parameters.getDouble(KEY_PITCH); + boolean skipSilence = parameters.getBoolean(KEY_SKIP_SILENCE); + return new PlaybackParameters(speed, pitch, skipSilence); + } + + private static int playbackStateStringToConstant(String string) { + switch (string) { + case STR_STATE_IDLE: + return STATE_IDLE; + case STR_STATE_BUFFERING: + return STATE_BUFFERING; + case STR_STATE_READY: + return STATE_READY; + case STR_STATE_ENDED: + return STATE_ENDED; + default: + throw new AssertionError("Unexpected state string: " + string); + } + } + + private static Integer stringToRepeatMode(String repeatModeStr) { + switch (repeatModeStr) { + case STR_REPEAT_MODE_OFF: + return REPEAT_MODE_OFF; + case STR_REPEAT_MODE_ONE: + return REPEAT_MODE_ONE; + case STR_REPEAT_MODE_ALL: + return REPEAT_MODE_ALL; + default: + throw new AssertionError("Illegal repeat mode: " + repeatModeStr); + } + } + + private static Integer stringToDiscontinuityReason(String discontinuityReasonStr) { + switch (discontinuityReasonStr) { + case STR_DISCONTINUITY_REASON_PERIOD_TRANSITION: + return DISCONTINUITY_REASON_PERIOD_TRANSITION; + case STR_DISCONTINUITY_REASON_SEEK: + return DISCONTINUITY_REASON_SEEK; + case STR_DISCONTINUITY_REASON_SEEK_ADJUSTMENT: + return DISCONTINUITY_REASON_SEEK_ADJUSTMENT; + case STR_DISCONTINUITY_REASON_AD_INSERTION: + return DISCONTINUITY_REASON_AD_INSERTION; + case STR_DISCONTINUITY_REASON_INTERNAL: + return DISCONTINUITY_REASON_INTERNAL; + default: + throw new AssertionError("Illegal discontinuity reason: " + discontinuityReasonStr); + } + } + + @C.SelectionFlags + private static int jsonArrayToSelectionFlags(JSONArray array) throws JSONException { + int result = 0; + for (int i = 0; i < array.length(); i++) { + switch (array.getString(i)) { + case ExoCastConstants.STR_SELECTION_FLAG_AUTOSELECT: + result |= C.SELECTION_FLAG_AUTOSELECT; + break; + case ExoCastConstants.STR_SELECTION_FLAG_FORCED: + result |= C.SELECTION_FLAG_FORCED; + break; + case ExoCastConstants.STR_SELECTION_FLAG_DEFAULT: + result |= C.SELECTION_FLAG_DEFAULT; + break; + default: + // Do nothing. + break; + } + } + return result; + } + + private static List jsonArrayToDrmSchemes(JSONArray drmSchemesAsJson) + throws JSONException { + ArrayList drmSchemes = new ArrayList<>(); + for (int i = 0; i < drmSchemesAsJson.length(); i++) { + JSONObject drmSchemeAsJson = drmSchemesAsJson.getJSONObject(i); + MediaItem.UriBundle uriBundle = + drmSchemeAsJson.has(KEY_LICENSE_SERVER) + ? jsonToUriBundle(drmSchemeAsJson.getJSONObject(KEY_LICENSE_SERVER)) + : null; + drmSchemes.add( + new MediaItem.DrmScheme(UUID.fromString(drmSchemeAsJson.getString(KEY_UUID)), uriBundle)); + } + return Collections.unmodifiableList(drmSchemes); + } + + private static MediaItem.UriBundle jsonToUriBundle(JSONObject json) throws JSONException { + Uri uri = Uri.parse(json.getString(KEY_URI)); + JSONObject requestHeadersAsJson = json.getJSONObject(KEY_REQUEST_HEADERS); + HashMap requestHeaders = new HashMap<>(); + for (Iterator i = requestHeadersAsJson.keys(); i.hasNext(); ) { + String key = i.next(); + requestHeaders.put(key, requestHeadersAsJson.getString(key)); + } + return new MediaItem.UriBundle(uri, requestHeaders); + } + + private static MediaItemInfo jsonToMediaitemInfo(JSONObject json) throws JSONException { + long durationUs = json.getLong(KEY_WINDOW_DURATION_US); + long defaultPositionUs = json.optLong(KEY_DEFAULT_START_POSITION_US, /* fallback= */ 0L); + JSONArray periodsJson = json.getJSONArray(KEY_PERIODS); + ArrayList periods = new ArrayList<>(); + long positionInFirstPeriodUs = json.getLong(KEY_POSITION_IN_FIRST_PERIOD_US); + + long windowPositionUs = -positionInFirstPeriodUs; + for (int i = 0; i < periodsJson.length(); i++) { + JSONObject periodJson = periodsJson.getJSONObject(i); + long periodDurationUs = periodJson.optLong(KEY_DURATION_US, C.TIME_UNSET); + periods.add( + new MediaItemInfo.Period( + periodJson.getString(KEY_ID), periodDurationUs, windowPositionUs)); + windowPositionUs += periodDurationUs; + } + boolean isDynamic = json.getBoolean(KEY_IS_DYNAMIC); + boolean isSeekable = json.getBoolean(KEY_IS_SEEKABLE); + return new MediaItemInfo( + durationUs, defaultPositionUs, periods, positionInFirstPeriodUs, isSeekable, isDynamic); + } +} diff --git a/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/ExoCastMessageTest.java b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/ExoCastMessageTest.java new file mode 100644 index 0000000000..b900a78937 --- /dev/null +++ b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/ExoCastMessageTest.java @@ -0,0 +1,436 @@ +/* + * Copyright (C) 2018 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 static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_ARGS; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_DESCRIPTION; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_DISABLED_TEXT_TRACK_SELECTION_FLAGS; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_DRM_SCHEMES; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_END_POSITION_US; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_INDEX; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_ITEMS; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_LICENSE_SERVER; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_MEDIA; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_METHOD; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_MIME_TYPE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_PITCH; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_PLAY_WHEN_READY; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_POSITION_MS; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_PREFERRED_AUDIO_LANGUAGE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_PREFERRED_TEXT_LANGUAGE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_REPEAT_MODE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_REQUEST_HEADERS; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_SELECT_UNDETERMINED_TEXT_LANGUAGE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_SEQUENCE_NUMBER; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_SHUFFLE_MODE_ENABLED; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_SHUFFLE_ORDER; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_SKIP_SILENCE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_SPEED; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_START_POSITION_US; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_TITLE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_URI; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_UUID; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_UUIDS; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.METHOD_ADD_ITEMS; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.METHOD_MOVE_ITEM; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.METHOD_REMOVE_ITEMS; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.METHOD_SEEK_TO; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.METHOD_SET_PLAYBACK_PARAMETERS; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.METHOD_SET_PLAY_WHEN_READY; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.METHOD_SET_REPEAT_MODE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.METHOD_SET_SHUFFLE_MODE_ENABLED; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.METHOD_SET_TRACK_SELECTION_PARAMETERS; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.STR_REPEAT_MODE_ALL; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.STR_REPEAT_MODE_OFF; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.STR_REPEAT_MODE_ONE; +import static com.google.common.truth.Truth.assertThat; + +import android.net.Uri; +import androidx.annotation.Nullable; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.PlaybackParameters; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.ext.cast.MediaItem.DrmScheme; +import com.google.android.exoplayer2.ext.cast.MediaItem.UriBundle; +import com.google.android.exoplayer2.source.ShuffleOrder; +import com.google.android.exoplayer2.trackselection.TrackSelectionParameters; +import com.google.android.exoplayer2.util.MimeTypes; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit tests for {@link ExoCastMessage}. */ +@RunWith(AndroidJUnit4.class) +public class ExoCastMessageTest { + + @Test + public void addItems_withUnsetIndex_doesNotAddIndexToJson() throws JSONException { + MediaItem sampleItem = new MediaItem.Builder().build(); + ExoCastMessage message = + new ExoCastMessage.AddItems( + C.INDEX_UNSET, + Collections.singletonList(sampleItem), + new ShuffleOrder.UnshuffledShuffleOrder(1)); + JSONObject messageAsJson = new JSONObject(message.toJsonString(/* sequenceNumber= */ 0)); + JSONObject arguments = messageAsJson.getJSONObject(KEY_ARGS); + JSONArray items = arguments.getJSONArray(KEY_ITEMS); + + assertThat(messageAsJson.getLong(KEY_SEQUENCE_NUMBER)).isEqualTo(0); + assertThat(messageAsJson.getString(KEY_METHOD)).isEqualTo(METHOD_ADD_ITEMS); + assertThat(arguments.has(KEY_INDEX)).isFalse(); + assertThat(items.length()).isEqualTo(1); + } + + @Test + public void addItems_withMultipleItems_producesExpectedJsonList() throws JSONException { + MediaItem sampleItem1 = new MediaItem.Builder().build(); + MediaItem sampleItem2 = new MediaItem.Builder().build(); + ExoCastMessage message = + new ExoCastMessage.AddItems( + 1, Arrays.asList(sampleItem2, sampleItem1), new ShuffleOrder.UnshuffledShuffleOrder(2)); + JSONObject messageAsJson = new JSONObject(message.toJsonString(/* sequenceNumber= */ 1)); + JSONObject arguments = messageAsJson.getJSONObject(KEY_ARGS); + JSONArray items = arguments.getJSONArray(KEY_ITEMS); + + assertThat(messageAsJson.getLong(KEY_SEQUENCE_NUMBER)).isEqualTo(1); + assertThat(messageAsJson.getString(KEY_METHOD)).isEqualTo(METHOD_ADD_ITEMS); + assertThat(arguments.getInt(KEY_INDEX)).isEqualTo(1); + assertThat(items.length()).isEqualTo(2); + } + + @Test + public void addItems_withoutItemOptionalFields_doesNotAddFieldsToJson() throws JSONException { + MediaItem itemWithoutOptionalFields = + new MediaItem.Builder() + .setTitle("title") + .setMimeType(MimeTypes.AUDIO_MP4) + .setDescription("desc") + .setDrmSchemes(Collections.singletonList(new DrmScheme(C.WIDEVINE_UUID, null))) + .setMedia("www.google.com") + .build(); + ExoCastMessage message = + new ExoCastMessage.AddItems( + C.INDEX_UNSET, + Collections.singletonList(itemWithoutOptionalFields), + new ShuffleOrder.UnshuffledShuffleOrder(1)); + JSONObject messageAsJson = new JSONObject(message.toJsonString(/* sequenceNumber= */ 0)); + JSONObject arguments = messageAsJson.getJSONObject(KEY_ARGS); + JSONArray items = arguments.getJSONArray(KEY_ITEMS); + + assertJsonEqualsMediaItem(items.getJSONObject(/* index= */ 0), itemWithoutOptionalFields); + } + + @Test + public void addItems_withAllItemFields_addsFieldsToJson() throws JSONException { + HashMap headersMedia = new HashMap<>(); + headersMedia.put("header1", "value1"); + headersMedia.put("header2", "value2"); + UriBundle media = new UriBundle(Uri.parse("www.google.com"), headersMedia); + + HashMap headersWidevine = new HashMap<>(); + headersWidevine.put("widevine", "value"); + UriBundle widevingUriBundle = new UriBundle(Uri.parse("www.widevine.com"), headersWidevine); + + HashMap headersPlayready = new HashMap<>(); + headersPlayready.put("playready", "value"); + UriBundle playreadyUriBundle = new UriBundle(Uri.parse("www.playready.com"), headersPlayready); + + DrmScheme[] drmSchemes = + new DrmScheme[] { + new DrmScheme(C.WIDEVINE_UUID, widevingUriBundle), + new DrmScheme(C.PLAYREADY_UUID, playreadyUriBundle) + }; + MediaItem itemWithAllFields = + new MediaItem.Builder() + .setTitle("title") + .setMimeType(MimeTypes.VIDEO_MP4) + .setDescription("desc") + .setStartPositionUs(3) + .setEndPositionUs(10) + .setDrmSchemes(Arrays.asList(drmSchemes)) + .setMedia(media) + .build(); + ExoCastMessage message = + new ExoCastMessage.AddItems( + C.INDEX_UNSET, + Collections.singletonList(itemWithAllFields), + new ShuffleOrder.UnshuffledShuffleOrder(1)); + JSONObject messageAsJson = new JSONObject(message.toJsonString(/* sequenceNumber= */ 0)); + JSONObject arguments = messageAsJson.getJSONObject(KEY_ARGS); + JSONArray items = arguments.getJSONArray(KEY_ITEMS); + + assertJsonEqualsMediaItem(items.getJSONObject(/* index= */ 0), itemWithAllFields); + } + + @Test + public void addItems_withShuffleOrder_producesExpectedJson() throws JSONException { + MediaItem.Builder builder = new MediaItem.Builder(); + MediaItem sampleItem1 = builder.build(); + MediaItem sampleItem2 = builder.build(); + MediaItem sampleItem3 = builder.build(); + MediaItem sampleItem4 = builder.build(); + + ExoCastMessage message = + new ExoCastMessage.AddItems( + C.INDEX_UNSET, + Arrays.asList(sampleItem1, sampleItem2, sampleItem3, sampleItem4), + new ShuffleOrder.DefaultShuffleOrder(new int[] {2, 1, 3, 0}, /* randomSeed= */ 0)); + JSONObject arguments = + new JSONObject(message.toJsonString(/* sequenceNumber= */ 0)).getJSONObject(KEY_ARGS); + JSONArray shuffledIndices = arguments.getJSONArray(KEY_SHUFFLE_ORDER); + assertThat(shuffledIndices.getInt(0)).isEqualTo(2); + assertThat(shuffledIndices.getInt(1)).isEqualTo(1); + assertThat(shuffledIndices.getInt(2)).isEqualTo(3); + assertThat(shuffledIndices.getInt(3)).isEqualTo(0); + } + + @Test + public void moveItem_producesExpectedJson() throws JSONException { + ExoCastMessage message = + new ExoCastMessage.MoveItem( + new UUID(0, 1), + /* index= */ 3, + new ShuffleOrder.DefaultShuffleOrder(new int[] {2, 1, 3, 0}, /* randomSeed= */ 0)); + JSONObject messageAsJson = new JSONObject(message.toJsonString(/* sequenceNumber= */ 1)); + JSONObject arguments = messageAsJson.getJSONObject(KEY_ARGS); + + assertThat(messageAsJson.getLong(KEY_SEQUENCE_NUMBER)).isEqualTo(1); + assertThat(messageAsJson.getString(KEY_METHOD)).isEqualTo(METHOD_MOVE_ITEM); + assertThat(arguments.getString(KEY_UUID)).isEqualTo(new UUID(0, 1).toString()); + assertThat(arguments.getInt(KEY_INDEX)).isEqualTo(3); + JSONArray shuffledIndices = arguments.getJSONArray(KEY_SHUFFLE_ORDER); + assertThat(shuffledIndices.getInt(0)).isEqualTo(2); + assertThat(shuffledIndices.getInt(1)).isEqualTo(1); + assertThat(shuffledIndices.getInt(2)).isEqualTo(3); + assertThat(shuffledIndices.getInt(3)).isEqualTo(0); + } + + @Test + public void removeItems_withSingleItem_producesExpectedJson() throws JSONException { + ExoCastMessage message = + new ExoCastMessage.RemoveItems(Collections.singletonList(new UUID(0, 1))); + JSONObject messageAsJson = new JSONObject(message.toJsonString(/* sequenceNumber= */ 0)); + JSONArray uuids = messageAsJson.getJSONObject(KEY_ARGS).getJSONArray(KEY_UUIDS); + + assertThat(messageAsJson.getLong(KEY_SEQUENCE_NUMBER)).isEqualTo(0); + assertThat(messageAsJson.getString(KEY_METHOD)).isEqualTo(METHOD_REMOVE_ITEMS); + assertThat(uuids.length()).isEqualTo(1); + assertThat(uuids.getString(0)).isEqualTo(new UUID(0, 1).toString()); + } + + @Test + public void removeItems_withMultipleItems_producesExpectedJson() throws JSONException { + ExoCastMessage message = + new ExoCastMessage.RemoveItems( + Arrays.asList(new UUID(0, 1), new UUID(0, 2), new UUID(0, 3))); + JSONObject messageAsJson = new JSONObject(message.toJsonString(/* sequenceNumber= */ 0)); + JSONArray uuids = messageAsJson.getJSONObject(KEY_ARGS).getJSONArray(KEY_UUIDS); + + assertThat(messageAsJson.getLong(KEY_SEQUENCE_NUMBER)).isEqualTo(0); + assertThat(messageAsJson.getString(KEY_METHOD)).isEqualTo(METHOD_REMOVE_ITEMS); + assertThat(uuids.length()).isEqualTo(3); + assertThat(uuids.getString(0)).isEqualTo(new UUID(0, 1).toString()); + assertThat(uuids.getString(1)).isEqualTo(new UUID(0, 2).toString()); + assertThat(uuids.getString(2)).isEqualTo(new UUID(0, 3).toString()); + } + + @Test + public void setPlayWhenReady_producesExpectedJson() throws JSONException { + ExoCastMessage message = new ExoCastMessage.SetPlayWhenReady(true); + JSONObject messageAsJson = new JSONObject(message.toJsonString(/* sequenceNumber= */ 0)); + + assertThat(messageAsJson.getLong(KEY_SEQUENCE_NUMBER)).isEqualTo(0); + assertThat(messageAsJson.getString(KEY_METHOD)).isEqualTo(METHOD_SET_PLAY_WHEN_READY); + assertThat(messageAsJson.getJSONObject(KEY_ARGS).getBoolean(KEY_PLAY_WHEN_READY)).isTrue(); + } + + @Test + public void setRepeatMode_withRepeatModeOff_producesExpectedJson() throws JSONException { + ExoCastMessage message = new ExoCastMessage.SetRepeatMode(Player.REPEAT_MODE_OFF); + JSONObject messageAsJson = new JSONObject(message.toJsonString(/* sequenceNumber= */ 0)); + + assertThat(messageAsJson.getLong(KEY_SEQUENCE_NUMBER)).isEqualTo(0); + assertThat(messageAsJson.getString(KEY_METHOD)).isEqualTo(METHOD_SET_REPEAT_MODE); + assertThat(messageAsJson.getJSONObject(KEY_ARGS).getString(KEY_REPEAT_MODE)) + .isEqualTo(STR_REPEAT_MODE_OFF); + } + + @Test + public void setRepeatMode_withRepeatModeOne_producesExpectedJson() throws JSONException { + ExoCastMessage message = new ExoCastMessage.SetRepeatMode(Player.REPEAT_MODE_ONE); + JSONObject messageAsJson = new JSONObject(message.toJsonString(/* sequenceNumber= */ 0)); + + assertThat(messageAsJson.getLong(KEY_SEQUENCE_NUMBER)).isEqualTo(0); + assertThat(messageAsJson.getString(KEY_METHOD)).isEqualTo(METHOD_SET_REPEAT_MODE); + assertThat(messageAsJson.getJSONObject(KEY_ARGS).getString(KEY_REPEAT_MODE)) + .isEqualTo(STR_REPEAT_MODE_ONE); + } + + @Test + public void setRepeatMode_withRepeatModeAll_producesExpectedJson() throws JSONException { + ExoCastMessage message = new ExoCastMessage.SetRepeatMode(Player.REPEAT_MODE_ALL); + JSONObject messageAsJson = new JSONObject(message.toJsonString(/* sequenceNumber= */ 0)); + + assertThat(messageAsJson.getLong(KEY_SEQUENCE_NUMBER)).isEqualTo(0); + assertThat(messageAsJson.getString(KEY_METHOD)).isEqualTo(METHOD_SET_REPEAT_MODE); + assertThat(messageAsJson.getJSONObject(KEY_ARGS).getString(KEY_REPEAT_MODE)) + .isEqualTo(STR_REPEAT_MODE_ALL); + } + + @Test + public void setShuffleModeEnabled_producesExpectedJson() throws JSONException { + ExoCastMessage message = + new ExoCastMessage.SetShuffleModeEnabled(/* shuffleModeEnabled= */ false); + JSONObject messageAsJson = new JSONObject(message.toJsonString(/* sequenceNumber= */ 0)); + + assertThat(messageAsJson.getLong(KEY_SEQUENCE_NUMBER)).isEqualTo(0); + assertThat(messageAsJson.getString(KEY_METHOD)).isEqualTo(METHOD_SET_SHUFFLE_MODE_ENABLED); + assertThat(messageAsJson.getJSONObject(KEY_ARGS).getBoolean(KEY_SHUFFLE_MODE_ENABLED)) + .isFalse(); + } + + @Test + public void seekTo_withPositionInItem_addsPositionField() throws JSONException { + ExoCastMessage message = new ExoCastMessage.SeekTo(new UUID(0, 1), /* positionMs= */ 10); + JSONObject messageAsJson = new JSONObject(message.toJsonString(/* sequenceNumber= */ 0)); + JSONObject arguments = messageAsJson.getJSONObject(KEY_ARGS); + + assertThat(messageAsJson.getLong(KEY_SEQUENCE_NUMBER)).isEqualTo(0); + assertThat(messageAsJson.getString(KEY_METHOD)).isEqualTo(METHOD_SEEK_TO); + assertThat(arguments.getString(KEY_UUID)).isEqualTo(new UUID(0, 1).toString()); + assertThat(arguments.getLong(KEY_POSITION_MS)).isEqualTo(10); + } + + @Test + public void seekTo_withUnsetPosition_doesNotAddPositionField() throws JSONException { + ExoCastMessage message = + new ExoCastMessage.SeekTo(new UUID(0, 1), /* positionMs= */ C.TIME_UNSET); + JSONObject messageAsJson = new JSONObject(message.toJsonString(/* sequenceNumber= */ 0)); + JSONObject arguments = messageAsJson.getJSONObject(KEY_ARGS); + + assertThat(messageAsJson.getLong(KEY_SEQUENCE_NUMBER)).isEqualTo(0); + assertThat(messageAsJson.getString(KEY_METHOD)).isEqualTo(METHOD_SEEK_TO); + assertThat(arguments.getString(KEY_UUID)).isEqualTo(new UUID(0, 1).toString()); + assertThat(arguments.has(KEY_POSITION_MS)).isFalse(); + } + + @Test + public void setPlaybackParameters_producesExpectedJson() throws JSONException { + ExoCastMessage message = + new ExoCastMessage.SetPlaybackParameters( + new PlaybackParameters(/* speed= */ 0.5f, /* pitch= */ 2, /* skipSilence= */ false)); + JSONObject messageAsJson = new JSONObject(message.toJsonString(/* sequenceNumber= */ 0)); + JSONObject arguments = messageAsJson.getJSONObject(KEY_ARGS); + + assertThat(messageAsJson.getLong(KEY_SEQUENCE_NUMBER)).isEqualTo(0); + assertThat(messageAsJson.getString(KEY_METHOD)).isEqualTo(METHOD_SET_PLAYBACK_PARAMETERS); + assertThat(arguments.getDouble(KEY_SPEED)).isEqualTo(0.5); + assertThat(arguments.getDouble(KEY_PITCH)).isEqualTo(2.0); + assertThat(arguments.getBoolean(KEY_SKIP_SILENCE)).isFalse(); + } + + @Test + public void setSelectionParameters_producesExpectedJson() throws JSONException { + ExoCastMessage message = + new ExoCastMessage.SetTrackSelectionParameters( + TrackSelectionParameters.DEFAULT + .buildUpon() + .setDisabledTextTrackSelectionFlags( + C.SELECTION_FLAG_AUTOSELECT | C.SELECTION_FLAG_DEFAULT) + .setSelectUndeterminedTextLanguage(true) + .setPreferredAudioLanguage("esp") + .setPreferredTextLanguage("deu") + .build()); + JSONObject messageAsJson = new JSONObject(message.toJsonString(/* sequenceNumber= */ 0)); + JSONObject arguments = messageAsJson.getJSONObject(KEY_ARGS); + + assertThat(messageAsJson.getLong(KEY_SEQUENCE_NUMBER)).isEqualTo(0); + assertThat(messageAsJson.getString(KEY_METHOD)) + .isEqualTo(METHOD_SET_TRACK_SELECTION_PARAMETERS); + assertThat(arguments.getBoolean(KEY_SELECT_UNDETERMINED_TEXT_LANGUAGE)).isTrue(); + assertThat(arguments.getString(KEY_PREFERRED_AUDIO_LANGUAGE)).isEqualTo("esp"); + assertThat(arguments.getString(KEY_PREFERRED_TEXT_LANGUAGE)).isEqualTo("deu"); + ArrayList selectionFlagStrings = new ArrayList<>(); + JSONArray selectionFlagsJson = arguments.getJSONArray(KEY_DISABLED_TEXT_TRACK_SELECTION_FLAGS); + for (int i = 0; i < selectionFlagsJson.length(); i++) { + selectionFlagStrings.add(selectionFlagsJson.getString(i)); + } + assertThat(selectionFlagStrings).contains(ExoCastConstants.STR_SELECTION_FLAG_AUTOSELECT); + assertThat(selectionFlagStrings).doesNotContain(ExoCastConstants.STR_SELECTION_FLAG_FORCED); + assertThat(selectionFlagStrings).contains(ExoCastConstants.STR_SELECTION_FLAG_DEFAULT); + } + + private static void assertJsonEqualsMediaItem(JSONObject itemAsJson, MediaItem mediaItem) + throws JSONException { + assertThat(itemAsJson.getString(KEY_UUID)).isEqualTo(mediaItem.uuid.toString()); + assertThat(itemAsJson.getString(KEY_TITLE)).isEqualTo(mediaItem.title); + assertThat(itemAsJson.getString(KEY_MIME_TYPE)).isEqualTo(mediaItem.mimeType); + assertThat(itemAsJson.getString(KEY_DESCRIPTION)).isEqualTo(mediaItem.description); + assertJsonMatchesTimestamp(itemAsJson, KEY_START_POSITION_US, mediaItem.startPositionUs); + assertJsonMatchesTimestamp(itemAsJson, KEY_END_POSITION_US, mediaItem.endPositionUs); + assertJsonMatchesUriBundle(itemAsJson, KEY_MEDIA, mediaItem.media); + + List drmSchemes = mediaItem.drmSchemes; + int drmSchemesLength = drmSchemes.size(); + JSONArray drmSchemesAsJson = itemAsJson.getJSONArray(KEY_DRM_SCHEMES); + + assertThat(drmSchemesAsJson.length()).isEqualTo(drmSchemesLength); + for (int i = 0; i < drmSchemesLength; i++) { + DrmScheme drmScheme = drmSchemes.get(i); + JSONObject drmSchemeAsJson = drmSchemesAsJson.getJSONObject(i); + + assertThat(drmSchemeAsJson.getString(KEY_UUID)).isEqualTo(drmScheme.uuid.toString()); + assertJsonMatchesUriBundle(drmSchemeAsJson, KEY_LICENSE_SERVER, drmScheme.licenseServer); + } + } + + private static void assertJsonMatchesUriBundle( + JSONObject jsonObject, String key, @Nullable UriBundle uriBundle) throws JSONException { + if (uriBundle == null) { + assertThat(jsonObject.has(key)).isFalse(); + return; + } + JSONObject uriBundleAsJson = jsonObject.getJSONObject(key); + assertThat(uriBundleAsJson.getString(KEY_URI)).isEqualTo(uriBundle.uri.toString()); + Map requestHeaders = uriBundle.requestHeaders; + JSONObject requestHeadersAsJson = uriBundleAsJson.getJSONObject(KEY_REQUEST_HEADERS); + + assertThat(requestHeadersAsJson.length()).isEqualTo(requestHeaders.size()); + for (String headerKey : requestHeaders.keySet()) { + assertThat(requestHeadersAsJson.getString(headerKey)) + .isEqualTo(requestHeaders.get(headerKey)); + } + } + + private static void assertJsonMatchesTimestamp(JSONObject object, String key, long timestamp) + throws JSONException { + if (timestamp == C.TIME_UNSET) { + assertThat(object.has(key)).isFalse(); + } else { + assertThat(object.getLong(key)).isEqualTo(timestamp); + } + } +} diff --git a/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/ExoCastPlayerTest.java b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/ExoCastPlayerTest.java new file mode 100644 index 0000000000..58f78b090a --- /dev/null +++ b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/ExoCastPlayerTest.java @@ -0,0 +1,1018 @@ +/* + * Copyright (C) 2018 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 static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_ARGS; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_INDEX; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_ITEMS; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_UUID; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_UUIDS; +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Matchers.eq; +import static org.mockito.Matchers.isNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; +import static org.mockito.MockitoAnnotations.initMocks; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +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.testutil.FakeClock; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.trackselection.TrackSelectionParameters; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.UUID; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; + +/** Unit test for {@link ExoCastPlayer}. */ +@RunWith(AndroidJUnit4.class) +public class ExoCastPlayerTest { + + private static final long MOCK_SEQUENCE_NUMBER = 1; + private ExoCastPlayer player; + private MediaItem.Builder itemBuilder; + private CastSessionManager.StateListener receiverAppStateListener; + private FakeClock clock; + @Mock private CastSessionManager sessionManager; + @Mock private SessionAvailabilityListener sessionAvailabilityListener; + @Mock private Player.EventListener playerEventListener; + + @Before + public void setUp() { + initMocks(this); + clock = new FakeClock(/* initialTimeMs= */ 0); + player = + new ExoCastPlayer( + listener -> { + receiverAppStateListener = listener; + return sessionManager; + }, + clock); + player.addListener(playerEventListener); + itemBuilder = new MediaItem.Builder(); + } + + @Test + public void exoCastPlayer_startsAndStopsSessionManager() { + // The session manager should have been started when setting up, with the creation of + // ExoCastPlayer. + verify(sessionManager).start(); + verifyNoMoreInteractions(sessionManager); + player.release(); + verify(sessionManager).stopTrackingSession(); + verifyNoMoreInteractions(sessionManager); + } + + @Test + public void exoCastPlayer_propagatesSessionStatus() { + player.setSessionAvailabilityListener(sessionAvailabilityListener); + verify(sessionAvailabilityListener, never()).onCastSessionAvailable(); + receiverAppStateListener.onCastSessionAvailable(); + verify(sessionAvailabilityListener).onCastSessionAvailable(); + verifyNoMoreInteractions(sessionAvailabilityListener); + receiverAppStateListener.onCastSessionUnavailable(); + verify(sessionAvailabilityListener).onCastSessionUnavailable(); + verifyNoMoreInteractions(sessionAvailabilityListener); + } + + @Test + public void addItemsToQueue_producesExpectedMessages() throws JSONException { + MediaItem item1 = itemBuilder.setUuid(toUuid(1)).build(); + MediaItem item2 = itemBuilder.setUuid(toUuid(2)).build(); + MediaItem item3 = itemBuilder.setUuid(toUuid(3)).build(); + MediaItem item4 = itemBuilder.setUuid(toUuid(4)).build(); + MediaItem item5 = itemBuilder.setUuid(toUuid(5)).build(); + + player.addItemsToQueue(item1, item2); + assertMediaItemQueue(item1, item2); + + player.addItemsToQueue(1, item3, item4); + assertMediaItemQueue(item1, item3, item4, item2); + + player.addItemsToQueue(item5); + assertMediaItemQueue(item1, item3, item4, item2, item5); + + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(ExoCastMessage.class); + verify(sessionManager, times(3)).send(messageCaptor.capture()); + assertMessageAddsItems( + /* message= */ messageCaptor.getAllValues().get(0), + /* index= */ C.INDEX_UNSET, + Arrays.asList(item1, item2)); + assertMessageAddsItems( + /* message= */ messageCaptor.getAllValues().get(1), + /* index= */ 1, + Arrays.asList(item3, item4)); + assertMessageAddsItems( + /* message= */ messageCaptor.getAllValues().get(2), + /* index= */ C.INDEX_UNSET, + Collections.singletonList(item5)); + } + + @Test + public void addItemsToQueue_masksRemoteUpdates() { + player.prepare(); + when(sessionManager.send(any(ExoCastMessage.class))).thenReturn(3L); + MediaItem item1 = itemBuilder.setUuid(toUuid(1)).build(); + MediaItem item2 = itemBuilder.setUuid(toUuid(2)).build(); + MediaItem item3 = itemBuilder.setUuid(toUuid(3)).build(); + MediaItem item4 = itemBuilder.setUuid(toUuid(4)).build(); + + player.addItemsToQueue(item1, item2); + assertMediaItemQueue(item1, item2); + + // Should be ignored due to a lower sequence number. + receiverAppStateListener.onStateUpdateFromReceiverApp( + ReceiverAppStateUpdate.builder(/* sequenceNumber= */ 2) + .setItems(Arrays.asList(item3, item4)) + .build()); + + // Should override the current state. + assertMediaItemQueue(item1, item2); + receiverAppStateListener.onStateUpdateFromReceiverApp( + ReceiverAppStateUpdate.builder(/* sequenceNumber= */ 3) + .setItems(Arrays.asList(item3, item4)) + .build()); + + assertMediaItemQueue(item3, item4); + } + + @Test + public void addItemsToQueue_masksWindowIndexAsExpected() { + player.prepare(); + player.addItemsToQueue(itemBuilder.build(), itemBuilder.build(), itemBuilder.build()); + player.seekTo(/* windowIndex= */ 2, /* positionMs= */ 500); + + assertThat(player.getCurrentWindowIndex()).isEqualTo(2); + assertThat(player.getCurrentPeriodIndex()).isEqualTo(2); + player.addItemsToQueue(/* optionalIndex= */ 0, itemBuilder.build()); + assertThat(player.getCurrentWindowIndex()).isEqualTo(3); + assertThat(player.getCurrentPeriodIndex()).isEqualTo(3); + + player.addItemsToQueue(itemBuilder.build()); + assertThat(player.getCurrentWindowIndex()).isEqualTo(3); + assertThat(player.getCurrentPeriodIndex()).isEqualTo(3); + } + + @Test + public void addItemsToQueue_doesNotAddDuplicateUuids() { + player.prepare(); + player.addItemsToQueue(itemBuilder.setUuid(toUuid(1)).build()); + assertThat(player.getQueueSize()).isEqualTo(1); + player.addItemsToQueue( + itemBuilder.setUuid(toUuid(1)).build(), itemBuilder.setUuid(toUuid(2)).build()); + assertThat(player.getQueueSize()).isEqualTo(2); + try { + player.addItemsToQueue( + itemBuilder.setUuid(toUuid(3)).build(), itemBuilder.setUuid(toUuid(3)).build()); + fail(); + } catch (IllegalArgumentException e) { + // Expected. + } + } + + @Test + public void moveItemInQueue_behavesAsExpected() throws JSONException { + MediaItem item1 = itemBuilder.setUuid(toUuid(1)).build(); + MediaItem item2 = itemBuilder.setUuid(toUuid(2)).build(); + MediaItem item3 = itemBuilder.setUuid(toUuid(3)).build(); + player.addItemsToQueue(item1, item2, item3); + assertMediaItemQueue(item1, item2, item3); + player.moveItemInQueue(/* index= */ 0, /* newIndex= */ 2); + assertMediaItemQueue(item2, item3, item1); + player.moveItemInQueue(/* index= */ 1, /* newIndex= */ 1); + assertMediaItemQueue(item2, item3, item1); + player.moveItemInQueue(/* index= */ 1, /* newIndex= */ 0); + assertMediaItemQueue(item3, item2, item1); + + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(ExoCastMessage.class); + verify(sessionManager, times(4)).send(messageCaptor.capture()); + // First sent message is an "add" message. + assertMessageMovesItem( + /* message= */ messageCaptor.getAllValues().get(1), item1, /* index= */ 2); + assertMessageMovesItem( + /* message= */ messageCaptor.getAllValues().get(2), item3, /* index= */ 1); + assertMessageMovesItem( + /* message= */ messageCaptor.getAllValues().get(3), item3, /* index= */ 0); + } + + @Test + public void moveItemInQueue_moveBeforeToAfter_masksWindowIndexAsExpected() { + player.prepare(); + player.addItemsToQueue(itemBuilder.build(), itemBuilder.build(), itemBuilder.build()); + player.seekTo(/* windowIndex= */ 1, /* positionMs= */ 500); + + assertThat(player.getCurrentWindowIndex()).isEqualTo(1); + assertThat(player.getCurrentPeriodIndex()).isEqualTo(1); + player.moveItemInQueue(/* index= */ 0, /* newIndex= */ 1); + assertThat(player.getCurrentWindowIndex()).isEqualTo(0); + assertThat(player.getCurrentPeriodIndex()).isEqualTo(0); + } + + @Test + public void moveItemInQueue_moveAfterToBefore_masksWindowIndexAsExpected() { + player.prepare(); + player.addItemsToQueue(itemBuilder.build(), itemBuilder.build(), itemBuilder.build()); + player.seekTo(/* windowIndex= */ 0, /* positionMs= */ 500); + + assertThat(player.getCurrentWindowIndex()).isEqualTo(0); + assertThat(player.getCurrentPeriodIndex()).isEqualTo(0); + player.moveItemInQueue(/* index= */ 1, /* newIndex= */ 0); + assertThat(player.getCurrentWindowIndex()).isEqualTo(1); + assertThat(player.getCurrentPeriodIndex()).isEqualTo(1); + } + + @Test + public void moveItemInQueue_moveCurrent_masksWindowIndexAsExpected() { + player.prepare(); + player.addItemsToQueue(itemBuilder.build(), itemBuilder.build(), itemBuilder.build()); + player.seekTo(/* windowIndex= */ 0, /* positionMs= */ 500); + + assertThat(player.getCurrentWindowIndex()).isEqualTo(0); + assertThat(player.getCurrentPeriodIndex()).isEqualTo(0); + player.moveItemInQueue(/* index= */ 0, /* newIndex= */ 2); + assertThat(player.getCurrentWindowIndex()).isEqualTo(2); + assertThat(player.getCurrentPeriodIndex()).isEqualTo(2); + } + + @Test + public void removeItemsFromQueue_masksMediaQueue() throws JSONException { + MediaItem item1 = itemBuilder.setUuid(toUuid(1)).build(); + MediaItem item2 = itemBuilder.setUuid(toUuid(2)).build(); + MediaItem item3 = itemBuilder.setUuid(toUuid(3)).build(); + MediaItem item4 = itemBuilder.setUuid(toUuid(4)).build(); + MediaItem item5 = itemBuilder.setUuid(toUuid(5)).build(); + player.addItemsToQueue(item1, item2, item3, item4, item5); + assertMediaItemQueue(item1, item2, item3, item4, item5); + + player.removeItemFromQueue(2); + assertMediaItemQueue(item1, item2, item4, item5); + + player.removeRangeFromQueue(1, 3); + assertMediaItemQueue(item1, item5); + + player.clearQueue(); + assertMediaItemQueue(); + + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(ExoCastMessage.class); + verify(sessionManager, times(4)).send(messageCaptor.capture()); + // First sent message is an "add" message. + assertMessageRemovesItems( + messageCaptor.getAllValues().get(1), Collections.singletonList(item3)); + assertMessageRemovesItems(messageCaptor.getAllValues().get(2), Arrays.asList(item2, item4)); + assertMessageRemovesItems(messageCaptor.getAllValues().get(3), Arrays.asList(item1, item5)); + } + + @Test + public void removeRangeFromQueue_beforeCurrentItem_masksWindowIndexAsExpected() { + player.prepare(); + player.addItemsToQueue(itemBuilder.build(), itemBuilder.build(), itemBuilder.build()); + player.seekTo(/* windowIndex= */ 2, /* positionMs= */ 500); + + assertThat(player.getCurrentWindowIndex()).isEqualTo(2); + assertThat(player.getCurrentPeriodIndex()).isEqualTo(2); + player.removeRangeFromQueue(/* indexFrom= */ 0, /* indexExclusiveTo= */ 2); + assertThat(player.getCurrentWindowIndex()).isEqualTo(0); + assertThat(player.getCurrentPeriodIndex()).isEqualTo(0); + } + + @Test + public void removeRangeFromQueue_currentItem_masksWindowIndexAsExpected() { + player.prepare(); + player.addItemsToQueue(itemBuilder.build(), itemBuilder.build(), itemBuilder.build()); + player.seekTo(/* windowIndex= */ 1, /* positionMs= */ 500); + + assertThat(player.getCurrentWindowIndex()).isEqualTo(1); + assertThat(player.getCurrentPeriodIndex()).isEqualTo(1); + player.removeRangeFromQueue(/* indexFrom= */ 0, /* indexExclusiveTo= */ 2); + assertThat(player.getCurrentWindowIndex()).isEqualTo(0); + assertThat(player.getCurrentPeriodIndex()).isEqualTo(0); + } + + @Test + public void removeRangeFromQueue_currentItemWhichIsLast_transitionsToEnded() { + player.prepare(); + player.addItemsToQueue(itemBuilder.build(), itemBuilder.build(), itemBuilder.build()); + player.seekTo(/* windowIndex= */ 1, /* positionMs= */ 500); + + assertThat(player.getCurrentWindowIndex()).isEqualTo(1); + assertThat(player.getCurrentPeriodIndex()).isEqualTo(1); + player.removeRangeFromQueue(/* indexFrom= */ 1, /* indexExclusiveTo= */ 3); + assertThat(player.getCurrentWindowIndex()).isEqualTo(0); + assertThat(player.getCurrentPeriodIndex()).isEqualTo(0); + assertThat(player.getPlaybackState()).isEqualTo(Player.STATE_ENDED); + } + + @Test + public void clearQueue_resetsPlaybackPosition() { + player.prepare(); + player.addItemsToQueue(itemBuilder.build(), itemBuilder.build(), itemBuilder.build()); + player.seekTo(/* windowIndex= */ 1, /* positionMs= */ 500); + + assertThat(player.getCurrentWindowIndex()).isEqualTo(1); + assertThat(player.getCurrentPeriodIndex()).isEqualTo(1); + player.clearQueue(); + assertThat(player.getCurrentWindowIndex()).isEqualTo(0); + assertThat(player.getCurrentPeriodIndex()).isEqualTo(0); + assertThat(player.getPlaybackState()).isEqualTo(Player.STATE_ENDED); + } + + @Test + public void prepare_emptyQueue_transitionsToEnded() { + player.prepare(); + assertThat(player.getPlaybackState()).isEqualTo(Player.STATE_ENDED); + verify(playerEventListener).onPlayerStateChanged(/* playWhenReady=*/ false, Player.STATE_ENDED); + } + + @Test + public void prepare_withQueue_transitionsToBuffering() { + player.addItemsToQueue(itemBuilder.build()); + player.prepare(); + assertThat(player.getPlaybackState()).isEqualTo(Player.STATE_BUFFERING); + verify(playerEventListener) + .onPlayerStateChanged(/* playWhenReady=*/ false, Player.STATE_BUFFERING); + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(Timeline.class); + verify(playerEventListener) + .onTimelineChanged( + argumentCaptor.capture(), + /* manifest= */ isNull(), + eq(Player.TIMELINE_CHANGE_REASON_PREPARED)); + assertThat(argumentCaptor.getValue().getWindowCount()).isEqualTo(1); + } + + @Test + public void stop_withoutReset_leavesCurrentTimeline() { + assertThat(player.getPlaybackState()).isEqualTo(Player.STATE_IDLE); + player.addItemsToQueue(itemBuilder.setUuid(toUuid(1)).build()); + player.prepare(); + assertThat(player.getPlaybackState()).isEqualTo(Player.STATE_BUFFERING); + player.seekTo(/* windowIndex= */ 0, /* positionMs= */ C.TIME_UNSET); + verify(playerEventListener) + .onPlayerStateChanged(/* playWhenReady =*/ false, Player.STATE_BUFFERING); + verify(playerEventListener).onPositionDiscontinuity(Player.DISCONTINUITY_REASON_SEEK); + player.stop(/* reset= */ false); + verify(playerEventListener).onPlayerStateChanged(/* playWhenReady =*/ false, Player.STATE_IDLE); + + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(Timeline.class); + // Update for prepare. + verify(playerEventListener) + .onTimelineChanged( + argumentCaptor.capture(), + /* manifest= */ isNull(), + eq(Player.TIMELINE_CHANGE_REASON_PREPARED)); + assertThat(argumentCaptor.getValue().getWindowCount()).isEqualTo(1); + + // Update for stop. + verifyNoMoreInteractions(playerEventListener); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(1); + } + + @Test + public void stop_withReset_clearsQueue() { + player.prepare(); + player.addItemsToQueue(itemBuilder.setUuid(toUuid(1)).build()); + verify(playerEventListener) + .onTimelineChanged( + any(Timeline.class), isNull(), eq(Player.TIMELINE_CHANGE_REASON_DYNAMIC)); + player.seekTo(/* windowIndex= */ 0, /* positionMs= */ C.TIME_UNSET); + verify(playerEventListener) + .onPlayerStateChanged(/* playWhenReady =*/ false, Player.STATE_BUFFERING); + player.stop(/* reset= */ true); + verify(playerEventListener).onPlayerStateChanged(/* playWhenReady =*/ false, Player.STATE_IDLE); + + // Update for add. + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(Timeline.class); + verify(playerEventListener) + .onTimelineChanged( + argumentCaptor.capture(), + /* manifest= */ isNull(), + eq(Player.TIMELINE_CHANGE_REASON_DYNAMIC)); + assertThat(argumentCaptor.getValue().getWindowCount()).isEqualTo(1); + + // Update for stop. + verify(playerEventListener) + .onTimelineChanged( + argumentCaptor.capture(), + /* manifest= */ isNull(), + eq(Player.TIMELINE_CHANGE_REASON_RESET)); + assertThat(argumentCaptor.getValue().getWindowCount()).isEqualTo(0); + + assertThat(player.getCurrentTimeline().isEmpty()).isTrue(); + } + + @Test + public void getCurrentTimeline_masksRemoteUpdates() { + player.prepare(); + MediaItem item1 = itemBuilder.setUuid(toUuid(1)).build(); + MediaItem item2 = itemBuilder.setUuid(toUuid(2)).build(); + assertThat(player.getCurrentTimeline().isEmpty()).isTrue(); + player.addItemsToQueue(item1, item2); + + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(Timeline.class); + verify(playerEventListener) + .onTimelineChanged( + messageCaptor.capture(), + /* manifest= */ isNull(), + eq(Player.TIMELINE_CHANGE_REASON_DYNAMIC)); + Timeline reportedTimeline = messageCaptor.getValue(); + assertThat(reportedTimeline).isSameInstanceAs(player.getCurrentTimeline()); + assertThat(reportedTimeline.getWindowCount()).isEqualTo(2); + assertThat(reportedTimeline.getWindow(/* windowIndex= */ 0, new Timeline.Window()).durationUs) + .isEqualTo(C.TIME_UNSET); + assertThat(reportedTimeline.getWindow(/* windowIndex= */ 1, new Timeline.Window()).durationUs) + .isEqualTo(C.TIME_UNSET); + } + + @Test + public void getCurrentTimeline_exposesReceiverState() { + MediaItem item1 = itemBuilder.setUuid(toUuid(1)).build(); + MediaItem item2 = itemBuilder.setUuid(toUuid(2)).build(); + receiverAppStateListener.onStateUpdateFromReceiverApp( + ReceiverAppStateUpdate.builder(/* sequenceNumber= */ 1) + .setPlaybackState(Player.STATE_BUFFERING) + .setItems(Arrays.asList(item1, item2)) + .setShuffleOrder(Arrays.asList(1, 0)) + .build()); + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(Timeline.class); + verify(playerEventListener) + .onTimelineChanged( + messageCaptor.capture(), + /* manifest= */ isNull(), + eq(Player.TIMELINE_CHANGE_REASON_DYNAMIC)); + Timeline reportedTimeline = messageCaptor.getValue(); + assertThat(reportedTimeline).isSameInstanceAs(player.getCurrentTimeline()); + assertThat(reportedTimeline.getWindowCount()).isEqualTo(2); + assertThat(reportedTimeline.getWindow(/* windowIndex= */ 0, new Timeline.Window()).durationUs) + .isEqualTo(C.TIME_UNSET); + assertThat(reportedTimeline.getWindow(/* windowIndex= */ 1, new Timeline.Window()).durationUs) + .isEqualTo(C.TIME_UNSET); + } + + @Test + public void timelineUpdateFromReceiver_matchesLocalState_doesNotCallEventLsitener() { + MediaItem item1 = itemBuilder.setUuid(toUuid(1)).build(); + MediaItem item2 = itemBuilder.setUuid(toUuid(2)).build(); + MediaItem item3 = itemBuilder.setUuid(toUuid(3)).build(); + MediaItem item4 = itemBuilder.setUuid(toUuid(4)).build(); + + MediaItemInfo.Period period1 = + new MediaItemInfo.Period("id1", /* durationUs= */ 1000000L, /* positionInWindowUs= */ 0); + MediaItemInfo.Period period2 = + new MediaItemInfo.Period( + "id2", /* durationUs= */ 1000000L, /* positionInWindowUs= */ 1000000L); + MediaItemInfo.Period period3 = + new MediaItemInfo.Period( + "id3", /* durationUs= */ 1000000L, /* positionInWindowUs= */ 2000000L); + HashMap mediaItemInfoMap1 = new HashMap<>(); + mediaItemInfoMap1.put( + toUuid(1), + new MediaItemInfo( + /* windowDurationUs= */ 3000L, + /* defaultStartPositionUs= */ 10, + /* periods= */ Arrays.asList(period1, period2, period3), + /* positionInFirstPeriodUs= */ 0, + /* isSeekable= */ true, + /* isDynamic= */ false)); + mediaItemInfoMap1.put( + toUuid(3), + new MediaItemInfo( + /* windowDurationUs= */ 2000L, + /* defaultStartPositionUs= */ 10, + /* periods= */ Arrays.asList(period1, period2), + /* positionInFirstPeriodUs= */ 500, + /* isSeekable= */ true, + /* isDynamic= */ false)); + + receiverAppStateListener.onStateUpdateFromReceiverApp( + ReceiverAppStateUpdate.builder(1) + .setPlaybackState(Player.STATE_BUFFERING) + .setItems(Arrays.asList(item1, item2, item3, item4)) + .setShuffleOrder(Arrays.asList(1, 0, 2, 3)) + .setMediaItemsInformation(mediaItemInfoMap1) + .build()); + verify(playerEventListener) + .onTimelineChanged( + any(), /* manifest= */ isNull(), eq(Player.TIMELINE_CHANGE_REASON_DYNAMIC)); + verify(playerEventListener) + .onPlayerStateChanged( + /* playWhenReady= */ false, /* playbackState= */ Player.STATE_BUFFERING); + + HashMap mediaItemInfoMap2 = new HashMap<>(mediaItemInfoMap1); + mediaItemInfoMap2.put( + toUuid(5), + new MediaItemInfo( + /* windowDurationUs= */ 5, + /* defaultStartPositionUs= */ 0, + /* periods= */ Arrays.asList(period1, period2), + /* positionInFirstPeriodUs= */ 500, + /* isSeekable= */ true, + /* isDynamic= */ false)); + + receiverAppStateListener.onStateUpdateFromReceiverApp( + ReceiverAppStateUpdate.builder(1).setMediaItemsInformation(mediaItemInfoMap2).build()); + verifyNoMoreInteractions(playerEventListener); + } + + @Test + public void getPeriodIndex_producesExpectedOutput() { + MediaItem item1 = itemBuilder.setUuid(toUuid(1)).build(); + MediaItem item2 = itemBuilder.setUuid(toUuid(2)).build(); + MediaItem item3 = itemBuilder.setUuid(toUuid(3)).build(); + MediaItem item4 = itemBuilder.setUuid(toUuid(4)).build(); + + MediaItemInfo.Period period1 = + new MediaItemInfo.Period("id1", /* durationUs= */ 1000000L, /* positionInWindowUs= */ 0); + MediaItemInfo.Period period2 = + new MediaItemInfo.Period( + "id2", /* durationUs= */ 1000000L, /* positionInWindowUs= */ 1000000L); + MediaItemInfo.Period period3 = + new MediaItemInfo.Period( + "id3", /* durationUs= */ 1000000L, /* positionInWindowUs= */ 2000000L); + HashMap mediaItemInfoMap = new HashMap<>(); + mediaItemInfoMap.put( + toUuid(1), + new MediaItemInfo( + /* windowDurationUs= */ 3000L, + /* defaultStartPositionUs= */ 10, + /* periods= */ Arrays.asList(period1, period2, period3), + /* positionInFirstPeriodUs= */ 0, + /* isSeekable= */ true, + /* isDynamic= */ false)); + mediaItemInfoMap.put( + toUuid(3), + new MediaItemInfo( + /* windowDurationUs= */ 2000L, + /* defaultStartPositionUs= */ 10, + /* periods= */ Arrays.asList(period1, period2), + /* positionInFirstPeriodUs= */ 500, + /* isSeekable= */ true, + /* isDynamic= */ false)); + + receiverAppStateListener.onStateUpdateFromReceiverApp( + ReceiverAppStateUpdate.builder(/* sequenceNumber= */ 1L) + .setPlaybackState(Player.STATE_BUFFERING) + .setItems(Arrays.asList(item1, item2, item3, item4)) + .setShuffleOrder(Arrays.asList(1, 0, 3, 2)) + .setMediaItemsInformation(mediaItemInfoMap) + .setPlaybackPosition( + /* currentPlayingItemUuid= */ item3.uuid, + /* currentPlayingPeriodId= */ "id2", + /* currentPlaybackPositionMs= */ 500L) + .build()); + + assertThat(player.getCurrentPeriodIndex()).isEqualTo(5); + player.seekTo(/* windowIndex= */ 1, /* positionMs= */ 0L); + assertThat(player.getCurrentPeriodIndex()).isEqualTo(3); + player.seekTo(/* windowIndex= */ 0, /* positionMs= */ 1500L); + assertThat(player.getCurrentPeriodIndex()).isEqualTo(1); + } + + @Test + public void exoCastPlayer_propagatesPlayerStateFromReceiver() { + ReceiverAppStateUpdate.Builder builder = + ReceiverAppStateUpdate.builder(/* sequenceNumber= */ 1); + + // The first idle state update should be discarded, since it matches the current state. + receiverAppStateListener.onStateUpdateFromReceiverApp( + builder.setPlaybackState(Player.STATE_IDLE).build()); + receiverAppStateListener.onStateUpdateFromReceiverApp( + builder.setPlaybackState(Player.STATE_BUFFERING).build()); + receiverAppStateListener.onStateUpdateFromReceiverApp( + builder.setPlaybackState(Player.STATE_READY).build()); + receiverAppStateListener.onStateUpdateFromReceiverApp( + builder.setPlaybackState(Player.STATE_ENDED).build()); + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(Integer.class); + verify(playerEventListener, times(3)) + .onPlayerStateChanged(/* playWhenReady= */ eq(false), messageCaptor.capture()); + List states = messageCaptor.getAllValues(); + assertThat(states).hasSize(3); + assertThat(states) + .isEqualTo(Arrays.asList(Player.STATE_BUFFERING, Player.STATE_READY, Player.STATE_ENDED)); + } + + @Test + public void setPlayWhenReady_changedLocally_notifiesListeners() { + player.setPlayWhenReady(false); + verify(playerEventListener, never()).onPlayerStateChanged(false, Player.STATE_IDLE); + player.setPlayWhenReady(true); + verify(playerEventListener).onPlayerStateChanged(true, Player.STATE_IDLE); + player.setPlayWhenReady(false); + verify(playerEventListener).onPlayerStateChanged(false, Player.STATE_IDLE); + } + + @Test + public void setPlayWhenReady_changedRemotely_notifiesListeners() { + receiverAppStateListener.onStateUpdateFromReceiverApp( + ReceiverAppStateUpdate.builder(/* sequenceNumber= */ 0).setPlayWhenReady(true).build()); + verify(playerEventListener) + .onPlayerStateChanged(/* playWhenReady= */ true, /* playbackState= */ Player.STATE_IDLE); + receiverAppStateListener.onStateUpdateFromReceiverApp( + ReceiverAppStateUpdate.builder(/* sequenceNumber= */ 0).setPlayWhenReady(true).build()); + verifyNoMoreInteractions(playerEventListener); + receiverAppStateListener.onStateUpdateFromReceiverApp( + ReceiverAppStateUpdate.builder(/* sequenceNumber= */ 0).setPlayWhenReady(false).build()); + verify(playerEventListener) + .onPlayerStateChanged(/* playWhenReady= */ false, /* playbackState= */ Player.STATE_IDLE); + verifyNoMoreInteractions(playerEventListener); + } + + @Test + public void getPlayWhenReady_masksRemoteUpdates() { + when(sessionManager.send(any(ExoCastMessage.class))).thenReturn(3L); + player.setPlayWhenReady(true); + verify(playerEventListener) + .onPlayerStateChanged(/* playWhenReady= */ true, /* playbackState= */ Player.STATE_IDLE); + receiverAppStateListener.onStateUpdateFromReceiverApp( + ReceiverAppStateUpdate.builder(/* sequenceNumber= */ 2).setPlayWhenReady(false).build()); + verifyNoMoreInteractions(playerEventListener); + receiverAppStateListener.onStateUpdateFromReceiverApp( + ReceiverAppStateUpdate.builder(/* sequenceNumber= */ 3).setPlayWhenReady(true).build()); + verifyNoMoreInteractions(playerEventListener); + receiverAppStateListener.onStateUpdateFromReceiverApp( + ReceiverAppStateUpdate.builder(/* sequenceNumber= */ 3).setPlayWhenReady(false).build()); + verify(playerEventListener) + .onPlayerStateChanged(/* playWhenReady= */ false, /* playbackState= */ Player.STATE_IDLE); + } + + @Test + public void setRepeatMode_changedLocally_notifiesListeners() { + player.setRepeatMode(Player.REPEAT_MODE_OFF); + verifyNoMoreInteractions(playerEventListener); + player.setRepeatMode(Player.REPEAT_MODE_ONE); + verify(playerEventListener).onRepeatModeChanged(Player.REPEAT_MODE_ONE); + player.setRepeatMode(Player.REPEAT_MODE_ONE); + verifyNoMoreInteractions(playerEventListener); + } + + @Test + public void setRepeatMode_changedRemotely_notifiesListeners() { + receiverAppStateListener.onStateUpdateFromReceiverApp( + ReceiverAppStateUpdate.builder(/* sequenceNumber= */ 0) + .setRepeatMode(Player.REPEAT_MODE_ONE) + .build()); + verify(playerEventListener).onRepeatModeChanged(Player.REPEAT_MODE_ONE); + assertThat(player.getRepeatMode()).isEqualTo(Player.REPEAT_MODE_ONE); + } + + @Test + public void getRepeatMode_masksRemoteUpdates() { + when(sessionManager.send(any(ExoCastMessage.class))).thenReturn(3L); + player.setRepeatMode(Player.REPEAT_MODE_ALL); + assertThat(player.getRepeatMode()).isEqualTo(Player.REPEAT_MODE_ALL); + verify(playerEventListener).onRepeatModeChanged(Player.REPEAT_MODE_ALL); + receiverAppStateListener.onStateUpdateFromReceiverApp( + ReceiverAppStateUpdate.builder(/* sequenceNumber= */ 2) + .setRepeatMode(Player.REPEAT_MODE_ONE) + .build()); + verifyNoMoreInteractions(playerEventListener); + receiverAppStateListener.onStateUpdateFromReceiverApp( + ReceiverAppStateUpdate.builder(/* sequenceNumber= */ 3) + .setRepeatMode(Player.REPEAT_MODE_ONE) + .build()); + verify(playerEventListener).onRepeatModeChanged(Player.REPEAT_MODE_ONE); + } + + @Test + public void getPlaybackPosition_withStateChanges_producesExpectedOutput() { + UUID uuid = toUuid(1); + HashMap mediaItemInfoMap = new HashMap<>(); + + MediaItemInfo.Period period1 = new MediaItemInfo.Period("id1", 1000L, 0); + MediaItemInfo.Period period2 = new MediaItemInfo.Period("id2", 1000L, 0); + MediaItemInfo.Period period3 = new MediaItemInfo.Period("id3", 1000L, 0); + mediaItemInfoMap.put( + uuid, + new MediaItemInfo( + /* windowDurationUs= */ 1000L, + /* defaultStartPositionUs= */ 10, + /* periods= */ Arrays.asList(period1, period2, period3), + /* positionInFirstPeriodUs= */ 500, + /* isSeekable= */ true, + /* isDynamic= */ false)); + + when(sessionManager.send(any(ExoCastMessage.class))).thenReturn(1L); + player.addItemsToQueue(itemBuilder.setUuid(uuid).build()); + receiverAppStateListener.onStateUpdateFromReceiverApp( + ReceiverAppStateUpdate.builder(/* sequenceNumber= */ 1) + .setPlaybackState(Player.STATE_BUFFERING) + .setMediaItemsInformation(mediaItemInfoMap) + .setPlaybackPosition(uuid, "id2", /* currentPlaybackPositionMs= */ 1000L) + .build()); + assertThat(player.getCurrentWindowIndex()).isEqualTo(0); + assertThat(player.getCurrentPosition()).isEqualTo(1000L); + clock.advanceTime(/* timeDiffMs= */ 1L); + receiverAppStateListener.onStateUpdateFromReceiverApp( + ReceiverAppStateUpdate.builder(/* sequenceNumber= */ 1) + .setPlaybackState(Player.STATE_READY) + .build()); + // Play when ready is still false, so position should not change. + assertThat(player.getCurrentPosition()).isEqualTo(1000L); + player.setPlayWhenReady(true); + clock.advanceTime(1); + assertThat(player.getCurrentPosition()).isEqualTo(1001L); + clock.advanceTime(1); + assertThat(player.getCurrentPosition()).isEqualTo(1002L); + receiverAppStateListener.onStateUpdateFromReceiverApp( + ReceiverAppStateUpdate.builder(/* sequenceNumber= */ 1) + .setPlaybackState(Player.STATE_BUFFERING) + .setMediaItemsInformation(mediaItemInfoMap) + .setPlaybackPosition(uuid, "id2", /* currentPlaybackPositionMs= */ 1010L) + .build()); + clock.advanceTime(1); + assertThat(player.getCurrentPosition()).isEqualTo(1010L); + clock.advanceTime(1); + receiverAppStateListener.onStateUpdateFromReceiverApp( + ReceiverAppStateUpdate.builder(/* sequenceNumber= */ 1) + .setPlaybackState(Player.STATE_READY) + .setMediaItemsInformation(mediaItemInfoMap) + .setPlaybackPosition(uuid, "id2", /* currentPlaybackPositionMs= */ 1011L) + .build()); + clock.advanceTime(10); + assertThat(player.getCurrentPosition()).isEqualTo(1021L); + } + + @Test + public void getPlaybackPosition_withNonDefaultPlaybackSpeed_producesExpectedOutput() { + MediaItem item = itemBuilder.setUuid(toUuid(1)).build(); + MediaItemInfo info = + new MediaItemInfo( + /* windowDurationUs= */ 10000000, + /* defaultStartPositionUs= */ 3000000, + /* periods= */ Collections.singletonList( + new MediaItemInfo.Period( + /* id= */ "id", /* durationUs= */ 10000000, /* positionInWindowUs= */ 0)), + /* positionInFirstPeriodUs= */ 0, + /* isSeekable= */ true, + /* isDynamic= */ false); + receiverAppStateListener.onStateUpdateFromReceiverApp( + ReceiverAppStateUpdate.builder(/* sequenceNumber= */ 1) + .setMediaItemsInformation(Collections.singletonMap(toUuid(1), info)) + .setShuffleOrder(Collections.singletonList(0)) + .setItems(Collections.singletonList(item)) + .setPlaybackPosition( + toUuid(1), /* currentPlayingPeriodId= */ "id", /* currentPlaybackPositionMs= */ 20L) + .setPlaybackState(Player.STATE_READY) + .setPlayWhenReady(true) + .build()); + assertThat(player.getCurrentPeriodIndex()).isEqualTo(0); + assertThat(player.getCurrentWindowIndex()).isEqualTo(0); + assertThat(player.getCurrentPosition()).isEqualTo(20); + clock.advanceTime(10); + assertThat(player.getCurrentPosition()).isEqualTo(30); + clock.advanceTime(10); + receiverAppStateListener.onStateUpdateFromReceiverApp( + ReceiverAppStateUpdate.builder(1) + .setPlaybackPosition( + toUuid(1), /* currentPlayingPeriodId= */ "id", /* currentPlaybackPositionMs= */ 40L) + .setPlaybackParameters(new PlaybackParameters(2)) + .build()); + clock.advanceTime(10); + assertThat(player.getCurrentPosition()).isEqualTo(60); + } + + @Test + public void positionChanges_notifiesDiscontinuities() { + UUID uuid = toUuid(1); + HashMap mediaItemInfoMap = new HashMap<>(); + + MediaItemInfo.Period period1 = new MediaItemInfo.Period("id1", 1000L, 0); + MediaItemInfo.Period period2 = new MediaItemInfo.Period("id2", 1000L, 0); + MediaItemInfo.Period period3 = new MediaItemInfo.Period("id3", 1000L, 0); + mediaItemInfoMap.put( + uuid, + new MediaItemInfo( + /* windowDurationUs= */ 1000L, + /* defaultStartPositionUs= */ 10, + /* periods= */ Arrays.asList(period1, period2, period3), + /* positionInFirstPeriodUs= */ 500, + /* isSeekable= */ true, + /* isDynamic= */ false)); + + player.addItemsToQueue(itemBuilder.setUuid(uuid).build()); + receiverAppStateListener.onStateUpdateFromReceiverApp( + ReceiverAppStateUpdate.builder(/* sequenceNumber= */ 1) + .setPlaybackState(Player.STATE_BUFFERING) + .setMediaItemsInformation(mediaItemInfoMap) + .setPlaybackPosition(uuid, "id2", /* currentPlaybackPositionMs= */ 1000L) + .setDiscontinuityReason(Player.DISCONTINUITY_REASON_SEEK) + .build()); + verify(playerEventListener).onPositionDiscontinuity(Player.DISCONTINUITY_REASON_SEEK); + player.seekTo(/* windowIndex= */ 0, /* positionMs= */ 999); + verify(playerEventListener, times(2)).onPositionDiscontinuity(Player.DISCONTINUITY_REASON_SEEK); + } + + @Test + public void setShuffleModeEnabled_changedLocally_notifiesListeners() { + player.setShuffleModeEnabled(true); + verify(playerEventListener).onShuffleModeEnabledChanged(true); + player.setShuffleModeEnabled(true); + verifyNoMoreInteractions(playerEventListener); + } + + @Test + public void setShuffleModeEnabled_changedRemotely_notifiesListeners() { + receiverAppStateListener.onStateUpdateFromReceiverApp( + ReceiverAppStateUpdate.builder(/* sequenceNumber= */ 0) + .setShuffleModeEnabled(true) + .build()); + verify(playerEventListener).onShuffleModeEnabledChanged(true); + assertThat(player.getShuffleModeEnabled()).isTrue(); + } + + @Test + public void getShuffleMode_masksRemoteUpdates() { + when(sessionManager.send(any(ExoCastMessage.class))).thenReturn(3L); + player.setShuffleModeEnabled(true); + assertThat(player.getShuffleModeEnabled()).isTrue(); + verify(playerEventListener).onShuffleModeEnabledChanged(true); + receiverAppStateListener.onStateUpdateFromReceiverApp( + ReceiverAppStateUpdate.builder(/* sequenceNumber= */ 2) + .setShuffleModeEnabled(false) + .build()); + verifyNoMoreInteractions(playerEventListener); + receiverAppStateListener.onStateUpdateFromReceiverApp( + ReceiverAppStateUpdate.builder(/* sequenceNumber= */ 3) + .setShuffleModeEnabled(false) + .build()); + verify(playerEventListener).onShuffleModeEnabledChanged(false); + assertThat(player.getShuffleModeEnabled()).isFalse(); + } + + @Test + public void seekTo_inIdle_doesNotChangePlaybackState() { + player.prepare(); + assertThat(player.getPlaybackState()).isEqualTo(Player.STATE_ENDED); + player.addItemsToQueue(itemBuilder.build(), itemBuilder.build()); + assertThat(player.getPlaybackState()).isEqualTo(Player.STATE_ENDED); + player.seekTo(/* windowIndex= */ 0, /* positionMs= */ 0); + assertThat(player.getPlaybackState()).isEqualTo(Player.STATE_BUFFERING); + player.stop(false); + assertThat(player.getPlaybackState()).isEqualTo(Player.STATE_IDLE); + player.seekTo(/* windowIndex= */ 0, /* positionMs= */ 0); + assertThat(player.getPlaybackState()).isEqualTo(Player.STATE_IDLE); + } + + @Test + public void seekTo_withTwoItems_producesExpectedMessage() { + player.prepare(); + MediaItem item1 = itemBuilder.setUuid(toUuid(1)).build(); + MediaItem item2 = itemBuilder.setUuid(toUuid(2)).build(); + player.addItemsToQueue(item1, item2); + player.seekTo(/* windowIndex= */ 1, /* positionMs= */ 1000); + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(ExoCastMessage.class); + verify(sessionManager, times(3)).send(messageCaptor.capture()); + // Messages should be prepare, add and seek. + ExoCastMessage.SeekTo seekToMessage = + (ExoCastMessage.SeekTo) messageCaptor.getAllValues().get(2); + assertThat(seekToMessage.positionMs).isEqualTo(1000); + assertThat(seekToMessage.uuid).isEqualTo(toUuid(2)); + } + + @Test + public void seekTo_masksRemoteUpdates() { + player.prepare(); + when(sessionManager.send(any(ExoCastMessage.class))).thenReturn(3L); + MediaItem item1 = itemBuilder.setUuid(toUuid(1)).build(); + MediaItem item2 = itemBuilder.setUuid(toUuid(2)).build(); + player.addItemsToQueue(item1, item2); + player.seekTo(/* windowIndex= */ 1, /* positionMs= */ 1000L); + verify(playerEventListener).onPositionDiscontinuity(Player.DISCONTINUITY_REASON_SEEK); + verify(playerEventListener) + .onPlayerStateChanged( + /* playWhenReady= */ false, /* playbackState= */ Player.STATE_BUFFERING); + assertThat(player.getCurrentWindowIndex()).isEqualTo(1); + assertThat(player.getCurrentPosition()).isEqualTo(1000); + receiverAppStateListener.onStateUpdateFromReceiverApp( + ReceiverAppStateUpdate.builder(/* sequenceNumber= */ 2) + .setPlaybackPosition(toUuid(1), "id", 500L) + .build()); + assertThat(player.getCurrentWindowIndex()).isEqualTo(1); + assertThat(player.getCurrentPosition()).isEqualTo(1000); + receiverAppStateListener.onStateUpdateFromReceiverApp( + ReceiverAppStateUpdate.builder(/* sequenceNumber= */ 3) + .setPlaybackPosition(toUuid(1), "id", 500L) + .setDiscontinuityReason(Player.DISCONTINUITY_REASON_SEEK) + .build()); + verify(playerEventListener, times(2)).onPositionDiscontinuity(Player.DISCONTINUITY_REASON_SEEK); + assertThat(player.getCurrentWindowIndex()).isEqualTo(0); + assertThat(player.getCurrentPosition()).isEqualTo(500); + } + + @Test + public void setPlaybackParameters_producesExpectedMessage() { + PlaybackParameters playbackParameters = + new PlaybackParameters(/* speed= */ .5f, /* pitch= */ .25f, /* skipSilence= */ true); + player.setPlaybackParameters(playbackParameters); + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(ExoCastMessage.class); + verify(sessionManager).send(messageCaptor.capture()); + ExoCastMessage.SetPlaybackParameters message = + (ExoCastMessage.SetPlaybackParameters) messageCaptor.getValue(); + assertThat(message.playbackParameters).isEqualTo(playbackParameters); + } + + @Test + public void getTrackSelectionParameters_doesNotOverrideUnexpectedFields() { + when(sessionManager.send(any(ExoCastMessage.class))).thenReturn(3L); + DefaultTrackSelector.Parameters parameters = + DefaultTrackSelector.Parameters.DEFAULT + .buildUpon() + .setPreferredAudioLanguage("spa") + .setMaxVideoSize(/* maxVideoWidth= */ 3, /* maxVideoHeight= */ 3) + .build(); + player.setTrackSelectionParameters(parameters); + TrackSelectionParameters returned = + TrackSelectionParameters.DEFAULT.buildUpon().setPreferredAudioLanguage("deu").build(); + receiverAppStateListener.onStateUpdateFromReceiverApp( + ReceiverAppStateUpdate.builder(/* sequenceNumber= */ 3) + .setTrackSelectionParameters(returned) + .build()); + DefaultTrackSelector.Parameters result = + (DefaultTrackSelector.Parameters) player.getTrackSelectionParameters(); + assertThat(result.preferredAudioLanguage).isEqualTo("deu"); + assertThat(result.maxVideoHeight).isEqualTo(3); + assertThat(result.maxVideoWidth).isEqualTo(3); + } + + @Test + public void testExoCast_getRendererType() { + assertThat(player.getRendererCount()).isEqualTo(4); + assertThat(player.getRendererType(/* index= */ 0)).isEqualTo(C.TRACK_TYPE_VIDEO); + assertThat(player.getRendererType(/* index= */ 1)).isEqualTo(C.TRACK_TYPE_AUDIO); + assertThat(player.getRendererType(/* index= */ 2)).isEqualTo(C.TRACK_TYPE_TEXT); + assertThat(player.getRendererType(/* index= */ 3)).isEqualTo(C.TRACK_TYPE_METADATA); + } + + private static UUID toUuid(long lowerBits) { + return new UUID(0, lowerBits); + } + + private void assertMediaItemQueue(MediaItem... mediaItemQueue) { + assertThat(player.getQueueSize()).isEqualTo(mediaItemQueue.length); + for (int i = 0; i < mediaItemQueue.length; i++) { + assertThat(player.getQueueItem(i).uuid).isEqualTo(mediaItemQueue[i].uuid); + } + } + + private static void assertMessageAddsItems( + ExoCastMessage message, int index, List mediaItems) throws JSONException { + assertThat(message.method).isEqualTo(ExoCastConstants.METHOD_ADD_ITEMS); + JSONObject args = + new JSONObject(message.toJsonString(MOCK_SEQUENCE_NUMBER)).getJSONObject(KEY_ARGS); + if (index != C.INDEX_UNSET) { + assertThat(args.getInt(KEY_INDEX)).isEqualTo(index); + } else { + assertThat(args.has(KEY_INDEX)).isFalse(); + } + JSONArray itemsAsJson = args.getJSONArray(KEY_ITEMS); + assertThat(ReceiverAppStateUpdate.toMediaItemArrayList(itemsAsJson)).isEqualTo(mediaItems); + } + + private static void assertMessageMovesItem(ExoCastMessage message, MediaItem item, int index) + throws JSONException { + assertThat(message.method).isEqualTo(ExoCastConstants.METHOD_MOVE_ITEM); + JSONObject args = + new JSONObject(message.toJsonString(MOCK_SEQUENCE_NUMBER)).getJSONObject(KEY_ARGS); + assertThat(args.getString(KEY_UUID)).isEqualTo(item.uuid.toString()); + assertThat(args.getInt(KEY_INDEX)).isEqualTo(index); + } + + private static void assertMessageRemovesItems(ExoCastMessage message, List items) + throws JSONException { + assertThat(message.method).isEqualTo(ExoCastConstants.METHOD_REMOVE_ITEMS); + JSONObject args = + new JSONObject(message.toJsonString(MOCK_SEQUENCE_NUMBER)).getJSONObject(KEY_ARGS); + JSONArray uuidsAsJson = args.getJSONArray(KEY_UUIDS); + for (int i = 0; i < uuidsAsJson.length(); i++) { + assertThat(uuidsAsJson.getString(i)).isEqualTo(items.get(i).uuid.toString()); + } + } +} diff --git a/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/ExoCastTimelineTest.java b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/ExoCastTimelineTest.java new file mode 100644 index 0000000000..f6084339e4 --- /dev/null +++ b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/ExoCastTimelineTest.java @@ -0,0 +1,466 @@ +/* + * Copyright (C) 2018 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 static com.google.common.truth.Truth.assertThat; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.source.ShuffleOrder.DefaultShuffleOrder; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.UUID; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link ExoCastTimeline}. */ +@RunWith(AndroidJUnit4.class) +public class ExoCastTimelineTest { + + private MediaItem mediaItem1; + private MediaItem mediaItem2; + private MediaItem mediaItem3; + private MediaItem mediaItem4; + private MediaItem mediaItem5; + + @Before + public void setUp() { + MediaItem.Builder builder = new MediaItem.Builder(); + mediaItem1 = builder.setUuid(asUUID(1)).build(); + mediaItem2 = builder.setUuid(asUUID(2)).build(); + mediaItem3 = builder.setUuid(asUUID(3)).build(); + mediaItem4 = builder.setUuid(asUUID(4)).build(); + mediaItem5 = builder.setUuid(asUUID(5)).build(); + } + + @Test + public void getWindowCount_withNoItems_producesExpectedCount() { + ExoCastTimeline timeline = + ExoCastTimeline.createTimelineFor( + Collections.emptyList(), Collections.emptyMap(), new DefaultShuffleOrder(0)); + + assertThat(timeline.getWindowCount()).isEqualTo(0); + } + + @Test + public void getWindowCount_withFiveItems_producesExpectedCount() { + ExoCastTimeline timeline = + ExoCastTimeline.createTimelineFor( + Arrays.asList(mediaItem1, mediaItem2, mediaItem3, mediaItem4, mediaItem5), + Collections.emptyMap(), + new DefaultShuffleOrder(5)); + + assertThat(timeline.getWindowCount()).isEqualTo(5); + } + + @Test + public void getWindow_withNoMediaItemInfo_returnsEmptyWindow() { + ExoCastTimeline timeline = + ExoCastTimeline.createTimelineFor( + Arrays.asList(mediaItem1, mediaItem2, mediaItem3, mediaItem4, mediaItem5), + Collections.emptyMap(), + new DefaultShuffleOrder(5)); + Timeline.Window window = timeline.getWindow(2, new Timeline.Window(), /* setTag= */ true); + + assertThat(window.tag).isNull(); + assertThat(window.presentationStartTimeMs).isEqualTo(C.TIME_UNSET); + assertThat(window.windowStartTimeMs).isEqualTo(C.TIME_UNSET); + assertThat(window.isSeekable).isFalse(); + assertThat(window.isDynamic).isTrue(); + assertThat(window.defaultPositionUs).isEqualTo(0L); + assertThat(window.durationUs).isEqualTo(C.TIME_UNSET); + assertThat(window.firstPeriodIndex).isEqualTo(2); + assertThat(window.lastPeriodIndex).isEqualTo(2); + assertThat(window.positionInFirstPeriodUs).isEqualTo(0L); + } + + @Test + public void getWindow_withMediaItemInfo_returnsPopulatedWindow() { + MediaItem populatedMediaItem = new MediaItem.Builder().setAttachment("attachment").build(); + HashMap mediaItemInfos = new HashMap<>(); + MediaItemInfo.Period period1 = + new MediaItemInfo.Period("id1", /* durationUs= */ 1000000L, /* positionInWindowUs= */ 0L); + MediaItemInfo.Period period2 = + new MediaItemInfo.Period( + "id2", /* durationUs= */ 5000000L, /* positionInWindowUs= */ 1000000L); + mediaItemInfos.put( + populatedMediaItem.uuid, + new MediaItemInfo( + /* windowDurationUs= */ 4000000L, + /* defaultStartPositionUs= */ 20L, + Arrays.asList(period1, period2), + /* positionInFirstPeriodUs= */ 500L, + /* isSeekable= */ true, + /* isDynamic= */ false)); + ExoCastTimeline timeline = + ExoCastTimeline.createTimelineFor( + Arrays.asList(mediaItem1, mediaItem2, mediaItem3, mediaItem4, populatedMediaItem), + mediaItemInfos, + new DefaultShuffleOrder(5)); + Timeline.Window window = timeline.getWindow(4, new Timeline.Window(), /* setTag= */ true); + + assertThat(window.tag).isSameInstanceAs(populatedMediaItem.attachment); + assertThat(window.presentationStartTimeMs).isEqualTo(C.TIME_UNSET); + assertThat(window.windowStartTimeMs).isEqualTo(C.TIME_UNSET); + assertThat(window.isSeekable).isTrue(); + assertThat(window.isDynamic).isFalse(); + assertThat(window.defaultPositionUs).isEqualTo(20L); + assertThat(window.durationUs).isEqualTo(4000000L); + assertThat(window.firstPeriodIndex).isEqualTo(4); + assertThat(window.lastPeriodIndex).isEqualTo(5); + assertThat(window.positionInFirstPeriodUs).isEqualTo(500L); + } + + @Test + public void getPeriodCount_producesExpectedOutput() { + HashMap mediaItemInfos = new HashMap<>(); + MediaItemInfo.Period period1 = + new MediaItemInfo.Period( + "id1", /* durationUs= */ 5000000L, /* positionInWindowUs= */ 1000000L); + MediaItemInfo.Period period2 = + new MediaItemInfo.Period( + "id2", /* durationUs= */ 5000000L, /* positionInWindowUs= */ 6000000L); + mediaItemInfos.put( + asUUID(2), + new MediaItemInfo( + /* windowDurationUs= */ 7000000L, + /* defaultStartPositionUs= */ 20L, + Arrays.asList(period1, period2), + /* positionInFirstPeriodUs= */ 0L, + /* isSeekable= */ true, + /* isDynamic= */ false)); + ExoCastTimeline timeline = + ExoCastTimeline.createTimelineFor( + Arrays.asList(mediaItem1, mediaItem2, mediaItem3, mediaItem4, mediaItem5), + mediaItemInfos, + new DefaultShuffleOrder(5)); + + assertThat(timeline.getPeriodCount()).isEqualTo(6); + } + + @Test + public void getPeriod_forPopulatedPeriod_producesExpectedOutput() { + HashMap mediaItemInfos = new HashMap<>(); + MediaItemInfo.Period period1 = + new MediaItemInfo.Period( + "id1", /* durationUs= */ 4000000L, /* positionInWindowUs= */ 1000000L); + MediaItemInfo.Period period2 = + new MediaItemInfo.Period( + "id2", /* durationUs= */ 5000000L, /* positionInWindowUs= */ 6000000L); + mediaItemInfos.put( + asUUID(5), + new MediaItemInfo( + /* windowDurationUs= */ 7000000L, + /* defaultStartPositionUs= */ 20L, + Arrays.asList(period1, period2), + /* positionInFirstPeriodUs= */ 0L, + /* isSeekable= */ true, + /* isDynamic= */ false)); + ExoCastTimeline timeline = + ExoCastTimeline.createTimelineFor( + Arrays.asList(mediaItem1, mediaItem2, mediaItem3, mediaItem4, mediaItem5), + mediaItemInfos, + new DefaultShuffleOrder(5)); + Timeline.Period period = + timeline.getPeriod(/* periodIndex= */ 5, new Timeline.Period(), /* setIds= */ true); + Object periodUid = timeline.getUidOfPeriod(/* periodIndex= */ 5); + + assertThat(period.durationUs).isEqualTo(5000000L); + assertThat(period.windowIndex).isEqualTo(4); + assertThat(period.id).isEqualTo("id2"); + assertThat(period.uid).isEqualTo(periodUid); + } + + @Test + public void getPeriod_forEmptyPeriod_producesExpectedOutput() { + ExoCastTimeline timeline = + ExoCastTimeline.createTimelineFor( + Arrays.asList(mediaItem1, mediaItem2, mediaItem3, mediaItem4, mediaItem5), + Collections.emptyMap(), + new DefaultShuffleOrder(5)); + Timeline.Period period = timeline.getPeriod(2, new Timeline.Period(), /* setIds= */ true); + Object uid = timeline.getUidOfPeriod(/* periodIndex= */ 2); + + assertThat(period.durationUs).isEqualTo(C.TIME_UNSET); + assertThat(period.windowIndex).isEqualTo(2); + assertThat(period.id).isEqualTo(MediaItemInfo.EMPTY.periods.get(0).id); + assertThat(period.uid).isEqualTo(uid); + } + + @Test + public void getIndexOfPeriod_worksAcrossDifferentTimelines() { + MediaItemInfo.Period period1 = + new MediaItemInfo.Period( + "id1", /* durationUs= */ 4000000L, /* positionInWindowUs= */ 1000000L); + MediaItemInfo.Period period2 = + new MediaItemInfo.Period( + "id2", /* durationUs= */ 5000000L, /* positionInWindowUs= */ 1000000L); + + HashMap mediaItemInfos1 = new HashMap<>(); + mediaItemInfos1.put( + asUUID(1), + new MediaItemInfo( + /* windowDurationUs= */ 5000000L, + /* defaultStartPositionUs= */ 20L, + Collections.singletonList(period2), + /* positionInFirstPeriodUs= */ 0L, + /* isSeekable= */ true, + /* isDynamic= */ false)); + ExoCastTimeline timeline1 = + ExoCastTimeline.createTimelineFor( + Arrays.asList(mediaItem1, mediaItem2), mediaItemInfos1, new DefaultShuffleOrder(2)); + + HashMap mediaItemInfos2 = new HashMap<>(); + mediaItemInfos2.put( + asUUID(1), + new MediaItemInfo( + /* windowDurationUs= */ 7000000L, + /* defaultStartPositionUs= */ 20L, + Arrays.asList(period1, period2), + /* positionInFirstPeriodUs= */ 0L, + /* isSeekable= */ true, + /* isDynamic= */ false)); + ExoCastTimeline timeline2 = + ExoCastTimeline.createTimelineFor( + Arrays.asList(mediaItem2, mediaItem1, mediaItem3, mediaItem4, mediaItem5), + mediaItemInfos2, + new DefaultShuffleOrder(5)); + Object uidOfFirstPeriod = timeline1.getUidOfPeriod(0); + + assertThat(timeline1.getIndexOfPeriod(uidOfFirstPeriod)).isEqualTo(0); + assertThat(timeline2.getIndexOfPeriod(uidOfFirstPeriod)).isEqualTo(2); + } + + @Test + public void getIndexOfPeriod_forLastPeriod_producesExpectedOutput() { + MediaItemInfo.Period period1 = + new MediaItemInfo.Period( + "id1", /* durationUs= */ 4000000L, /* positionInWindowUs= */ 1000000L); + MediaItemInfo.Period period2 = + new MediaItemInfo.Period( + "id2", /* durationUs= */ 5000000L, /* positionInWindowUs= */ 1000000L); + + HashMap mediaItemInfos1 = new HashMap<>(); + mediaItemInfos1.put( + asUUID(5), + new MediaItemInfo( + /* windowDurationUs= */ 4000000L, + /* defaultStartPositionUs= */ 20L, + Collections.singletonList(period2), + /* positionInFirstPeriodUs= */ 0L, + /* isSeekable= */ true, + /* isDynamic= */ false)); + ExoCastTimeline singlePeriodTimeline = + ExoCastTimeline.createTimelineFor( + Collections.singletonList(mediaItem5), mediaItemInfos1, new DefaultShuffleOrder(1)); + Object periodUid = singlePeriodTimeline.getUidOfPeriod(0); + + HashMap mediaItemInfos2 = new HashMap<>(); + mediaItemInfos2.put( + asUUID(5), + new MediaItemInfo( + /* windowDurationUs= */ 7000000L, + /* defaultStartPositionUs= */ 20L, + Arrays.asList(period1, period2), + /* positionInFirstPeriodUs= */ 0L, + /* isSeekable= */ true, + /* isDynamic= */ false)); + ExoCastTimeline timeline = + ExoCastTimeline.createTimelineFor( + Arrays.asList(mediaItem1, mediaItem2, mediaItem3, mediaItem4, mediaItem5), + mediaItemInfos2, + new DefaultShuffleOrder(5)); + + assertThat(timeline.getIndexOfPeriod(periodUid)).isEqualTo(5); + } + + @Test + public void getUidOfPeriod_withInvalidUid_returnsUnsetIndex() { + ExoCastTimeline timeline = + ExoCastTimeline.createTimelineFor( + Arrays.asList(mediaItem1, mediaItem2, mediaItem3, mediaItem4, mediaItem5), + Collections.emptyMap(), + new DefaultShuffleOrder(/* length= */ 5)); + + assertThat(timeline.getIndexOfPeriod(new Object())).isEqualTo(C.INDEX_UNSET); + } + + @Test + public void getFirstWindowIndex_returnsIndexAccordingToShuffleMode() { + ExoCastTimeline timeline = + ExoCastTimeline.createTimelineFor( + Arrays.asList(mediaItem1, mediaItem2, mediaItem3, mediaItem4, mediaItem5), + Collections.emptyMap(), + new DefaultShuffleOrder(new int[] {1, 2, 0, 4, 3}, /* randomSeed= */ 0)); + + assertThat(timeline.getFirstWindowIndex(/* shuffleModeEnabled= */ false)).isEqualTo(0); + assertThat(timeline.getFirstWindowIndex(/* shuffleModeEnabled= */ true)).isEqualTo(1); + } + + @Test + public void getLastWindowIndex_returnsIndexAccordingToShuffleMode() { + ExoCastTimeline timeline = + ExoCastTimeline.createTimelineFor( + Arrays.asList(mediaItem1, mediaItem2, mediaItem3, mediaItem4, mediaItem5), + Collections.emptyMap(), + new DefaultShuffleOrder(new int[] {1, 2, 0, 4, 3}, /* randomSeed= */ 0)); + + assertThat(timeline.getLastWindowIndex(/* shuffleModeEnabled= */ false)).isEqualTo(4); + assertThat(timeline.getLastWindowIndex(/* shuffleModeEnabled= */ true)).isEqualTo(3); + } + + @Test + public void getNextWindowIndex_repeatModeOne_returnsSameIndex() { + ExoCastTimeline timeline = + ExoCastTimeline.createTimelineFor( + Arrays.asList(mediaItem1, mediaItem2, mediaItem3, mediaItem4, mediaItem5), + Collections.emptyMap(), + new DefaultShuffleOrder(5)); + + for (int i = 0; i < 5; i++) { + assertThat( + timeline.getNextWindowIndex( + i, Player.REPEAT_MODE_ONE, /* shuffleModeEnabled= */ true)) + .isEqualTo(i); + assertThat( + timeline.getNextWindowIndex( + i, Player.REPEAT_MODE_ONE, /* shuffleModeEnabled= */ false)) + .isEqualTo(i); + } + } + + @Test + public void getNextWindowIndex_onLastIndex_returnsExpectedIndex() { + ExoCastTimeline timeline = + ExoCastTimeline.createTimelineFor( + Arrays.asList(mediaItem1, mediaItem2, mediaItem3, mediaItem4, mediaItem5), + Collections.emptyMap(), + new DefaultShuffleOrder(new int[] {1, 2, 0, 4, 3}, /* randomSeed= */ 0)); + + // Shuffle mode disabled: + assertThat( + timeline.getNextWindowIndex( + /* windowIndex= */ 4, Player.REPEAT_MODE_OFF, /* shuffleModeEnabled= */ false)) + .isEqualTo(C.INDEX_UNSET); + assertThat( + timeline.getNextWindowIndex( + /* windowIndex= */ 4, Player.REPEAT_MODE_ALL, /* shuffleModeEnabled= */ false)) + .isEqualTo(0); + // Shuffle mode enabled: + assertThat( + timeline.getNextWindowIndex( + /* windowIndex= */ 3, Player.REPEAT_MODE_OFF, /* shuffleModeEnabled= */ true)) + .isEqualTo(C.INDEX_UNSET); + assertThat( + timeline.getNextWindowIndex( + /* windowIndex= */ 3, Player.REPEAT_MODE_ALL, /* shuffleModeEnabled= */ true)) + .isEqualTo(1); + } + + @Test + public void getNextWindowIndex_inMiddleOfQueue_returnsNextIndex() { + ExoCastTimeline timeline = + ExoCastTimeline.createTimelineFor( + Arrays.asList(mediaItem1, mediaItem2, mediaItem3, mediaItem4, mediaItem5), + Collections.emptyMap(), + new DefaultShuffleOrder(new int[] {1, 2, 0, 4, 3}, /* randomSeed= */ 0)); + + // Shuffle mode disabled: + assertThat( + timeline.getNextWindowIndex( + /* windowIndex= */ 2, Player.REPEAT_MODE_OFF, /* shuffleModeEnabled= */ false)) + .isEqualTo(3); + // Shuffle mode enabled: + assertThat( + timeline.getNextWindowIndex( + /* windowIndex= */ 2, Player.REPEAT_MODE_OFF, /* shuffleModeEnabled= */ true)) + .isEqualTo(0); + } + + @Test + public void getPreviousWindowIndex_repeatModeOne_returnsSameIndex() { + ExoCastTimeline timeline = + ExoCastTimeline.createTimelineFor( + Arrays.asList(mediaItem1, mediaItem2, mediaItem3, mediaItem4, mediaItem5), + Collections.emptyMap(), + new DefaultShuffleOrder(new int[] {1, 2, 0, 4, 3}, /* randomSeed= */ 0)); + + for (int i = 0; i < 5; i++) { + assertThat( + timeline.getPreviousWindowIndex( + /* windowIndex= */ i, Player.REPEAT_MODE_ONE, /* shuffleModeEnabled= */ true)) + .isEqualTo(i); + assertThat( + timeline.getPreviousWindowIndex( + /* windowIndex= */ i, Player.REPEAT_MODE_ONE, /* shuffleModeEnabled= */ false)) + .isEqualTo(i); + } + } + + @Test + public void getPreviousWindowIndex_onFirstIndex_returnsExpectedIndex() { + ExoCastTimeline timeline = + ExoCastTimeline.createTimelineFor( + Arrays.asList(mediaItem1, mediaItem2, mediaItem3, mediaItem4, mediaItem5), + Collections.emptyMap(), + new DefaultShuffleOrder(new int[] {1, 2, 0, 4, 3}, /* randomSeed= */ 0)); + + // Shuffle mode disabled: + assertThat( + timeline.getPreviousWindowIndex( + /* windowIndex= */ 0, Player.REPEAT_MODE_OFF, /* shuffleModeEnabled= */ false)) + .isEqualTo(C.INDEX_UNSET); + assertThat( + timeline.getPreviousWindowIndex( + /* windowIndex= */ 0, Player.REPEAT_MODE_ALL, /* shuffleModeEnabled= */ false)) + .isEqualTo(4); + // Shuffle mode enabled: + assertThat( + timeline.getPreviousWindowIndex( + /* windowIndex= */ 1, Player.REPEAT_MODE_OFF, /* shuffleModeEnabled= */ true)) + .isEqualTo(C.INDEX_UNSET); + assertThat( + timeline.getPreviousWindowIndex( + /* windowIndex= */ 1, Player.REPEAT_MODE_ALL, /* shuffleModeEnabled= */ true)) + .isEqualTo(3); + } + + @Test + public void getPreviousWindowIndex_inMiddleOfQueue_returnsPreviousIndex() { + ExoCastTimeline timeline = + ExoCastTimeline.createTimelineFor( + Arrays.asList(mediaItem1, mediaItem2, mediaItem3, mediaItem4, mediaItem5), + Collections.emptyMap(), + new DefaultShuffleOrder(new int[] {1, 2, 0, 4, 3}, /* randomSeed= */ 0)); + + assertThat( + timeline.getPreviousWindowIndex( + /* windowIndex= */ 4, Player.REPEAT_MODE_ALL, /* shuffleModeEnabled= */ false)) + .isEqualTo(3); + assertThat( + timeline.getPreviousWindowIndex( + /* windowIndex= */ 4, Player.REPEAT_MODE_OFF, /* shuffleModeEnabled= */ true)) + .isEqualTo(0); + } + + private static UUID asUUID(long number) { + return new UUID(/* mostSigBits= */ 0L, number); + } +} diff --git a/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/ReceiverAppStateUpdateTest.java b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/ReceiverAppStateUpdateTest.java new file mode 100644 index 0000000000..fbe936a016 --- /dev/null +++ b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/ReceiverAppStateUpdateTest.java @@ -0,0 +1,378 @@ +/* + * Copyright (C) 2018 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 static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_DEFAULT_START_POSITION_US; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_DISABLED_TEXT_TRACK_SELECTION_FLAGS; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_DISCONTINUITY_REASON; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_DURATION_US; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_ERROR_MESSAGE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_ID; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_IS_DYNAMIC; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_IS_LOADING; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_IS_SEEKABLE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_MEDIA_ITEMS_INFO; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_MEDIA_QUEUE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_PERIODS; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_PERIOD_ID; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_PITCH; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_PLAYBACK_PARAMETERS; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_PLAYBACK_POSITION; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_PLAYBACK_STATE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_PLAY_WHEN_READY; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_POSITION_IN_FIRST_PERIOD_US; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_POSITION_MS; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_PREFERRED_AUDIO_LANGUAGE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_PREFERRED_TEXT_LANGUAGE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_REPEAT_MODE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_SELECT_UNDETERMINED_TEXT_LANGUAGE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_SEQUENCE_NUMBER; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_SHUFFLE_MODE_ENABLED; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_SHUFFLE_ORDER; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_SKIP_SILENCE; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_SPEED; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_TRACK_SELECTION_PARAMETERS; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_UUID; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.KEY_WINDOW_DURATION_US; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.STR_DISCONTINUITY_REASON_SEEK; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.STR_REPEAT_MODE_OFF; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.STR_SELECTION_FLAG_FORCED; +import static com.google.android.exoplayer2.ext.cast.ExoCastConstants.STR_STATE_BUFFERING; +import static com.google.common.truth.Truth.assertThat; + +import android.net.Uri; +import androidx.test.ext.junit.runners.AndroidJUnit4; +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.ShuffleOrder; +import com.google.android.exoplayer2.trackselection.TrackSelectionParameters; +import com.google.android.exoplayer2.util.Util; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.UUID; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit tests for {@link ReceiverAppStateUpdate}. */ +@RunWith(AndroidJUnit4.class) +public class ReceiverAppStateUpdateTest { + + private static final long MOCK_SEQUENCE_NUMBER = 1; + + @Test + public void statusUpdate_withPlayWhenReady_producesExpectedUpdate() throws JSONException { + ReceiverAppStateUpdate stateUpdate = + ReceiverAppStateUpdate.builder(MOCK_SEQUENCE_NUMBER).setPlayWhenReady(true).build(); + JSONObject stateMessage = createStateMessage().put(KEY_PLAY_WHEN_READY, true); + + assertThat(ReceiverAppStateUpdate.fromJsonMessage(stateMessage.toString())) + .isEqualTo(stateUpdate); + } + + @Test + public void statusUpdate_withPlaybackState_producesExpectedUpdate() throws JSONException { + ReceiverAppStateUpdate stateUpdate = + ReceiverAppStateUpdate.builder(MOCK_SEQUENCE_NUMBER) + .setPlaybackState(Player.STATE_BUFFERING) + .build(); + JSONObject stateMessage = createStateMessage().put(KEY_PLAYBACK_STATE, STR_STATE_BUFFERING); + + assertThat(ReceiverAppStateUpdate.fromJsonMessage(stateMessage.toString())) + .isEqualTo(stateUpdate); + } + + @Test + public void statusUpdate_withMediaQueue_producesExpectedUpdate() throws JSONException { + HashMap requestHeaders = new HashMap<>(); + requestHeaders.put("key", "value"); + MediaItem.UriBundle media = new MediaItem.UriBundle(Uri.parse("www.media.com"), requestHeaders); + MediaItem.DrmScheme drmScheme1 = + new MediaItem.DrmScheme( + C.WIDEVINE_UUID, + new MediaItem.UriBundle(Uri.parse("www.widevine.com"), requestHeaders)); + MediaItem.DrmScheme drmScheme2 = + new MediaItem.DrmScheme( + C.PLAYREADY_UUID, + new MediaItem.UriBundle(Uri.parse("www.playready.com"), requestHeaders)); + MediaItem item = + new MediaItem.Builder() + .setTitle("title") + .setDescription("description") + .setMedia(media) + .setDrmSchemes(Arrays.asList(drmScheme1, drmScheme2)) + .setStartPositionUs(10) + .setEndPositionUs(20) + .build(); + ReceiverAppStateUpdate stateUpdate = + ReceiverAppStateUpdate.builder(MOCK_SEQUENCE_NUMBER) + .setItems(Collections.singletonList(item)) + .build(); + JSONObject object = + createStateMessage() + .put(KEY_MEDIA_QUEUE, new JSONArray().put(ExoCastMessage.mediaItemAsJsonObject(item))); + + assertThat(ReceiverAppStateUpdate.fromJsonMessage(object.toString())).isEqualTo(stateUpdate); + } + + @Test + public void statusUpdate_withRepeatMode_producesExpectedUpdate() throws JSONException { + ReceiverAppStateUpdate stateUpdate = + ReceiverAppStateUpdate.builder(MOCK_SEQUENCE_NUMBER) + .setRepeatMode(Player.REPEAT_MODE_OFF) + .build(); + JSONObject stateMessage = createStateMessage().put(KEY_REPEAT_MODE, STR_REPEAT_MODE_OFF); + + assertThat(ReceiverAppStateUpdate.fromJsonMessage(stateMessage.toString())) + .isEqualTo(stateUpdate); + } + + @Test + public void statusUpdate_withShuffleModeEnabled_producesExpectedUpdate() throws JSONException { + ReceiverAppStateUpdate stateUpdate = + ReceiverAppStateUpdate.builder(MOCK_SEQUENCE_NUMBER).setShuffleModeEnabled(false).build(); + JSONObject stateMessage = createStateMessage().put(KEY_SHUFFLE_MODE_ENABLED, false); + + assertThat(ReceiverAppStateUpdate.fromJsonMessage(stateMessage.toString())) + .isEqualTo(stateUpdate); + } + + @Test + public void statusUpdate_withIsLoading_producesExpectedUpdate() throws JSONException { + ReceiverAppStateUpdate stateUpdate = + ReceiverAppStateUpdate.builder(MOCK_SEQUENCE_NUMBER).setIsLoading(true).build(); + JSONObject stateMessage = createStateMessage().put(KEY_IS_LOADING, true); + + assertThat(ReceiverAppStateUpdate.fromJsonMessage(stateMessage.toString())) + .isEqualTo(stateUpdate); + } + + @Test + public void statusUpdate_withPlaybackParameters_producesExpectedUpdate() throws JSONException { + ReceiverAppStateUpdate stateUpdate = + ReceiverAppStateUpdate.builder(MOCK_SEQUENCE_NUMBER) + .setPlaybackParameters( + new PlaybackParameters( + /* speed= */ .5f, /* pitch= */ .25f, /* skipSilence= */ false)) + .build(); + JSONObject playbackParamsJson = + new JSONObject().put(KEY_SPEED, .5).put(KEY_PITCH, .25).put(KEY_SKIP_SILENCE, false); + JSONObject stateMessage = createStateMessage().put(KEY_PLAYBACK_PARAMETERS, playbackParamsJson); + + assertThat(ReceiverAppStateUpdate.fromJsonMessage(stateMessage.toString())) + .isEqualTo(stateUpdate); + } + + @Test + public void statusUpdate_withTrackSelectionParameters_producesExpectedUpdate() + throws JSONException { + ReceiverAppStateUpdate stateUpdate = + ReceiverAppStateUpdate.builder(MOCK_SEQUENCE_NUMBER) + .setTrackSelectionParameters( + TrackSelectionParameters.DEFAULT + .buildUpon() + .setDisabledTextTrackSelectionFlags( + C.SELECTION_FLAG_FORCED | C.SELECTION_FLAG_DEFAULT) + .setPreferredAudioLanguage("esp") + .setPreferredTextLanguage("deu") + .setSelectUndeterminedTextLanguage(true) + .build()) + .build(); + + JSONArray selectionFlagsJson = + new JSONArray() + .put(ExoCastConstants.STR_SELECTION_FLAG_DEFAULT) + .put(STR_SELECTION_FLAG_FORCED); + JSONObject playbackParamsJson = + new JSONObject() + .put(KEY_DISABLED_TEXT_TRACK_SELECTION_FLAGS, selectionFlagsJson) + .put(KEY_PREFERRED_AUDIO_LANGUAGE, "esp") + .put(KEY_PREFERRED_TEXT_LANGUAGE, "deu") + .put(KEY_SELECT_UNDETERMINED_TEXT_LANGUAGE, true); + JSONObject object = + createStateMessage().put(KEY_TRACK_SELECTION_PARAMETERS, playbackParamsJson); + + assertThat(ReceiverAppStateUpdate.fromJsonMessage(object.toString())).isEqualTo(stateUpdate); + } + + @Test + public void statusUpdate_withError_producesExpectedUpdate() throws JSONException { + ReceiverAppStateUpdate stateUpdate = + ReceiverAppStateUpdate.builder(MOCK_SEQUENCE_NUMBER) + .setErrorMessage("error message") + .build(); + JSONObject stateMessage = createStateMessage().put(KEY_ERROR_MESSAGE, "error message"); + + assertThat(ReceiverAppStateUpdate.fromJsonMessage(stateMessage.toString())) + .isEqualTo(stateUpdate); + } + + @Test + public void statusUpdate_withPlaybackPosition_producesExpectedUpdate() throws JSONException { + ReceiverAppStateUpdate stateUpdate = + ReceiverAppStateUpdate.builder(MOCK_SEQUENCE_NUMBER) + .setPlaybackPosition( + new UUID(/* mostSigBits= */ 0, /* leastSigBits= */ 1), "period", 10L) + .build(); + JSONObject positionJson = + new JSONObject() + .put(KEY_UUID, new UUID(0, 1)) + .put(KEY_POSITION_MS, 10) + .put(KEY_PERIOD_ID, "period"); + JSONObject stateMessage = createStateMessage().put(KEY_PLAYBACK_POSITION, positionJson); + + assertThat(ReceiverAppStateUpdate.fromJsonMessage(stateMessage.toString())) + .isEqualTo(stateUpdate); + } + + @Test + public void statusUpdate_withDiscontinuity_producesExpectedUpdate() throws JSONException { + ReceiverAppStateUpdate stateUpdate = + ReceiverAppStateUpdate.builder(MOCK_SEQUENCE_NUMBER) + .setPlaybackPosition( + new UUID(/* mostSigBits= */ 0, /* leastSigBits= */ 1), "period", 10L) + .setDiscontinuityReason(Player.DISCONTINUITY_REASON_SEEK) + .build(); + JSONObject positionJson = + new JSONObject() + .put(KEY_UUID, new UUID(0, 1)) + .put(KEY_POSITION_MS, 10) + .put(KEY_PERIOD_ID, "period") + .put(KEY_DISCONTINUITY_REASON, STR_DISCONTINUITY_REASON_SEEK); + JSONObject stateMessage = createStateMessage().put(KEY_PLAYBACK_POSITION, positionJson); + + assertThat(ReceiverAppStateUpdate.fromJsonMessage(stateMessage.toString())) + .isEqualTo(stateUpdate); + } + + @Test + public void statusUpdate_withMediaItemInfo_producesExpectedTimeline() throws JSONException { + MediaItem.Builder builder = new MediaItem.Builder(); + MediaItem item1 = builder.setUuid(new UUID(0, 1)).build(); + MediaItem item2 = builder.setUuid(new UUID(0, 2)).build(); + + JSONArray periodsJson = new JSONArray(); + periodsJson + .put(new JSONObject().put(KEY_ID, "id1").put(KEY_DURATION_US, 5000000L)) + .put(new JSONObject().put(KEY_ID, "id2").put(KEY_DURATION_US, 7000000L)) + .put(new JSONObject().put(KEY_ID, "id3").put(KEY_DURATION_US, 6000000L)); + JSONObject mediaItemInfoForUuid1 = new JSONObject(); + mediaItemInfoForUuid1 + .put(KEY_WINDOW_DURATION_US, 10000000L) + .put(KEY_DEFAULT_START_POSITION_US, 1000000L) + .put(KEY_PERIODS, periodsJson) + .put(KEY_POSITION_IN_FIRST_PERIOD_US, 2000000L) + .put(KEY_IS_DYNAMIC, false) + .put(KEY_IS_SEEKABLE, true); + JSONObject mediaItemInfoMapJson = + new JSONObject().put(new UUID(0, 1).toString(), mediaItemInfoForUuid1); + + JSONObject receiverAppStateUpdateJson = + createStateMessage().put(KEY_MEDIA_ITEMS_INFO, mediaItemInfoMapJson); + ReceiverAppStateUpdate receiverAppStateUpdate = + ReceiverAppStateUpdate.fromJsonMessage(receiverAppStateUpdateJson.toString()); + ExoCastTimeline timeline = + ExoCastTimeline.createTimelineFor( + Arrays.asList(item1, item2), + receiverAppStateUpdate.mediaItemsInformation, + new ShuffleOrder.DefaultShuffleOrder( + /* shuffledIndices= */ new int[] {1, 0}, /* randomSeed= */ 0)); + Timeline.Window window0 = + timeline.getWindow(/* windowIndex= */ 0, new Timeline.Window(), /* setTag= */ true); + Timeline.Window window1 = + timeline.getWindow(/* windowIndex= */ 1, new Timeline.Window(), /* setTag= */ true); + Timeline.Period[] periods = new Timeline.Period[4]; + for (int i = 0; i < 4; i++) { + periods[i] = + timeline.getPeriod(/* periodIndex= */ i, new Timeline.Period(), /* setIds= */ true); + } + + assertThat(timeline.getWindowCount()).isEqualTo(2); + assertThat(window0.positionInFirstPeriodUs).isEqualTo(2000000L); + assertThat(window0.durationUs).isEqualTo(10000000L); + assertThat(window0.isDynamic).isFalse(); + assertThat(window0.isSeekable).isTrue(); + assertThat(window0.defaultPositionUs).isEqualTo(1000000L); + assertThat(window1.positionInFirstPeriodUs).isEqualTo(0L); + assertThat(window1.durationUs).isEqualTo(C.TIME_UNSET); + assertThat(window1.isDynamic).isTrue(); + assertThat(window1.isSeekable).isFalse(); + assertThat(window1.defaultPositionUs).isEqualTo(0L); + + assertThat(timeline.getPeriodCount()).isEqualTo(4); + assertThat(periods[0].id).isEqualTo("id1"); + assertThat(periods[0].getPositionInWindowUs()).isEqualTo(-2000000L); + assertThat(periods[0].durationUs).isEqualTo(5000000L); + assertThat(periods[1].id).isEqualTo("id2"); + assertThat(periods[1].durationUs).isEqualTo(7000000L); + assertThat(periods[1].getPositionInWindowUs()).isEqualTo(3000000L); + assertThat(periods[2].id).isEqualTo("id3"); + assertThat(periods[2].durationUs).isEqualTo(6000000L); + assertThat(periods[2].getPositionInWindowUs()).isEqualTo(10000000L); + assertThat(periods[3].durationUs).isEqualTo(C.TIME_UNSET); + } + + @Test + public void statusUpdate_withShuffleOrder_producesExpectedTimeline() throws JSONException { + MediaItem.Builder builder = new MediaItem.Builder(); + JSONObject receiverAppStateUpdateJson = + createStateMessage().put(KEY_SHUFFLE_ORDER, new JSONArray(Arrays.asList(2, 3, 1, 0))); + ReceiverAppStateUpdate receiverAppStateUpdate = + ReceiverAppStateUpdate.fromJsonMessage(receiverAppStateUpdateJson.toString()); + ExoCastTimeline timeline = + ExoCastTimeline.createTimelineFor( + /* mediaItems= */ Arrays.asList( + builder.build(), builder.build(), builder.build(), builder.build()), + /* mediaItemInfoMap= */ Collections.emptyMap(), + /* shuffleOrder= */ new ShuffleOrder.DefaultShuffleOrder( + Util.toArray(receiverAppStateUpdate.shuffleOrder), /* randomSeed= */ 0)); + + assertThat(timeline.getFirstWindowIndex(/* shuffleModeEnabled= */ true)).isEqualTo(2); + assertThat( + timeline.getNextWindowIndex( + /* windowIndex= */ 2, + /* repeatMode= */ Player.REPEAT_MODE_OFF, + /* shuffleModeEnabled= */ true)) + .isEqualTo(3); + assertThat( + timeline.getNextWindowIndex( + /* windowIndex= */ 3, + /* repeatMode= */ Player.REPEAT_MODE_OFF, + /* shuffleModeEnabled= */ true)) + .isEqualTo(1); + assertThat( + timeline.getNextWindowIndex( + /* windowIndex= */ 1, + /* repeatMode= */ Player.REPEAT_MODE_OFF, + /* shuffleModeEnabled= */ true)) + .isEqualTo(0); + assertThat( + timeline.getNextWindowIndex( + /* windowIndex= */ 0, + /* repeatMode= */ Player.REPEAT_MODE_OFF, + /* shuffleModeEnabled= */ true)) + .isEqualTo(C.INDEX_UNSET); + } + + private static JSONObject createStateMessage() throws JSONException { + return new JSONObject().put(KEY_SEQUENCE_NUMBER, MOCK_SEQUENCE_NUMBER); + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/DecryptableSampleQueueReader.java b/library/core/src/main/java/com/google/android/exoplayer2/source/DecryptableSampleQueueReader.java index cb548ec3fd..12db27d68e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/DecryptableSampleQueueReader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/DecryptableSampleQueueReader.java @@ -71,6 +71,7 @@ public final class DecryptableSampleQueueReader { * @throws IOException The underlying error. */ public void maybeThrowError() throws IOException { + // TODO: Avoid throwing if the DRM error is not preventing a read operation. if (currentSession != null && currentSession.getState() == DrmSession.STATE_ERROR) { throw Assertions.checkNotNull(currentSession.getError()); } @@ -179,4 +180,21 @@ public final class DecryptableSampleQueueReader { previousSession.releaseReference(); } } + + /** Returns whether there is data available for reading. */ + public boolean isReady(boolean loadingFinished) { + @SampleQueue.PeekResult int nextInQueue = upstream.peekNext(); + if (nextInQueue == SampleQueue.PEEK_RESULT_NOTHING) { + return loadingFinished; + } else if (nextInQueue == SampleQueue.PEEK_RESULT_FORMAT) { + return true; + } else if (nextInQueue == SampleQueue.PEEK_RESULT_BUFFER_CLEAR) { + return currentSession == null || playClearSamplesWithoutKeys; + } else if (nextInQueue == SampleQueue.PEEK_RESULT_BUFFER_ENCRYPTED) { + return Assertions.checkNotNull(currentSession).getState() + == DrmSession.STATE_OPENED_WITH_KEYS; + } else { + throw new IllegalStateException(); + } + } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleMetadataQueue.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleMetadataQueue.java index b2c09bd70f..542565e70d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleMetadataQueue.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleMetadataQueue.java @@ -20,6 +20,7 @@ import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.FormatHolder; import com.google.android.exoplayer2.decoder.DecoderInputBuffer; import com.google.android.exoplayer2.extractor.TrackOutput.CryptoData; +import com.google.android.exoplayer2.source.SampleQueue.PeekResult; import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Util; @@ -214,6 +215,27 @@ import com.google.android.exoplayer2.util.Util; readPosition = 0; } + /** + * Returns a {@link PeekResult} depending on what a following call to {@link #read + * read(formatHolder, decoderInputBuffer, formatRequired= false, allowOnlyClearBuffers= false, + * loadingFinished= false, decodeOnlyUntilUs= 0)} would result in. + */ + @SuppressWarnings("ReferenceEquality") + @PeekResult + public synchronized int peekNext(Format downstreamFormat) { + if (readPosition == length) { + return SampleQueue.PEEK_RESULT_NOTHING; + } + int relativeReadIndex = getRelativeIndex(readPosition); + if (formats[relativeReadIndex] != downstreamFormat) { + return SampleQueue.PEEK_RESULT_FORMAT; + } else { + return (flags[relativeReadIndex] & C.BUFFER_FLAG_ENCRYPTED) != 0 + ? SampleQueue.PEEK_RESULT_BUFFER_ENCRYPTED + : SampleQueue.PEEK_RESULT_BUFFER_CLEAR; + } + } + /** * Attempts to read from the queue. * diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java index 976a5d4e48..921afcdf2f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SampleQueue.java @@ -15,6 +15,7 @@ */ package com.google.android.exoplayer2.source; +import androidx.annotation.IntDef; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; @@ -28,6 +29,9 @@ import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.util.ParsableByteArray; import java.io.EOFException; import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; import java.nio.ByteBuffer; /** A queue of media samples. */ @@ -47,6 +51,27 @@ public class SampleQueue implements TrackOutput { } + /** Values returned by {@link #peekNext()}. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef( + value = { + PEEK_RESULT_NOTHING, + PEEK_RESULT_FORMAT, + PEEK_RESULT_BUFFER_CLEAR, + PEEK_RESULT_BUFFER_ENCRYPTED + }) + @interface PeekResult {} + + /** Nothing is available for reading. */ + public static final int PEEK_RESULT_NOTHING = 0; + /** A format change is available for reading */ + public static final int PEEK_RESULT_FORMAT = 1; + /** A clear buffer is available for reading. */ + public static final int PEEK_RESULT_BUFFER_CLEAR = 2; + /** An encrypted buffer is available for reading. */ + public static final int PEEK_RESULT_BUFFER_ENCRYPTED = 3; + public static final int ADVANCE_FAILED = -1; private static final int INITIAL_SCRATCH_SIZE = 32; @@ -312,6 +337,16 @@ public class SampleQueue implements TrackOutput { return metadataQueue.setReadPosition(sampleIndex); } + /** + * Returns a {@link PeekResult} depending on what a following call to {@link #read + * read(formatHolder, decoderInputBuffer, formatRequired= false, allowOnlyClearBuffers= false, + * loadingFinished= false, decodeOnlyUntilUs= 0)} would result in. + */ + @PeekResult + public int peekNext() { + return metadataQueue.peekNext(downstreamFormat); + } + /** * Attempts to read from the queue. *