diff --git a/.github/publish.sh b/.github/publish.sh new file mode 100644 index 000000000..f209c5c8a --- /dev/null +++ b/.github/publish.sh @@ -0,0 +1,191 @@ +#!/bin/bash +# This script takes care of the following tasks, +## 1. Create a new branch from the default 'develop' or 'dev' branch. +## 2. Change the version in version.gradle file. +## 3. Commit and push the version.gradle changes to remote. +## 4. Call `release_notes.sh` to generate release notes. +## 5. Publish and Close it for SONATYPE Maven publishing. +## 6. Create a TAG with release notes and push to remote. +## 7. Notify MS-Teams when the whole process it done. +## IMP: For Patch, there should always be a branch pushed to the remote +## named as `patch/v*.*.*`. Because for patch, script is checking out that branch +## and tagging it. + +exit_on_failure() { + echo "$@" 1>&2 + exit 1 +} + +checkout() { + echo Checking out newtag = "$NEW_TAG", release type = "$RELEASE_TYPE" + + case $RELEASE_TYPE in + Full) + git checkout -b "$BRANCH_NAME" || exit_on_failure "Unable to checkout $BRANCH_NAME";; + Patch) + git checkout "$BRANCH_NAME" || exit_on_failure "Unable to checkout $BRANCH_NAME";; + Update) + git checkout -b "$BRANCH_NAME" "$PREV_TAG" || exit_on_failure "Unable to checkout $BRANCH_NAME";; + esac +} + +set_version() { + echo Setting version of "$REPO_NAME" to "$NEW_VERSION" + + # Changing the version in version.gradle file + perl -pi -e "s/^ext.playkitVersion.*$/ext.playkitVersion = '$NEW_VERSION'/" $VERSION_FILE +} + +build() { + chmod +x gradlew + ./gradlew publishToSonatype closeAndReleaseSonatypeStagingRepository +} + +release_and_tag() { + git config user.name "$GH_USER_NAME" + git config user.email "<>" + + echo Releasing version $NEW_VERSION of $REPO_NAME to GitHub + set +e + git add $VERSION_FILE + git commit -m "Update version to $NEW_TAG" + set -e + git push origin HEAD:$BRANCH_NAME + + # Generate Release notes + bash $RELEASE_NOTES_SCRIPT + + if [[ "$RELEASE_TYPE" = "Patch" || "$RELEASE_TYPE" = "Full" ]]; then + + releaseNotes=$(awk -v d="\\\n" '{s=(NR==1?s:s d)$0}END{print s}' $RELEASE_NOTES) + + +cat << EOF > ./post.json +{ + "name": "$NEW_TAG", + "body": "$releaseNotes", + "tag_name": "$NEW_TAG", + "target_commitish": "$BRANCH_NAME" +} +EOF + fi + + if [ "$RELEASE_TYPE" = "Update" ]; then + JSON_BODY="### Playkit Plugin Support\n\n" + JSON_BODY="$JSON_BODY$NEW_TAG\n\n" + JSON_BODY="$JSON_BODY * upgrade to $NEW_TAG\n\n" + JSON_BODY="$JSON_BODY #### Gradle\n\n" + JSON_BODY="$JSON_BODY * implementation 'com.kaltura.playkit:playkit" + JSON_BODY="$NEW_VERSION" + JSON_BODY="$JSON_BODY'" + + +cat << EOF > ./post.json +{ + "name": "$NEW_TAG", + "body": "## Changes from [$PREV_TAG](https://github.com/kaltura/$REPO_NAME/releases/tag/$PREV_TAG)\n\n$JSON_BODY", + "tag_name": "$NEW_TAG", + "target_commitish": "$BRANCH_NAME" +} +EOF + fi + + cat post.json + + curl --request POST \ + --url https://api.github.com/repos/kaltura/$REPO_NAME/releases \ + --header "authorization: Bearer $TOKEN" \ + --header 'content-type: application/json' \ + -d@post.json + + rm ./post.json + rm $RELEASE_NOTES + + # delete temp branch + #git push origin --delete $BRANCH_NAME +} + +notify_teams() { +COMMIT_SHA=$(git log --pretty=format:'%h' -n 1) +COMMIT_MESSAGE=$(git log --format=%B -n 1 "$COMMIT_SHA") + +color=0072C6 + curl "$TEAMS_WEBHOOK" -d @- << EOF + { + "@context": "https://schema.org/extensions", + "@type": "MessageCard", + "themeColor": "$color", + "title": "$REPO_NAME | $BRANCH_NAME", + "text": "🎉 Release Ready", + "sections": [ + { + "facts": [ + { + "name": "Branch/tag", + "value": "$BRANCH_NAME" + }, + { + "name": "Commit", + "value": "$COMMIT_SHA ($COMMIT_MESSAGE)" + }, + { + "name": "Pusher", + "value": "$GH_USER_NAME" + }, + { + "name": "Gradle line", + "value": "implementation 'com.kaltura.playkit:playkit:$COMMIT_SHA'" + } + ] + } + ], + "potentialAction": [ + { + "@type": "OpenUri", + "name": "GitHub Release Page", + "targets": [ + { + "os": "default", + "uri": "$RELEASE_URL" + } + ] + } + ] + } +EOF + +} + + RELEASE_TYPE=$RELEASE_TYPE + + export REPO_NAME=$REPO_NAME + MODULE_NAME=$MODULE_NAME + VERSION_FILE=$MODULE_NAME/version.gradle + + REPO_URL=https://github.com/kaltura/$REPO_NAME + export NEW_VERSION=$NEW_VERSION + PREV_VERSION=$PREV_VERSION + TOKEN=$TOKEN + TEAMS_WEBHOOK=$TEAMS_WEBHOOK + + NEW_TAG=v$NEW_VERSION #New Version with 'v' + export PREV_TAG=v$PREV_VERSION #Previous Version with 'v' + RELEASE_URL=$REPO_URL/releases/tag/$NEW_TAG + + if [[ "$RELEASE_TYPE" = "Full" || "$RELEASE_TYPE" = "Update" ]]; then + BRANCH_NAME="release/$NEW_TAG" + fi + + if [ "$RELEASE_TYPE" = "Patch" ]; then + BRANCH_NAME="patch/$NEW_TAG" + fi + + export RELEASE_NOTES="release_notes.md" + RELEASE_NOTES_SCRIPT=".github/release_notes.sh" + GH_USER_NAME="Github Actions Bot KLTR" + + checkout + set_version + build + release_and_tag + notify_teams diff --git a/.github/release_notes.sh b/.github/release_notes.sh new file mode 100644 index 000000000..ded969acc --- /dev/null +++ b/.github/release_notes.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# This script generates "release_notes.md". +# It grabs the logs from the previous defined tag till the HEAD +# and do grep of the PULL requests by filtering using '(#' +# While generating a release notes file, it has 3 sections +# 'New Features', 'Bug Fixes' and 'More Changes' + +# STRICT RULES FOR PULL REQUEST SUBJECT LINE: +## 1. 'New Feature' PR should start with 'feat(FEC-***)'. Add '|' Pipe symbol should be added before subject line starts. +## Example: feat(FEC-1234) | PR Subject line +## 2. 'Bug Fixes' PR should start with 'fix(FEC-***)'. Add '|' Pipe symbol should be added before subject line starts. +## Example: fix(FEC-1234) | PR Subject line +## 3. 'Other Changes' PR which is apart from the above can start like +## Example: FEC-1234 | PR Subject line + +nl=$'\n' +touch $RELEASE_NOTES +echo "## Changes from [$PREV_TAG](https://github.com/kaltura/$REPO_NAME/releases/tag/$PREV_TAG)$nl" > $RELEASE_NOTES + +resultedLine=$(git log $PREV_TAG..HEAD --oneline --grep='(#') +if [[ ! -n "$resultedLine" ]]; then + echo "### Plugin Playkit Support$nl v$NEW_VERSION" >> $RELEASE_NOTES +else + git log $PREV_TAG..HEAD --oneline --grep='(#' | cut -d' ' -f2- | while read -r line; do + echo "$line" + + bugFixes="Bug Fixes" + newFeatures="New Features" + moreChanges="More Changes" + + if [[ "$line" == "fix"* || "$line" == "fix(FEC-"* || "$line" == "fix (FEC-"* ]]; then + + grep -qF -- $bugFixes $RELEASE_NOTES || echo "### "$bugFixes$nl >> $RELEASE_NOTES + modifiedLine=$(echo "$line" | sed 's/fix://' | sed 's/fix//' | sed 's|(\(FEC-[^)]*\))|\1|') +sed -i '/'"$bugFixes"'/a\ +'"- $modifiedLine$nl"'' $RELEASE_NOTES + + elif [[ "$line" == "feat"* || "$line" == "feat(FEC-"* || "$line" == "feat (FEC-"* ]]; then + + grep -qF -- $newFeatures $RELEASE_NOTES || echo "### "$newFeatures$nl >> $RELEASE_NOTES + modifiedLine=$(echo "$line" | sed 's/feat://' | sed 's/feat//' | sed 's|(\(FEC-[^)]*\))|\1|') +sed -i '/'"$newFeatures"'/a\ +'"- $modifiedLine$nl"'' $RELEASE_NOTES + + else + grep -qF -- $moreChanges $RELEASE_NOTES || echo "### "$moreChanges$nl >> $RELEASE_NOTES + echo "- $line$nl" >> $RELEASE_NOTES + + fi + done +fi + +echo "### Plugin's Version" >> $RELEASE_NOTES +echo "$nl* \`implementation 'com.kaltura.playkit:playkit:$NEW_VERSION"\'\` >> $RELEASE_NOTES +echo "$nl* \`implementation 'com.kaltura.playkit:playkitproviders:$NEW_VERSION"\'\` >> $RELEASE_NOTES +echo "$nl* \`implementation 'com.kaltura.playkit:youboraplugin:$NEW_VERSION"\'\` >> $RELEASE_NOTES +echo "$nl* \`implementation 'com.kaltura.playkit:imaplugin:$NEW_VERSION"\'\` >> $RELEASE_NOTES +echo "$nl* \`implementation 'com.kaltura.playkit:kavaplugin:$NEW_VERSION"\'\` >> $RELEASE_NOTES +echo "$nl* \`implementation 'com.kaltura.playkit:vrplugin:$NEW_VERSION"\'\` >> $RELEASE_NOTES +echo "$nl* \`implementation 'com.kaltura.playkit:googlecast:$NEW_VERSION"\'\` >> $RELEASE_NOTES + +echo "$nl [Samples](https://github.com/kaltura/playkit-android-samples/tree/master)" >> $RELEASE_NOTES diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 000000000..6e23632a4 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,31 @@ +name: build CI + +on: + push: + branches: [ "dev", "master", "main" ] + pull_request: + branches: [ "dev", "master", "main" ] + workflow_call: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + gradle-build: + environment: Build + runs-on: ubuntu-latest + + steps: + - name: Checkout repo and clone to CI workspace + uses: actions/checkout@v3 + + - name: Setup JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'adopt' + cache: 'gradle' + + - name: Gradle Build... + run: ./gradlew build diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 000000000..24c1eda6f --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,68 @@ +name: publish CI + +on: + workflow_dispatch: + inputs: + release_type: + type: choice + required: true + description: 'Release Type' + options: + - Full + - Patch + new_version: + description: "New Version (Without 'v' Ex: 1.0.0)" + required: true + type: string + prev_version: + description: "Previous Version (Without 'v' Ex: 1.0.0)" + required: true + type: string + +env: + RELEASE_TYPE: ${{ inputs.release_type }} + NEW_VERSION: ${{ inputs.new_version }} + PREV_VERSION: ${{ inputs.prev_version }} + REPO_NAME: ${{ github.event.repository.name }} + NEXUS_USERNAME: ${{ secrets.OSSRH_USERNAME }} + NEXUS_PASSWORD: ${{ secrets.OSSRH_PASSWORD }} + SIGNING_KEYID: ${{ secrets.SIGNING_KEY_ID }} + SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} + MODULE_NAME: ${{ secrets.MODULE_NAME }} + +jobs: + build: + uses: ./.github/workflows/build.yml + + maven-release: + environment: Release + runs-on: ubuntu-latest + needs: build + + steps: + - name: Checkout repo and clone to CI workspace + uses: actions/checkout@v3 + with: + fetch-depth: '0' + + - name: Copy and Decode + run: | + mkdir $PWD/.kltrenv && echo "${{ secrets.SIGNING_KEY }}" > $PWD/.kltrenv/secring.gpg.b64 + base64 -d $PWD/.kltrenv/secring.gpg.b64 > $PWD/.kltrenv/secring.gpg + + - name: Setup JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'adopt' + cache: 'gradle' + + - name: Run publish Script + run: | + RELEASE_TYPE=${RELEASE_TYPE} NEW_VERSION=${NEW_VERSION} + PREV_VERSION=${PREV_VERSION} REPO_NAME=${REPO_NAME} + TOKEN=${{ secrets.GITHUB_TOKEN }} MODULE_NAME=${MODULE_NAME} TEAMS_WEBHOOK=${{ secrets.TEAMS_WEBHOOK }} bash .github/publish.sh + + - name: Delete secring file + run: | + rm -rf $PWD/.kltrenv diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 164a27581..000000000 --- a/.travis.yml +++ /dev/null @@ -1,23 +0,0 @@ -language: android -dist: trusty -sudo: required -jdk: - - oraclejdk8 -android: - components: - - tools - - build-tools-28.0.3 - - android-28 -before_script: - - curl https://kaltura.github.io/fe-tools/android/license.sh | sh -script: - - ./gradlew playkit:build -after_failure: - - cat playkit/build/reports/lint-results.xml -notifications: - email: - recipients: - - noam.tamim@kaltura.com - - gilad.nadav@kaltura.com - on_success: change - on_failure: always diff --git a/README.md b/README.md index a1f014818..2566f21a9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -[![CI Status](https://travis-ci.org/kaltura/playkit-android.svg?branch=develop)](https://travis-ci.org/kaltura/playkit-android) -[ ![Download](https://api.bintray.com/packages/kaltura/android/playkit/images/download.svg) ](https://bintray.com/kaltura/android/playkit/_latestVersion) +[![CI Status](https://github.com/kaltura/playkit-android/actions/workflows/build.yml/badge.svg)](https://github.com/kaltura/playkit-android/actions/workflows/build.yml) +[![Download](https://img.shields.io/maven-central/v/com.kaltura.playkit/playkit?label=Download)](https://search.maven.org/artifact/com.kaltura.playkit/playkit) [![License](https://img.shields.io/badge/license-AGPLv3-black.svg)](https://github.com/kaltura/playkit-android/blob/master/LICENSE) ![Android](https://img.shields.io/badge/platform-android-green.svg) diff --git a/build.gradle b/build.gradle index a385d84b1..fd2d3ef7e 100644 --- a/build.gradle +++ b/build.gradle @@ -1,22 +1,44 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. +apply plugin: 'io.github.gradle-nexus.publish-plugin' + +ext["nexusUserName"] = System.getenv('NEXUS_USERNAME') +ext["nexusPassword"] = System.getenv('NEXUS_PASSWORD') +ext["signingKeyId"] = System.getenv('SIGNING_KEYID') +ext["signingPassword"] = System.getenv('SIGNING_PASSWORD') buildscript { + ext.kotlin_version = '1.8.0' repositories { google() - jcenter() + mavenCentral() maven { url "https://maven.google.com" } + maven { url "https://jitpack.io" } } dependencies { - classpath 'com.android.tools.build:gradle:3.4.2' - classpath 'com.novoda:bintray-release:0.9.1' + classpath 'com.android.tools.build:gradle:8.3.2' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath 'io.github.gradle-nexus:publish-plugin:1.1.0' } } allprojects { repositories { google() - jcenter() + mavenCentral() maven { url "https://maven.google.com" } + maven { url "https://jitpack.io" } + } +} + +nexusPublishing { + repositories { + sonaType { + nexusUrl.set(uri("https://oss.sonatype.org/service/local/")) + snapshotRepositoryUrl.set(uri("https://oss.sonatype.org/content/repositories/snapshots/")) + packageGroup = GROUP + username = nexusUserName + password = nexusPassword + } } } diff --git a/docs/PlayerSettings.md b/docs/PlayerSettings.md index 8b3ca4268..508096103 100644 --- a/docs/PlayerSettings.md +++ b/docs/PlayerSettings.md @@ -61,7 +61,7 @@ Once you created a player instance you can set the above settings on it. Player player = PlayKitManager.loadPlayer(context, pluginConfigs); ``` -### Apply PLayer Settings if required: +### Apply Player Settings if required: ``` // SELECTING if to use TextureView instead of surface view diff --git a/gradle.properties b/gradle.properties index 5a568caea..fb82cfc3d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -18,3 +18,20 @@ # org.gradle.parallel=true android.useAndroidX=true android.enableJetifier=true + +VERSION_NAME=playkitVersion +GROUP=com.kaltura.playkit + +POM_DESCRIPTION=PlayKit: Kaltura Player SDK +POM_URL=https://github.com/kaltura/playkit-android/ +POM_SCM_URL=https://github.com/kaltura/playkit-android/ +POM_SCM_CONNECTION=scm:git@https://github.com/kaltura/playkit-android.git +POM_SCM_DEV_CONNECTION=scm:git@https://github.com/kaltura/playkit-android.git +POM_LICENCE_NAME=GNU Affero General Public License, Version 3.0 +POM_LICENCE_URL=https://www.gnu.org/licenses/agpl-3.0.html +POM_LICENCE_DIST=repo +POM_DEVELOPER_ID=playkitdev +POM_DEVELOPER_NAME=Playkit Dev + +# This file will be generated by publish.yml from workflow (.github folder) +SECRING_PATH=../.kltrenv/secring.gpg diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ae91c1231..3c2b9399f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Wed Apr 01 20:52:25 IDT 2020 +#Thu Jul 11 16:22:33 IDT 2019 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.1.1-all.zip \ No newline at end of file +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-all.zip diff --git a/jitpack.yml b/jitpack.yml index 24e44f066..6bfd6a576 100644 --- a/jitpack.yml +++ b/jitpack.yml @@ -1,4 +1,4 @@ jdk: - - oraclejdk8 + - openjdk17 before_install: - curl https://kaltura.github.io/fe-tools/android/license.sh | sh diff --git a/playkit/build.gradle b/playkit/build.gradle index c38b356fc..e2e3b30b8 100644 --- a/playkit/build.gradle +++ b/playkit/build.gradle @@ -1,17 +1,20 @@ apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' apply from: 'version.gradle' android { - compileSdkVersion 30 + namespace 'com.kaltura.playkit' + compileSdk 34 compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } defaultConfig { - minSdkVersion 16 - targetSdkVersion 30 + minSdkVersion 21 + targetSdkVersion 34 versionName playkitVersion // defined in version.gradle testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + buildConfigField("String","VERSION_NAME","\"${playkitVersion}\"") } buildTypes { release { @@ -26,6 +29,19 @@ android { lintOptions { lintConfig file("lint.xml") + disable 'UnsafeOptInUsageError' + } + buildFeatures { + buildConfig = true + } + publishing { + publishing { + singleVariant('release') { + } + } + } + kotlinOptions { + jvmTarget = '17' } } @@ -35,18 +51,40 @@ tasks.withType(Javadoc) { dependencies { - def exoPlayerVersion = '2.12.1' - api "com.kaltura.playkit:kexoplayer:$exoPlayerVersion" + def media3ExoPlayerVersion = '1.4.1' + api "com.kaltura.playkit:mediakexoplayer:$media3ExoPlayerVersion" - api 'com.google.code.gson:gson:2.8.5' - implementation 'androidx.annotation:annotation:1.1.0' + api 'com.google.code.gson:gson:2.8.6' + implementation 'androidx.annotation:annotation:1.2.0' // Ok is (optionally) used by ExoPlayer now - api 'com.squareup.okhttp3:okhttp:3.12.11' + api 'com.squareup.okhttp3:okhttp:4.9.2' + + // Kotlin Config + implementation 'androidx.core:core-ktx:1.9.0' + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + + def checkerframeworkVersion = '3.13.0' + def checkerframeworkCompatVersion = '2.5.5' + compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion + compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion + + def guavaVersion = '31.0.1-android' + + api ('com.google.guava:guava:' + guavaVersion) { + exclude group: 'org.checkerframework', module: 'checker-compat-qual' + exclude group: 'org.checkerframework', module: 'checker-qual' + } // Tests - testImplementation 'junit:junit:4.12' + testImplementation 'junit:junit:4.13.2' testImplementation 'org.hamcrest:hamcrest-library:1.3' testImplementation "org.mockito:mockito-core:2.28.2" androidTestImplementation 'androidx.test:runner:1.3.0' } + +if (!ext.playkitVersion.contains('dev')) { + apply from: './gradle-mvn-push.gradle' +} else { + apply from: './gradle-mvn-local.gradle' +} diff --git a/playkit/gradle-mvn-local.gradle b/playkit/gradle-mvn-local.gradle new file mode 100644 index 000000000..389f44fd7 --- /dev/null +++ b/playkit/gradle-mvn-local.gradle @@ -0,0 +1,19 @@ +apply plugin: 'maven-publish' + +task androidSourcesJar(type: Jar) { + archiveClassifier = 'sources' + from android.sourceSets.main.java.sourceFiles +} + +project.afterEvaluate { + publishing { + publications { + mavenJava(MavenPublication) { + from components.findByName('release') + } + } + repositories { + mavenLocal() + } + } +} diff --git a/playkit/gradle-mvn-push.gradle b/playkit/gradle-mvn-push.gradle new file mode 100644 index 000000000..99342be7f --- /dev/null +++ b/playkit/gradle-mvn-push.gradle @@ -0,0 +1,114 @@ +apply plugin: 'maven-publish' +apply plugin: 'signing' + +def isReleaseBuild() { + return VERSION_NAME.contains("dev") == false +} + +def getReleaseRepositoryUrl() { + return hasProperty('RELEASE_REPOSITORY_URL') ? RELEASE_REPOSITORY_URL + : "https://oss.sonatype.org/service/local/staging/deploy/maven2/" +} + +def getSnapshotRepositoryUrl() { + return hasProperty('SNAPSHOT_REPOSITORY_URL') ? SNAPSHOT_REPOSITORY_URL + : "https://oss.sonatype.org/content/repositories/snapshots/" +} + +def getRepositoryUsername() { + return nexusUserName +} + +def getRepositoryPassword() { + return nexusPassword +} + +afterEvaluate { project -> + publishing { + publications { + mavenJava(MavenPublication) { + from components.findByName('release') + groupId = GROUP + artifactId = POM_ARTIFACT_ID + + VERSION_NAME = playkitVersion + version = VERSION_NAME + + repositories { + maven { + def releasesRepoUrl = getReleaseRepositoryUrl() + def snapshotsRepoUrl = getSnapshotRepositoryUrl() + url = version.endsWith('SNAPSHOT') ? snapshotsRepoUrl : releasesRepoUrl + credentials { + username getRepositoryUsername() + password getRepositoryPassword() + } + } + } + + pom { + name = POM_NAME + packaging = POM_PACKAGING + description = POM_DESCRIPTION + url = POM_URL + + licenses { + license { + name = POM_LICENCE_NAME + url = POM_LICENCE_URL + distribution = POM_LICENCE_DIST + } + } + developers { + developer { + id = POM_DEVELOPER_ID + name = POM_DEVELOPER_NAME + } + } + scm { + url = POM_SCM_URL + connection = POM_SCM_CONNECTION + developerConnection = POM_SCM_DEV_CONNECTION + } + } + } + } + } + + signing { + allprojects { ext."signing.keyId" = signingKeyId } + allprojects { ext."signing.password" = signingPassword } + allprojects { ext."signing.secretKeyRingFile" = SECRING_PATH } + + required { isReleaseBuild() && gradle.taskGraph.hasTask("publish") } + sign publishing.publications + } + + task androidJavadocs(type: Javadoc) { + source = android.sourceSets.main.java.srcDirs + classpath += project.files(android.getBootClasspath().join(File.pathSeparator)) + exclude '**/*.kt' + include '**/BuildConfig.java' + } + + afterEvaluate { + androidJavadocs.classpath += files(android.libraryVariants.collect { variant -> + variant.javaCompileProvider.get().classpath.files + }) + } + + task androidJavadocsJar(type: Jar, dependsOn: androidJavadocs) { + archiveClassifier = 'javadoc' + from androidJavadocs.destinationDir + } + + task androidSourcesJar(type: Jar) { + archiveClassifier = 'sources' + from android.sourceSets.main.java.sourceFiles + } + + artifacts { + archives androidSourcesJar + archives androidJavadocsJar + } +} diff --git a/playkit/gradle.properties b/playkit/gradle.properties new file mode 100644 index 000000000..56f9eaf13 --- /dev/null +++ b/playkit/gradle.properties @@ -0,0 +1,3 @@ +POM_NAME=Playkit +POM_ARTIFACT_ID=playkit +POM_PACKAGING=aar diff --git a/playkit/src/main/AndroidManifest.xml b/playkit/src/main/AndroidManifest.xml index 72b023518..4cfb57e4f 100644 --- a/playkit/src/main/AndroidManifest.xml +++ b/playkit/src/main/AndroidManifest.xml @@ -1,5 +1,4 @@ - + diff --git a/playkit/src/main/java/com/kaltura/android/exoplayer2/upstream/KBandwidthMeter.java b/playkit/src/main/java/com/kaltura/android/exoplayer2/upstream/KBandwidthMeter.java new file mode 100644 index 000000000..717408d3a --- /dev/null +++ b/playkit/src/main/java/com/kaltura/android/exoplayer2/upstream/KBandwidthMeter.java @@ -0,0 +1,7 @@ +package com.kaltura.android.exoplayer2.upstream; + +import com.kaltura.androidx.media3.exoplayer.upstream.BandwidthMeter; + +public interface KBandwidthMeter extends BandwidthMeter { + void resetBitrateEstimate(); +} diff --git a/playkit/src/main/java/com/kaltura/android/exoplayer2/upstream/KDefaultBandwidthMeter.java b/playkit/src/main/java/com/kaltura/android/exoplayer2/upstream/KDefaultBandwidthMeter.java new file mode 100644 index 000000000..89728d191 --- /dev/null +++ b/playkit/src/main/java/com/kaltura/android/exoplayer2/upstream/KDefaultBandwidthMeter.java @@ -0,0 +1,132 @@ +package com.kaltura.android.exoplayer2.upstream; + +import android.content.Context; +import android.os.Handler; + +import androidx.annotation.Nullable; + +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import com.kaltura.androidx.media3.common.util.Clock; +import com.kaltura.androidx.media3.datasource.DataSource; +import com.kaltura.androidx.media3.datasource.DataSpec; +import com.kaltura.androidx.media3.datasource.TransferListener; +import com.kaltura.androidx.media3.exoplayer.upstream.DefaultBandwidthMeter; + + +public class KDefaultBandwidthMeter implements KBandwidthMeter, TransferListener { + + private final DefaultBandwidthMeter wrappedDefaultBandwidthMeter; + + @Nullable + private final Long initialBitrateEstimate; + + @Nullable + private Long bitrateEstimate; + + private KDefaultBandwidthMeter(DefaultBandwidthMeter defaultBandwidthMeter, @Nullable Long initialBitrateEstimate) { + this.wrappedDefaultBandwidthMeter = defaultBandwidthMeter; + this.initialBitrateEstimate = initialBitrateEstimate; + bitrateEstimate = null; + } + + public void resetBitrateEstimate() { + bitrateEstimate = initialBitrateEstimate; + } + + @Override + public long getBitrateEstimate() { + if (bitrateEstimate != null) { + return bitrateEstimate; + } + return wrappedDefaultBandwidthMeter.getBitrateEstimate(); + } + + @Nullable + @Override + public TransferListener getTransferListener() { + return this; + } + + @Override + public void addEventListener(Handler handler, EventListener eventListener) { + wrappedDefaultBandwidthMeter.addEventListener(handler, eventListener); + } + + @Override + public void removeEventListener(EventListener eventListener) { + wrappedDefaultBandwidthMeter.removeEventListener(eventListener); + } + + @Override + public void onTransferInitializing(DataSource dataSource, DataSpec dataSpec, boolean b) { + wrappedDefaultBandwidthMeter.onTransferInitializing(dataSource, dataSpec, b); + } + + @Override + public void onTransferStart(DataSource dataSource, DataSpec dataSpec, boolean b) { + wrappedDefaultBandwidthMeter.onTransferStart(dataSource, dataSpec, b); + } + + @Override + public void onBytesTransferred(DataSource dataSource, DataSpec dataSpec, boolean b, int i) { + wrappedDefaultBandwidthMeter.onBytesTransferred(dataSource, dataSpec, b, i); + } + + @Override + public void onTransferEnd(DataSource dataSource, DataSpec dataSpec, boolean b) { + wrappedDefaultBandwidthMeter.onTransferEnd(dataSource, dataSpec, b); + bitrateEstimate = null; + } + + public static final class Builder { + private final DefaultBandwidthMeter.Builder wrappedBuilder; + + @Nullable + private Long initialBitrateEstimate = null; + + public Builder(Context context) { + wrappedBuilder = new DefaultBandwidthMeter.Builder(context); + } + + @CanIgnoreReturnValue + public KDefaultBandwidthMeter.Builder setSlidingWindowMaxWeight(int slidingWindowMaxWeight) { + wrappedBuilder.setSlidingWindowMaxWeight(slidingWindowMaxWeight); + return this; + } + + @CanIgnoreReturnValue + public KDefaultBandwidthMeter.Builder setInitialBitrateEstimate(long initialBitrateEstimate) { + this.initialBitrateEstimate = initialBitrateEstimate; + wrappedBuilder.setInitialBitrateEstimate(initialBitrateEstimate); + return this; + } + + @CanIgnoreReturnValue + public KDefaultBandwidthMeter.Builder setInitialBitrateEstimate(int networkType, long initialBitrateEstimate) { + wrappedBuilder.setInitialBitrateEstimate(networkType, initialBitrateEstimate); + return this; + } + + @CanIgnoreReturnValue + public KDefaultBandwidthMeter.Builder setInitialBitrateEstimate(String countryCode) { + wrappedBuilder.setInitialBitrateEstimate(countryCode); + return this; + } + + @CanIgnoreReturnValue + public KDefaultBandwidthMeter.Builder setClock(Clock clock) { + wrappedBuilder.setClock(clock); + return this; + } + + @CanIgnoreReturnValue + public KDefaultBandwidthMeter.Builder setResetOnNetworkTypeChange(boolean resetOnNetworkTypeChange) { + wrappedBuilder.setResetOnNetworkTypeChange(resetOnNetworkTypeChange); + return this; + } + + public KDefaultBandwidthMeter build() { + return new KDefaultBandwidthMeter(wrappedBuilder.build(), initialBitrateEstimate); + } + } +} diff --git a/playkit/src/main/java/com/kaltura/android/exoplayer2/video/CustomLoadControl.java b/playkit/src/main/java/com/kaltura/android/exoplayer2/video/CustomLoadControl.java deleted file mode 100644 index cc73ef5a0..000000000 --- a/playkit/src/main/java/com/kaltura/android/exoplayer2/video/CustomLoadControl.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.kaltura.android.exoplayer2.video; - -import com.kaltura.android.exoplayer2.DefaultLoadControl; -import com.kaltura.android.exoplayer2.upstream.DefaultAllocator; - -public class CustomLoadControl extends DefaultLoadControl { - public CustomLoadControl(DefaultAllocator allocator, - int minBufferMs, - int maxBufferMs, - int bufferForPlaybackMs, - int bufferForPlaybackAfterRebufferMs, - int targetBufferBytes, - boolean prioritizeTimeOverSizeThresholds, - int backBufferDurationMs, - boolean retainBackBufferFromKeyframe) - { - super(allocator, - minBufferMs, - maxBufferMs, - bufferForPlaybackMs, - bufferForPlaybackAfterRebufferMs, - targetBufferBytes, - prioritizeTimeOverSizeThresholds, - backBufferDurationMs, - retainBackBufferFromKeyframe); - } -} diff --git a/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/audio/KMediaCodecAudioRenderer.java b/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/audio/KMediaCodecAudioRenderer.java new file mode 100644 index 000000000..174de84af --- /dev/null +++ b/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/audio/KMediaCodecAudioRenderer.java @@ -0,0 +1,175 @@ +package com.kaltura.androidx.media3.exoplayer.audio; + +import static com.kaltura.androidx.media3.exoplayer.audio.DefaultAudioSink.DEFAULT_PLAYBACK_SPEED; +import static java.lang.Math.max; +import static java.lang.Math.min; + +import android.content.Context; +import android.media.MediaCodec; +import android.os.Handler; + +import androidx.annotation.Nullable; + +import com.kaltura.androidx.media3.exoplayer.ExoPlaybackException; +import com.kaltura.androidx.media3.common.Format; +import com.kaltura.androidx.media3.common.PlaybackParameters; +import com.kaltura.androidx.media3.decoder.DecoderInputBuffer; +import com.kaltura.androidx.media3.exoplayer.mediacodec.MediaCodecAdapter; +import com.kaltura.androidx.media3.exoplayer.mediacodec.MediaCodecSelector; +import com.kaltura.playkit.PKLog; + +import java.lang.reflect.Field; +import java.nio.ByteBuffer; +import java.util.Objects; + +public class KMediaCodecAudioRenderer extends MediaCodecAudioRenderer { + + private static final String DECRYPT_ONLY_CODEC_FORMAT_FIELD_NAME = "decryptOnlyCodecFormat"; + + private static final boolean DEFAULT_USE_CONTINUOUS_SPEED_ADJUSTMENT = false; + + private static final float DEFAULT_MAX_SPEED_FACTOR = 4.0f; + + private static final float DEFAULT_SPEED_STEP = 3.0f; + + private static final long DEFAULT_MAX_AV_GAP = 600_000L; + + private final boolean useContinuousSpeedAdjustment; + + private final float maxSpeedFactor; + + private final float speedStep; + + private final long maxAVGap; + + private static final PKLog log = PKLog.get("KMediaCodecAudioRenderer"); + + private boolean speedAdjustedAfterPositionReset = false; + + public KMediaCodecAudioRenderer(Context context, + MediaCodecAdapter.Factory codecAdapterFactory, + MediaCodecSelector mediaCodecSelector, + boolean enableDecoderFallback, + @Nullable Handler eventHandler, + @Nullable AudioRendererEventListener eventListener, + AudioSink audioSink) { + this(context, + codecAdapterFactory, + mediaCodecSelector, + enableDecoderFallback, + eventHandler, + eventListener, + audioSink, + DEFAULT_MAX_SPEED_FACTOR, + DEFAULT_SPEED_STEP, + DEFAULT_MAX_AV_GAP, + DEFAULT_USE_CONTINUOUS_SPEED_ADJUSTMENT); + } + + public KMediaCodecAudioRenderer(Context context, + MediaCodecAdapter.Factory codecAdapterFactory, + MediaCodecSelector mediaCodecSelector, + boolean enableDecoderFallback, + @Nullable Handler eventHandler, + @Nullable AudioRendererEventListener eventListener, + AudioSink audioSink, + float maxSpeedFactor, + float speedStep, + long maxAVGap, + boolean useContinuousSpeedAdjustment) { + super(context, codecAdapterFactory, mediaCodecSelector, enableDecoderFallback, eventHandler, eventListener, audioSink); + this.maxSpeedFactor = maxSpeedFactor; + this.speedStep = speedStep; + this.maxAVGap = maxAVGap; + this.useContinuousSpeedAdjustment = useContinuousSpeedAdjustment; + log.d("KMediaCodecAudioRenderer", "getSpeedGap()=" + getMaxAVGap() + + ", getSpeedFactor()=" + getMaxSpeedFactor() + + ", getSpeedStep()=" + getSpeedStep() + + ", continuousSpeedAdjustment=" + getContinuousSpeedAdjustment()); + } + + protected float getMaxSpeedFactor() { + return maxSpeedFactor; + } + + protected float getSpeedStep() { + return speedStep; + } + + protected long getMaxAVGap() { + return maxAVGap; + } + + protected boolean getContinuousSpeedAdjustment() { + return useContinuousSpeedAdjustment; + } + + @Override + protected void onPositionReset(long positionUs, boolean joining) throws ExoPlaybackException { + super.onPositionReset(positionUs, joining); + speedAdjustedAfterPositionReset = false; + } + + @Override + protected boolean processOutputBuffer(long positionUs, long elapsedRealtimeUs, @Nullable MediaCodecAdapter codec, @Nullable ByteBuffer buffer, int bufferIndex, int bufferFlags, int sampleCount, long bufferPresentationTimeUs, boolean isDecodeOnlyBuffer, boolean isLastBuffer, Format format) throws ExoPlaybackException { + Format decryptOnlyCodecFormat = null; + try { + Field decryptOnlyCodeFormatField = Objects.requireNonNull( + getClass().getSuperclass()).getDeclaredField(DECRYPT_ONLY_CODEC_FORMAT_FIELD_NAME); + decryptOnlyCodeFormatField.setAccessible(true); + decryptOnlyCodecFormat = (Format)decryptOnlyCodeFormatField.get(this); + } catch (NoSuchFieldException | IllegalArgumentException | IllegalAccessException | NullPointerException e) { + log.e("KMediaCodecAudioRenderer", "Error getting decryptOnlyCodecFormat: " + e.getMessage()); + } + + if ((decryptOnlyCodecFormat != null + && (bufferFlags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) || isDecodeOnlyBuffer) { + return super.processOutputBuffer( + positionUs, + elapsedRealtimeUs, + codec, + buffer, + bufferIndex, + bufferFlags, + sampleCount, + bufferPresentationTimeUs, + isDecodeOnlyBuffer, + isLastBuffer, + format); + } + + if (!speedAdjustedAfterPositionReset || getContinuousSpeedAdjustment()) { +// log.d("KMediaCodecAudioRenderer", "currentSpeed=" + getPlaybackParameters().speed + +// ", bufferPresentationTimeUs=" + bufferPresentationTimeUs + +// ", positionUs=" + positionUs); + if (bufferPresentationTimeUs - positionUs > getMaxAVGap() + && getPlaybackParameters().speed < getMaxSpeedFactor()) { + float newSpeed = getPlaybackParameters().speed + getSpeedStep(); + newSpeed = min(newSpeed, getMaxSpeedFactor()); + log.d("KMediaCodecAudioRenderer", "Setting speed to " + newSpeed); + setPlaybackParameters(new PlaybackParameters(newSpeed)); + } else if (getPlaybackParameters().speed != DEFAULT_PLAYBACK_SPEED) { + float newSpeed = getPlaybackParameters().speed - getSpeedStep(); + newSpeed = max(newSpeed, DEFAULT_PLAYBACK_SPEED); + log.d("KMediaCodecAudioRenderer", "Setting speed to " + newSpeed); + setPlaybackParameters(new PlaybackParameters(newSpeed)); + if (newSpeed == DEFAULT_PLAYBACK_SPEED) { + speedAdjustedAfterPositionReset = true; + } + } + } + + return super.processOutputBuffer( + positionUs, + elapsedRealtimeUs, + codec, + buffer, + bufferIndex, + bufferFlags, + sampleCount, + bufferPresentationTimeUs, + isDecodeOnlyBuffer, + isLastBuffer, + format); + } +} diff --git a/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/dash/manifest/DashManifestParserForThumbnail.java b/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/dash/manifest/DashManifestParserForThumbnail.java new file mode 100644 index 000000000..7000e9065 --- /dev/null +++ b/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/dash/manifest/DashManifestParserForThumbnail.java @@ -0,0 +1,2058 @@ +package com.kaltura.androidx.media3.exoplayer.dash.manifest; + +import android.net.Uri; +import android.text.TextUtils; +import android.util.Base64; +import android.util.Pair; +import android.util.Xml; + +import androidx.annotation.Nullable; + +import com.google.common.base.Ascii; +import com.google.common.base.Charsets; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Lists; +import com.kaltura.androidx.media3.common.C; +import com.kaltura.androidx.media3.common.Format; +import com.kaltura.androidx.media3.common.ParserException; +import com.kaltura.androidx.media3.common.DrmInitData; +import com.kaltura.androidx.media3.common.DrmInitData.SchemeData; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.AdaptationSet; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.BaseUrl; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.DashManifest; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.Descriptor; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.EventStream; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.Period; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.ProgramInformation; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.RangedUri; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.Representation; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.SegmentBase; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.ServiceDescriptionElement; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.UrlTemplate; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.UtcTimingElement; +import com.kaltura.androidx.media3.extractor.mp4.PsshAtomUtil; +import com.kaltura.androidx.media3.extractor.metadata.emsg.EventMessage; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.SegmentBase.SegmentList; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.SegmentBase.SegmentTemplate; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.SegmentBase.SegmentTimelineElement; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.SegmentBase.SingleSegmentBase; +import com.kaltura.androidx.media3.exoplayer.upstream.ParsingLoadable; +import com.kaltura.androidx.media3.common.util.Assertions; +import com.kaltura.androidx.media3.common.util.Log; +import com.kaltura.androidx.media3.common.MimeTypes; +import com.kaltura.androidx.media3.common.util.UriUtil; +import com.kaltura.androidx.media3.common.util.Util; +import com.kaltura.androidx.media3.common.util.XmlPullParserUtil; + +import org.checkerframework.checker.nullness.compatqual.NullableType; +import org.xml.sax.helpers.DefaultHandler; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; +import org.xmlpull.v1.XmlPullParserFactory; +import org.xmlpull.v1.XmlSerializer; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static com.kaltura.androidx.media3.exoplayer.dash.manifest.BaseUrl.DEFAULT_DVB_PRIORITY; +import static com.kaltura.androidx.media3.exoplayer.dash.manifest.BaseUrl.DEFAULT_WEIGHT; +import static com.kaltura.androidx.media3.exoplayer.dash.manifest.BaseUrl.PRIORITY_UNSET; + +/** + * A parser of media presentation description files. + */ +public class DashManifestParserForThumbnail extends DefaultHandler + implements ParsingLoadable.Parser { + + private static final String TAG = "MpdParser"; + + private static final Pattern FRAME_RATE_PATTERN = Pattern.compile("(\\d+)(?:/(\\d+))?"); + + private static final Pattern CEA_608_ACCESSIBILITY_PATTERN = Pattern.compile("CC([1-4])=.*"); + private static final Pattern CEA_708_ACCESSIBILITY_PATTERN = + Pattern.compile("([1-9]|[1-5][0-9]|6[0-3])=.*"); + + /** + * Maps the value attribute of an AudioChannelConfiguration with schemeIdUri + * "urn:mpeg:mpegB:cicp:ChannelConfiguration", as defined by ISO 23001-8 clause 8.1, to a channel + * count. + */ + private static final int[] MPEG_CHANNEL_CONFIGURATION_MAPPING = + new int[] { + Format.NO_VALUE, 1, 2, 3, 4, 5, 6, 8, 2, 3, 4, 7, 8, 24, 8, 12, 10, 12, 14, 12, 14 + }; + + private final XmlPullParserFactory xmlParserFactory; + + public DashManifestParserForThumbnail() { + try { + xmlParserFactory = XmlPullParserFactory.newInstance(); + } catch (XmlPullParserException e) { + throw new RuntimeException("Couldn't create XmlPullParserFactory instance", e); + } + } + + // MPD parsing. + + @Override + public DashManifest parse(Uri uri, InputStream inputStream) throws IOException { + try { + XmlPullParser xpp = xmlParserFactory.newPullParser(); + xpp.setInput(inputStream, null); + int eventType = xpp.next(); + if (eventType != XmlPullParser.START_TAG || !"MPD".equals(xpp.getName())) { + throw ParserException.createForMalformedManifest( + "inputStream does not contain a valid media presentation description", + /* cause= */ null); + } + return parseMediaPresentationDescription(xpp, uri); + } catch (XmlPullParserException e) { + throw ParserException.createForMalformedManifest(/* message= */ null, /* cause= */ e); + } + } + + protected DashManifest parseMediaPresentationDescription( + XmlPullParser xpp, Uri documentBaseUri) throws XmlPullParserException, IOException { + boolean dvbProfileDeclared = + isDvbProfileDeclared(parseProfiles(xpp, "profiles", new String[0])); + long availabilityStartTime = parseDateTime(xpp, "availabilityStartTime", C.TIME_UNSET); + long durationMs = parseDuration(xpp, "mediaPresentationDuration", C.TIME_UNSET); + long minBufferTimeMs = parseDuration(xpp, "minBufferTime", C.TIME_UNSET); + String typeString = xpp.getAttributeValue(null, "type"); + boolean dynamic = "dynamic".equals(typeString); + long minUpdateTimeMs = + dynamic ? parseDuration(xpp, "minimumUpdatePeriod", C.TIME_UNSET) : C.TIME_UNSET; + long timeShiftBufferDepthMs = + dynamic ? parseDuration(xpp, "timeShiftBufferDepth", C.TIME_UNSET) : C.TIME_UNSET; + long suggestedPresentationDelayMs = + dynamic ? parseDuration(xpp, "suggestedPresentationDelay", C.TIME_UNSET) : C.TIME_UNSET; + long publishTimeMs = parseDateTime(xpp, "publishTime", C.TIME_UNSET); + ProgramInformation programInformation = null; + UtcTimingElement utcTiming = null; + Uri location = null; + ServiceDescriptionElement serviceDescription = null; + long baseUrlAvailabilityTimeOffsetUs = dynamic ? 0 : C.TIME_UNSET; + BaseUrl documentBaseUrl = + new BaseUrl( + documentBaseUri.toString(), + /* serviceLocation= */ documentBaseUri.toString(), + dvbProfileDeclared ? DEFAULT_DVB_PRIORITY : PRIORITY_UNSET, + DEFAULT_WEIGHT); + ArrayList parentBaseUrls = Lists.newArrayList(documentBaseUrl); + + List periods = new ArrayList<>(); + ArrayList baseUrls = new ArrayList<>(); + long nextPeriodStartMs = dynamic ? C.TIME_UNSET : 0; + boolean seenEarlyAccessPeriod = false; + boolean seenFirstBaseUrl = false; + do { + xpp.next(); + if (XmlPullParserUtil.isStartTag(xpp, "BaseURL")) { + if (!seenFirstBaseUrl) { + baseUrlAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, baseUrlAvailabilityTimeOffsetUs); + seenFirstBaseUrl = true; + } + baseUrls.addAll(parseBaseUrl(xpp, parentBaseUrls, dvbProfileDeclared)); + } else if (XmlPullParserUtil.isStartTag(xpp, "ProgramInformation")) { + programInformation = parseProgramInformation(xpp); + } else if (XmlPullParserUtil.isStartTag(xpp, "UTCTiming")) { + utcTiming = parseUtcTiming(xpp); + } else if (XmlPullParserUtil.isStartTag(xpp, "Location")) { + location = UriUtil.resolveToUri(documentBaseUri.toString(), xpp.nextText()); + } else if (XmlPullParserUtil.isStartTag(xpp, "ServiceDescription")) { + serviceDescription = parseServiceDescription(xpp); + } else if (XmlPullParserUtil.isStartTag(xpp, "Period") && !seenEarlyAccessPeriod) { + Pair periodWithDurationMs = + parsePeriod( + xpp, + !baseUrls.isEmpty() ? baseUrls : parentBaseUrls, + nextPeriodStartMs, + baseUrlAvailabilityTimeOffsetUs, + availabilityStartTime, + timeShiftBufferDepthMs, + dvbProfileDeclared); + Period period = periodWithDurationMs.first; + if (period.startMs == C.TIME_UNSET) { + if (dynamic) { + // This is an early access period. Ignore it. All subsequent periods must also be + // early access. + seenEarlyAccessPeriod = true; + } else { + throw ParserException.createForMalformedManifest( + "Unable to determine start of period " + periods.size(), /* cause= */ null); + } + } else { + long periodDurationMs = periodWithDurationMs.second; + nextPeriodStartMs = + periodDurationMs == C.TIME_UNSET ? C.TIME_UNSET : (period.startMs + periodDurationMs); + periods.add(period); + } + } else { + maybeSkipTag(xpp); + } + } while (!XmlPullParserUtil.isEndTag(xpp, "MPD")); + + if (durationMs == C.TIME_UNSET) { + if (nextPeriodStartMs != C.TIME_UNSET) { + // If we know the end time of the final period, we can use it as the duration. + durationMs = nextPeriodStartMs; + } else if (!dynamic) { + throw ParserException.createForMalformedManifest( + "Unable to determine duration of static manifest.", /* cause= */ null); + } + } + + if (periods.isEmpty()) { + throw ParserException.createForMalformedManifest("No periods found.", /* cause= */ null); + } + + return buildMediaPresentationDescription( + availabilityStartTime, + durationMs, + minBufferTimeMs, + dynamic, + minUpdateTimeMs, + timeShiftBufferDepthMs, + suggestedPresentationDelayMs, + publishTimeMs, + programInformation, + utcTiming, + serviceDescription, + location, + periods); + } + + protected DashManifest buildMediaPresentationDescription( + long availabilityStartTime, + long durationMs, + long minBufferTimeMs, + boolean dynamic, + long minUpdateTimeMs, + long timeShiftBufferDepthMs, + long suggestedPresentationDelayMs, + long publishTimeMs, + @Nullable ProgramInformation programInformation, + @Nullable UtcTimingElement utcTiming, + @Nullable ServiceDescriptionElement serviceDescription, + @Nullable Uri location, + List periods) { + return new DashManifest( + availabilityStartTime, + durationMs, + minBufferTimeMs, + dynamic, + minUpdateTimeMs, + timeShiftBufferDepthMs, + suggestedPresentationDelayMs, + publishTimeMs, + programInformation, + utcTiming, + serviceDescription, + location, + periods); + } + + protected UtcTimingElement parseUtcTiming(XmlPullParser xpp) { + String schemeIdUri = xpp.getAttributeValue(null, "schemeIdUri"); + String value = xpp.getAttributeValue(null, "value"); + return buildUtcTimingElement(schemeIdUri, value); + } + + protected UtcTimingElement buildUtcTimingElement(String schemeIdUri, String value) { + return new UtcTimingElement(schemeIdUri, value); + } + + protected ServiceDescriptionElement parseServiceDescription(XmlPullParser xpp) + throws XmlPullParserException, IOException { + long targetOffsetMs = C.TIME_UNSET; + long minOffsetMs = C.TIME_UNSET; + long maxOffsetMs = C.TIME_UNSET; + float minPlaybackSpeed = C.RATE_UNSET; + float maxPlaybackSpeed = C.RATE_UNSET; + do { + xpp.next(); + if (XmlPullParserUtil.isStartTag(xpp, "Latency")) { + targetOffsetMs = parseLong(xpp, "target", C.TIME_UNSET); + minOffsetMs = parseLong(xpp, "min", C.TIME_UNSET); + maxOffsetMs = parseLong(xpp, "max", C.TIME_UNSET); + } else if (XmlPullParserUtil.isStartTag(xpp, "PlaybackRate")) { + minPlaybackSpeed = parseFloat(xpp, "min", C.RATE_UNSET); + maxPlaybackSpeed = parseFloat(xpp, "max", C.RATE_UNSET); + } + } while (!XmlPullParserUtil.isEndTag(xpp, "ServiceDescription")); + return new ServiceDescriptionElement( + targetOffsetMs, minOffsetMs, maxOffsetMs, minPlaybackSpeed, maxPlaybackSpeed); + } + + protected Pair parsePeriod( + XmlPullParser xpp, + List parentBaseUrls, + long defaultStartMs, + long baseUrlAvailabilityTimeOffsetUs, + long availabilityStartTimeMs, + long timeShiftBufferDepthMs, + boolean dvbProfileDeclared) + throws XmlPullParserException, IOException { + @Nullable String id = xpp.getAttributeValue(null, "id"); + long startMs = parseDuration(xpp, "start", defaultStartMs); + long periodStartUnixTimeMs = + availabilityStartTimeMs != C.TIME_UNSET ? availabilityStartTimeMs + startMs : C.TIME_UNSET; + long durationMs = parseDuration(xpp, "duration", C.TIME_UNSET); + @Nullable SegmentBase segmentBase = null; + @Nullable Descriptor assetIdentifier = null; + List adaptationSets = new ArrayList<>(); + List eventStreams = new ArrayList<>(); + ArrayList baseUrls = new ArrayList<>(); + boolean seenFirstBaseUrl = false; + long segmentBaseAvailabilityTimeOffsetUs = C.TIME_UNSET; + do { + xpp.next(); + if (XmlPullParserUtil.isStartTag(xpp, "BaseURL")) { + if (!seenFirstBaseUrl) { + baseUrlAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, baseUrlAvailabilityTimeOffsetUs); + seenFirstBaseUrl = true; + } + baseUrls.addAll(parseBaseUrl(xpp, parentBaseUrls, dvbProfileDeclared)); + } else if (XmlPullParserUtil.isStartTag(xpp, "AdaptationSet")) { + adaptationSets.add( + parseAdaptationSet( + xpp, + !baseUrls.isEmpty() ? baseUrls : parentBaseUrls, + segmentBase, + durationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs, + periodStartUnixTimeMs, + timeShiftBufferDepthMs, + dvbProfileDeclared)); + } else if (XmlPullParserUtil.isStartTag(xpp, "EventStream")) { + eventStreams.add(parseEventStream(xpp)); + } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentBase")) { + segmentBase = parseSegmentBase(xpp, /* parent= */ null); + } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentList")) { + segmentBaseAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, /* parentAvailabilityTimeOffsetUs= */ C.TIME_UNSET); + segmentBase = + parseSegmentList( + xpp, + /* parent= */ null, + periodStartUnixTimeMs, + durationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs, + timeShiftBufferDepthMs); + } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentTemplate")) { + segmentBaseAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, /* parentAvailabilityTimeOffsetUs= */ C.TIME_UNSET); + segmentBase = + parseSegmentTemplate( + xpp, + /* parent= */ null, + ImmutableList.of(), + periodStartUnixTimeMs, + durationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs, + timeShiftBufferDepthMs); + } else if (XmlPullParserUtil.isStartTag(xpp, "AssetIdentifier")) { + assetIdentifier = parseDescriptor(xpp, "AssetIdentifier"); + } else { + maybeSkipTag(xpp); + } + } while (!XmlPullParserUtil.isEndTag(xpp, "Period")); + + return Pair.create( + buildPeriod(id, startMs, adaptationSets, eventStreams, assetIdentifier), durationMs); + } + + protected Period buildPeriod( + @Nullable String id, + long startMs, + List adaptationSets, + List eventStreams, + @Nullable Descriptor assetIdentifier) { + return new Period(id, startMs, adaptationSets, eventStreams, assetIdentifier); + } + + // AdaptationSet parsing. + + protected AdaptationSet parseAdaptationSet( + XmlPullParser xpp, + List parentBaseUrls, + @Nullable SegmentBase segmentBase, + long periodDurationMs, + long baseUrlAvailabilityTimeOffsetUs, + long segmentBaseAvailabilityTimeOffsetUs, + long periodStartUnixTimeMs, + long timeShiftBufferDepthMs, + boolean dvbProfileDeclared) + throws XmlPullParserException, IOException { + long id = parseLong(xpp, "id", AdaptationSet.ID_UNSET); + @C.TrackType int contentType = parseContentType(xpp); + + String mimeType = xpp.getAttributeValue(null, "mimeType"); + String codecs = xpp.getAttributeValue(null, "codecs"); + int width = parseInt(xpp, "width", Format.NO_VALUE); + int height = parseInt(xpp, "height", Format.NO_VALUE); + float frameRate = parseFrameRate(xpp, Format.NO_VALUE); + int audioChannels = Format.NO_VALUE; + int audioSamplingRate = parseInt(xpp, "audioSamplingRate", Format.NO_VALUE); + String language = xpp.getAttributeValue(null, "lang"); + String label = xpp.getAttributeValue(null, "label"); + String drmSchemeType = null; + ArrayList drmSchemeDatas = new ArrayList<>(); + ArrayList inbandEventStreams = new ArrayList<>(); + ArrayList accessibilityDescriptors = new ArrayList<>(); + ArrayList roleDescriptors = new ArrayList<>(); + ArrayList essentialProperties = new ArrayList<>(); + ArrayList supplementalProperties = new ArrayList<>(); + List representationInfos = new ArrayList<>(); + ArrayList baseUrls = new ArrayList<>(); + + boolean seenFirstBaseUrl = false; + do { + xpp.next(); + if (XmlPullParserUtil.isStartTag(xpp, "BaseURL")) { + if (!seenFirstBaseUrl) { + baseUrlAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, baseUrlAvailabilityTimeOffsetUs); + seenFirstBaseUrl = true; + } + baseUrls.addAll(parseBaseUrl(xpp, parentBaseUrls, dvbProfileDeclared)); + } else if (XmlPullParserUtil.isStartTag(xpp, "ContentProtection")) { + Pair contentProtection = parseContentProtection(xpp); + if (contentProtection.first != null) { + drmSchemeType = contentProtection.first; + } + if (contentProtection.second != null) { + drmSchemeDatas.add(contentProtection.second); + } + } else if (XmlPullParserUtil.isStartTag(xpp, "ContentComponent")) { + language = checkLanguageConsistency(language, xpp.getAttributeValue(null, "lang")); + contentType = checkContentTypeConsistency(contentType, parseContentType(xpp)); + } else if (XmlPullParserUtil.isStartTag(xpp, "Role")) { + roleDescriptors.add(parseDescriptor(xpp, "Role")); + } else if (XmlPullParserUtil.isStartTag(xpp, "AudioChannelConfiguration")) { + audioChannels = parseAudioChannelConfiguration(xpp); + } else if (XmlPullParserUtil.isStartTag(xpp, "Accessibility")) { + accessibilityDescriptors.add(parseDescriptor(xpp, "Accessibility")); + } else if (XmlPullParserUtil.isStartTag(xpp, "EssentialProperty")) { + essentialProperties.add(parseDescriptor(xpp, "EssentialProperty")); + } else if (XmlPullParserUtil.isStartTag(xpp, "SupplementalProperty")) { + supplementalProperties.add(parseDescriptor(xpp, "SupplementalProperty")); + } else if (XmlPullParserUtil.isStartTag(xpp, "Representation")) { + RepresentationInfo representationInfo = + parseRepresentation( + xpp, + !baseUrls.isEmpty() ? baseUrls : parentBaseUrls, + mimeType, + codecs, + width, + height, + frameRate, + audioChannels, + audioSamplingRate, + language, + roleDescriptors, + accessibilityDescriptors, + essentialProperties, + supplementalProperties, + segmentBase, + periodStartUnixTimeMs, + periodDurationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs, + timeShiftBufferDepthMs, + dvbProfileDeclared); + contentType = + checkContentTypeConsistency( + contentType, MimeTypes.getTrackType(representationInfo.format.sampleMimeType)); + representationInfos.add(representationInfo); + } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentBase")) { + segmentBase = parseSegmentBase(xpp, (SingleSegmentBase) segmentBase); + } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentList")) { + segmentBaseAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, segmentBaseAvailabilityTimeOffsetUs); + segmentBase = + parseSegmentList( + xpp, + (SegmentList) segmentBase, + periodStartUnixTimeMs, + periodDurationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs, + timeShiftBufferDepthMs); + } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentTemplate")) { + segmentBaseAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, segmentBaseAvailabilityTimeOffsetUs); + segmentBase = + parseSegmentTemplate( + xpp, + (SegmentTemplate) segmentBase, + supplementalProperties, + periodStartUnixTimeMs, + periodDurationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs, + timeShiftBufferDepthMs); + } else if (XmlPullParserUtil.isStartTag(xpp, "InbandEventStream")) { + inbandEventStreams.add(parseDescriptor(xpp, "InbandEventStream")); + } else if (XmlPullParserUtil.isStartTag(xpp, "Label")) { + label = parseLabel(xpp); + } else if (XmlPullParserUtil.isStartTag(xpp)) { + parseAdaptationSetChild(xpp); + } + } while (!XmlPullParserUtil.isEndTag(xpp, "AdaptationSet")); + + // Build the representations. + List representations = new ArrayList<>(representationInfos.size()); + for (int i = 0; i < representationInfos.size(); i++) { + representations.add( + buildRepresentation( + representationInfos.get(i), + label, + drmSchemeType, + drmSchemeDatas, + inbandEventStreams)); + } + + return buildAdaptationSet( + id, + contentType, + representations, + accessibilityDescriptors, + essentialProperties, + supplementalProperties); + } + + protected AdaptationSet buildAdaptationSet( + long id, + @C.TrackType int contentType, + List representations, + List accessibilityDescriptors, + List essentialProperties, + List supplementalProperties) { + return new AdaptationSet( + id, + contentType, + representations, + accessibilityDescriptors, + essentialProperties, + supplementalProperties); + } + + protected int parseContentType(XmlPullParser xpp) { + String contentType = xpp.getAttributeValue(null, "contentType"); + return TextUtils.isEmpty(contentType) + ? C.TRACK_TYPE_UNKNOWN + : MimeTypes.BASE_TYPE_AUDIO.equals(contentType) + ? C.TRACK_TYPE_AUDIO + : MimeTypes.BASE_TYPE_VIDEO.equals(contentType) + ? C.TRACK_TYPE_VIDEO + : MimeTypes.BASE_TYPE_TEXT.equals(contentType) + ? C.TRACK_TYPE_TEXT + : C.TRACK_TYPE_UNKNOWN; + } + + /** + * Parses a ContentProtection element. + * + * @param xpp The parser from which to read. + * @throws XmlPullParserException If an error occurs parsing the element. + * @throws IOException If an error occurs reading the element. + * @return The scheme type and/or {@link SchemeData} parsed from the ContentProtection element. + * Either or both may be null, depending on the ContentProtection element being parsed. + */ + protected Pair<@NullableType String, @NullableType SchemeData> parseContentProtection( + XmlPullParser xpp) throws XmlPullParserException, IOException { + String schemeType = null; + String licenseServerUrl = null; + byte[] data = null; + UUID uuid = null; + + String schemeIdUri = xpp.getAttributeValue(null, "schemeIdUri"); + if (schemeIdUri != null) { + switch (Ascii.toLowerCase(schemeIdUri)) { + case "urn:mpeg:dash:mp4protection:2011": + schemeType = xpp.getAttributeValue(null, "value"); + String defaultKid = XmlPullParserUtil.getAttributeValueIgnorePrefix(xpp, "default_KID"); + if (!TextUtils.isEmpty(defaultKid) + && !"00000000-0000-0000-0000-000000000000".equals(defaultKid)) { + String[] defaultKidStrings = defaultKid.split("\\s+"); + UUID[] defaultKids = new UUID[defaultKidStrings.length]; + for (int i = 0; i < defaultKidStrings.length; i++) { + defaultKids[i] = UUID.fromString(defaultKidStrings[i]); + } + data = PsshAtomUtil.buildPsshAtom(C.COMMON_PSSH_UUID, defaultKids, null); + uuid = C.COMMON_PSSH_UUID; + } + break; + case "urn:uuid:9a04f079-9840-4286-ab92-e65be0885f95": + uuid = C.PLAYREADY_UUID; + break; + case "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed": + uuid = C.WIDEVINE_UUID; + break; + case "urn:uuid:e2719d58-a985-b3c9-781a-b030af78d30e": + uuid = C.CLEARKEY_UUID; + break; + default: + break; + } + } + + do { + xpp.next(); + if (XmlPullParserUtil.isStartTag(xpp, "clearkey:Laurl") && xpp.next() == XmlPullParser.TEXT) { + licenseServerUrl = xpp.getText(); + } else if (XmlPullParserUtil.isStartTag(xpp, "ms:laurl")) { + licenseServerUrl = xpp.getAttributeValue(null, "licenseUrl"); + } else if (data == null + && XmlPullParserUtil.isStartTagIgnorePrefix(xpp, "pssh") + && xpp.next() == XmlPullParser.TEXT) { + // The cenc:pssh element is defined in 23001-7:2015. + data = Base64.decode(xpp.getText(), Base64.DEFAULT); + uuid = PsshAtomUtil.parseUuid(data); + if (uuid == null) { + Log.w(TAG, "Skipping malformed cenc:pssh data"); + data = null; + } + } else if (data == null + && C.PLAYREADY_UUID.equals(uuid) + && XmlPullParserUtil.isStartTag(xpp, "mspr:pro") + && xpp.next() == XmlPullParser.TEXT) { + // The mspr:pro element is defined in DASH Content Protection using Microsoft PlayReady. + data = + PsshAtomUtil.buildPsshAtom( + C.PLAYREADY_UUID, Base64.decode(xpp.getText(), Base64.DEFAULT)); + } else { + maybeSkipTag(xpp); + } + } while (!XmlPullParserUtil.isEndTag(xpp, "ContentProtection")); + SchemeData schemeData = + uuid != null ? new SchemeData(uuid, licenseServerUrl, MimeTypes.VIDEO_MP4, data) : null; + return Pair.create(schemeType, schemeData); + } + + /** + * Parses children of AdaptationSet elements not specifically parsed elsewhere. + * + * @param xpp The XmpPullParser from which the AdaptationSet child should be parsed. + * @throws XmlPullParserException If an error occurs parsing the element. + * @throws IOException If an error occurs reading the element. + */ + protected void parseAdaptationSetChild(XmlPullParser xpp) + throws XmlPullParserException, IOException { + maybeSkipTag(xpp); + } + + // Representation parsing. + + protected RepresentationInfo parseRepresentation( + XmlPullParser xpp, + List parentBaseUrls, + @Nullable String adaptationSetMimeType, + @Nullable String adaptationSetCodecs, + int adaptationSetWidth, + int adaptationSetHeight, + float adaptationSetFrameRate, + int adaptationSetAudioChannels, + int adaptationSetAudioSamplingRate, + @Nullable String adaptationSetLanguage, + List adaptationSetRoleDescriptors, + List adaptationSetAccessibilityDescriptors, + List adaptationSetEssentialProperties, + List adaptationSetSupplementalProperties, + @Nullable SegmentBase segmentBase, + long periodStartUnixTimeMs, + long periodDurationMs, + long baseUrlAvailabilityTimeOffsetUs, + long segmentBaseAvailabilityTimeOffsetUs, + long timeShiftBufferDepthMs, + boolean dvbProfileDeclared) + throws XmlPullParserException, IOException { + String id = xpp.getAttributeValue(null, "id"); + int bandwidth = parseInt(xpp, "bandwidth", Format.NO_VALUE); + + String mimeType = parseString(xpp, "mimeType", adaptationSetMimeType); + String codecs = parseString(xpp, "codecs", adaptationSetCodecs); + int width = parseInt(xpp, "width", adaptationSetWidth); + int height = parseInt(xpp, "height", adaptationSetHeight); + float frameRate = parseFrameRate(xpp, adaptationSetFrameRate); + int audioChannels = adaptationSetAudioChannels; + int audioSamplingRate = parseInt(xpp, "audioSamplingRate", adaptationSetAudioSamplingRate); + String drmSchemeType = null; + ArrayList drmSchemeDatas = new ArrayList<>(); + ArrayList inbandEventStreams = new ArrayList<>(); + ArrayList essentialProperties = new ArrayList<>(adaptationSetEssentialProperties); + ArrayList supplementalProperties = + new ArrayList<>(adaptationSetSupplementalProperties); + ArrayList baseUrls = new ArrayList<>(); + + boolean seenFirstBaseUrl = false; + do { + xpp.next(); + if (XmlPullParserUtil.isStartTag(xpp, "BaseURL")) { + if (!seenFirstBaseUrl) { + baseUrlAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, baseUrlAvailabilityTimeOffsetUs); + seenFirstBaseUrl = true; + } + baseUrls.addAll(parseBaseUrl(xpp, parentBaseUrls, dvbProfileDeclared)); + } else if (XmlPullParserUtil.isStartTag(xpp, "AudioChannelConfiguration")) { + audioChannels = parseAudioChannelConfiguration(xpp); + } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentBase")) { + segmentBase = parseSegmentBase(xpp, (SingleSegmentBase) segmentBase); + } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentList")) { + segmentBaseAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, segmentBaseAvailabilityTimeOffsetUs); + segmentBase = + parseSegmentList( + xpp, + (SegmentList) segmentBase, + periodStartUnixTimeMs, + periodDurationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs, + timeShiftBufferDepthMs); + } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentTemplate")) { + segmentBaseAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, segmentBaseAvailabilityTimeOffsetUs); + segmentBase = + parseSegmentTemplate( + xpp, + (SegmentTemplate) segmentBase, + adaptationSetSupplementalProperties, + periodStartUnixTimeMs, + periodDurationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs, + timeShiftBufferDepthMs); + } else if (XmlPullParserUtil.isStartTag(xpp, "ContentProtection")) { + Pair contentProtection = parseContentProtection(xpp); + if (contentProtection.first != null) { + drmSchemeType = contentProtection.first; + } + if (contentProtection.second != null) { + drmSchemeDatas.add(contentProtection.second); + } + } else if (XmlPullParserUtil.isStartTag(xpp, "InbandEventStream")) { + inbandEventStreams.add(parseDescriptor(xpp, "InbandEventStream")); + } else if (XmlPullParserUtil.isStartTag(xpp, "EssentialProperty")) { + essentialProperties.add(parseDescriptor(xpp, "EssentialProperty")); + } else if (XmlPullParserUtil.isStartTag(xpp, "SupplementalProperty")) { + supplementalProperties.add(parseDescriptor(xpp, "SupplementalProperty")); + } else { + maybeSkipTag(xpp); + } + } while (!XmlPullParserUtil.isEndTag(xpp, "Representation")); + + Format format = + buildFormat( + id, + mimeType, + width, + height, + frameRate, + audioChannels, + audioSamplingRate, + bandwidth, + adaptationSetLanguage, + adaptationSetRoleDescriptors, + adaptationSetAccessibilityDescriptors, + codecs, + essentialProperties, + supplementalProperties); + segmentBase = segmentBase != null ? segmentBase : new SingleSegmentBase(); + + return new RepresentationInfo( + format, + !baseUrls.isEmpty() ? baseUrls : parentBaseUrls, + segmentBase, + drmSchemeType, + drmSchemeDatas, + inbandEventStreams, + essentialProperties, + supplementalProperties, + Representation.REVISION_ID_DEFAULT); + } + + protected Format buildFormat( + @Nullable String id, + @Nullable String containerMimeType, + int width, + int height, + float frameRate, + int audioChannels, + int audioSamplingRate, + int bitrate, + @Nullable String language, + List roleDescriptors, + List accessibilityDescriptors, + @Nullable String codecs, + List essentialProperties, + List supplementalProperties) { + @Nullable String sampleMimeType = getSampleMimeType(containerMimeType, codecs); + if (MimeTypes.AUDIO_E_AC3.equals(sampleMimeType)) { + sampleMimeType = parseEac3SupplementalProperties(supplementalProperties); + if (MimeTypes.AUDIO_E_AC3_JOC.equals(sampleMimeType)) { + codecs = MimeTypes.CODEC_E_AC3_JOC; + } + } + @C.SelectionFlags int selectionFlags = parseSelectionFlagsFromRoleDescriptors(roleDescriptors); + @C.RoleFlags int roleFlags = parseRoleFlagsFromRoleDescriptors(roleDescriptors); + roleFlags |= parseRoleFlagsFromAccessibilityDescriptors(accessibilityDescriptors); + roleFlags |= parseRoleFlagsFromProperties(essentialProperties); + roleFlags |= parseRoleFlagsFromProperties(supplementalProperties); + + Format.Builder formatBuilder = + new Format.Builder() + .setId(id) + .setContainerMimeType(containerMimeType) + .setSampleMimeType(sampleMimeType) + .setCodecs(codecs) + .setPeakBitrate(bitrate) + .setSelectionFlags(selectionFlags) + .setRoleFlags(roleFlags) + .setLanguage(language); + + if (MimeTypes.isVideo(sampleMimeType)) { + formatBuilder.setWidth(width).setHeight(height).setFrameRate(frameRate); + } else if (MimeTypes.isAudio(sampleMimeType)) { + formatBuilder.setChannelCount(audioChannels).setSampleRate(audioSamplingRate); + } else if (MimeTypes.isText(sampleMimeType)) { + int accessibilityChannel = Format.NO_VALUE; + if (MimeTypes.APPLICATION_CEA608.equals(sampleMimeType)) { + accessibilityChannel = parseCea608AccessibilityChannel(accessibilityDescriptors); + } else if (MimeTypes.APPLICATION_CEA708.equals(sampleMimeType)) { + accessibilityChannel = parseCea708AccessibilityChannel(accessibilityDescriptors); + } + formatBuilder.setAccessibilityChannel(accessibilityChannel); + } + + return formatBuilder.build(); + } + + protected Representation buildRepresentation( + RepresentationInfo representationInfo, + @Nullable String label, + @Nullable String extraDrmSchemeType, + ArrayList extraDrmSchemeDatas, + ArrayList extraInbandEventStreams) { + Format.Builder formatBuilder = representationInfo.format.buildUpon(); + if (label != null) { + formatBuilder.setLabel(label); + } + @Nullable String drmSchemeType = representationInfo.drmSchemeType; + if (drmSchemeType == null) { + drmSchemeType = extraDrmSchemeType; + } + ArrayList drmSchemeDatas = representationInfo.drmSchemeDatas; + drmSchemeDatas.addAll(extraDrmSchemeDatas); + if (!drmSchemeDatas.isEmpty()) { + fillInClearKeyInformation(drmSchemeDatas); + filterRedundantIncompleteSchemeDatas(drmSchemeDatas); + formatBuilder.setDrmInitData(new DrmInitData(drmSchemeType, drmSchemeDatas)); + } + ArrayList inbandEventStreams = representationInfo.inbandEventStreams; + inbandEventStreams.addAll(extraInbandEventStreams); + return Representation.newInstance( + representationInfo.revisionId, + formatBuilder.build(), + representationInfo.baseUrls, + representationInfo.segmentBase, + inbandEventStreams, + representationInfo.essentialProperties, + representationInfo.supplementalProperties, + /* cacheKey= */ null); + } + + // SegmentBase, SegmentList and SegmentTemplate parsing. + + protected SingleSegmentBase parseSegmentBase( + XmlPullParser xpp, @Nullable SingleSegmentBase parent) + throws XmlPullParserException, IOException { + + long timescale = parseLong(xpp, "timescale", parent != null ? parent.timescale : 1); + long presentationTimeOffset = + parseLong( + xpp, "presentationTimeOffset", parent != null ? parent.presentationTimeOffset : 0); + + long indexStart = parent != null ? parent.indexStart : 0; + long indexLength = parent != null ? parent.indexLength : 0; + String indexRangeText = xpp.getAttributeValue(null, "indexRange"); + if (indexRangeText != null) { + String[] indexRange = indexRangeText.split("-"); + indexStart = Long.parseLong(indexRange[0]); + indexLength = Long.parseLong(indexRange[1]) - indexStart + 1; + } + + @Nullable RangedUri initialization = parent != null ? parent.initialization : null; + do { + xpp.next(); + if (XmlPullParserUtil.isStartTag(xpp, "Initialization")) { + initialization = parseInitialization(xpp); + } else { + maybeSkipTag(xpp); + } + } while (!XmlPullParserUtil.isEndTag(xpp, "SegmentBase")); + + return buildSingleSegmentBase( + initialization, timescale, presentationTimeOffset, indexStart, indexLength); + } + + protected SingleSegmentBase buildSingleSegmentBase( + RangedUri initialization, + long timescale, + long presentationTimeOffset, + long indexStart, + long indexLength) { + return new SingleSegmentBase( + initialization, timescale, presentationTimeOffset, indexStart, indexLength); + } + + protected SegmentList parseSegmentList( + XmlPullParser xpp, + @Nullable SegmentList parent, + long periodStartUnixTimeMs, + long periodDurationMs, + long baseUrlAvailabilityTimeOffsetUs, + long segmentBaseAvailabilityTimeOffsetUs, + long timeShiftBufferDepthMs) + throws XmlPullParserException, IOException { + + long timescale = parseLong(xpp, "timescale", parent != null ? parent.timescale : 1); + long presentationTimeOffset = + parseLong( + xpp, "presentationTimeOffset", parent != null ? parent.presentationTimeOffset : 0); + long duration = parseLong(xpp, "duration", parent != null ? parent.duration : C.TIME_UNSET); + long startNumber = parseLong(xpp, "startNumber", parent != null ? parent.startNumber : 1); + long availabilityTimeOffsetUs = + getFinalAvailabilityTimeOffset( + baseUrlAvailabilityTimeOffsetUs, segmentBaseAvailabilityTimeOffsetUs); + + RangedUri initialization = null; + List timeline = null; + List segments = null; + + do { + xpp.next(); + if (XmlPullParserUtil.isStartTag(xpp, "Initialization")) { + initialization = parseInitialization(xpp); + } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentTimeline")) { + timeline = parseSegmentTimeline(xpp, timescale, periodDurationMs); + } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentURL")) { + if (segments == null) { + segments = new ArrayList<>(); + } + segments.add(parseSegmentUrl(xpp)); + } else { + maybeSkipTag(xpp); + } + } while (!XmlPullParserUtil.isEndTag(xpp, "SegmentList")); + + if (parent != null) { + initialization = initialization != null ? initialization : parent.initialization; + timeline = timeline != null ? timeline : parent.segmentTimeline; + segments = segments != null ? segments : parent.mediaSegments; + } + + return buildSegmentList( + initialization, + timescale, + presentationTimeOffset, + startNumber, + duration, + timeline, + availabilityTimeOffsetUs, + segments, + timeShiftBufferDepthMs, + periodStartUnixTimeMs); + } + + protected SegmentList buildSegmentList( + RangedUri initialization, + long timescale, + long presentationTimeOffset, + long startNumber, + long duration, + @Nullable List timeline, + long availabilityTimeOffsetUs, + @Nullable List segments, + long timeShiftBufferDepthMs, + long periodStartUnixTimeMs) { + return new SegmentList( + initialization, + timescale, + presentationTimeOffset, + startNumber, + duration, + timeline, + availabilityTimeOffsetUs, + segments, + Util.msToUs(timeShiftBufferDepthMs), + Util.msToUs(periodStartUnixTimeMs)); + } + + protected SegmentTemplate parseSegmentTemplate( + XmlPullParser xpp, + @Nullable SegmentTemplate parent, + List adaptationSetSupplementalProperties, + long periodStartUnixTimeMs, + long periodDurationMs, + long baseUrlAvailabilityTimeOffsetUs, + long segmentBaseAvailabilityTimeOffsetUs, + long timeShiftBufferDepthMs) + throws XmlPullParserException, IOException { + long timescale = parseLong(xpp, "timescale", parent != null ? parent.timescale : 1); + long presentationTimeOffset = + parseLong( + xpp, "presentationTimeOffset", parent != null ? parent.presentationTimeOffset : 0); + long duration = parseLong(xpp, "duration", parent != null ? parent.duration : C.TIME_UNSET); + long startNumber = parseLong(xpp, "startNumber", parent != null ? parent.startNumber : 1); + long endNumber = + parseLastSegmentNumberSupplementalProperty(adaptationSetSupplementalProperties); + long availabilityTimeOffsetUs = + getFinalAvailabilityTimeOffset( + baseUrlAvailabilityTimeOffsetUs, segmentBaseAvailabilityTimeOffsetUs); + + UrlTemplate mediaTemplate = + parseUrlTemplate(xpp, "media", parent != null ? parent.mediaTemplate : null); + UrlTemplate initializationTemplate = + parseUrlTemplate( + xpp, "initialization", parent != null ? parent.initializationTemplate : null); + + RangedUri initialization = null; + List timeline = null; + + do { + xpp.next(); + if (XmlPullParserUtil.isStartTag(xpp, "Initialization")) { + initialization = parseInitialization(xpp); + } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentTimeline")) { + timeline = parseSegmentTimeline(xpp, timescale, periodDurationMs); + } else { + maybeSkipTag(xpp); + } + } while (!XmlPullParserUtil.isEndTag(xpp, "SegmentTemplate")); + + if (parent != null) { + initialization = initialization != null ? initialization : parent.initialization; + timeline = timeline != null ? timeline : parent.segmentTimeline; + } + + return buildSegmentTemplate( + initialization, + timescale, + presentationTimeOffset, + startNumber, + endNumber, + duration, + timeline, + availabilityTimeOffsetUs, + initializationTemplate, + mediaTemplate, + timeShiftBufferDepthMs, + periodStartUnixTimeMs); + } + + protected SegmentTemplate buildSegmentTemplate( + RangedUri initialization, + long timescale, + long presentationTimeOffset, + long startNumber, + long endNumber, + long duration, + List timeline, + long availabilityTimeOffsetUs, + @Nullable UrlTemplate initializationTemplate, + @Nullable UrlTemplate mediaTemplate, + long timeShiftBufferDepthMs, + long periodStartUnixTimeMs) { + return new SegmentTemplate( + initialization, + timescale, + presentationTimeOffset, + startNumber, + endNumber, + duration, + timeline, + availabilityTimeOffsetUs, + initializationTemplate, + mediaTemplate, + Util.msToUs(timeShiftBufferDepthMs), + Util.msToUs(periodStartUnixTimeMs)); + } + + /** + * Parses a single EventStream node in the manifest. + * + * @param xpp The current xml parser. + * @return The {@link EventStream} parsed from this EventStream node. + * @throws XmlPullParserException If there is any error parsing this node. + * @throws IOException If there is any error reading from the underlying input stream. + */ + protected EventStream parseEventStream(XmlPullParser xpp) + throws XmlPullParserException, IOException { + String schemeIdUri = parseString(xpp, "schemeIdUri", ""); + String value = parseString(xpp, "value", ""); + long timescale = parseLong(xpp, "timescale", 1); + long presentationTimeOffset = parseLong(xpp, "presentationTimeOffset", 0); + List> eventMessages = new ArrayList<>(); + ByteArrayOutputStream scratchOutputStream = new ByteArrayOutputStream(512); + do { + xpp.next(); + if (XmlPullParserUtil.isStartTag(xpp, "Event")) { + Pair event = + parseEvent(xpp, schemeIdUri, value, timescale, presentationTimeOffset, scratchOutputStream); + eventMessages.add(event); + } else { + maybeSkipTag(xpp); + } + } while (!XmlPullParserUtil.isEndTag(xpp, "EventStream")); + + long[] presentationTimesUs = new long[eventMessages.size()]; + EventMessage[] events = new EventMessage[eventMessages.size()]; + for (int i = 0; i < eventMessages.size(); i++) { + Pair event = eventMessages.get(i); + presentationTimesUs[i] = event.first; + events[i] = event.second; + } + return buildEventStream(schemeIdUri, value, timescale, presentationTimesUs, events); + } + + protected EventStream buildEventStream( + String schemeIdUri, + String value, + long timescale, + long[] presentationTimesUs, + EventMessage[] events) { + return new EventStream(schemeIdUri, value, timescale, presentationTimesUs, events); + } + + /** + * Parses a single Event node in the manifest. + * + * @param xpp The current xml parser. + * @param schemeIdUri The schemeIdUri of the parent EventStream. + * @param value The schemeIdUri of the parent EventStream. + * @param timescale The timescale of the parent EventStream. + * @param presentationTimeOffset The unscaled presentation time offset of the parent EventStream. + * @param scratchOutputStream A {@link ByteArrayOutputStream} that is used when parsing event + * objects. + * @return A pair containing the node's presentation timestamp in microseconds and the parsed + * {@link EventMessage}. + * @throws XmlPullParserException If there is any error parsing this node. + * @throws IOException If there is any error reading from the underlying input stream. + */ + protected Pair parseEvent( + XmlPullParser xpp, + String schemeIdUri, + String value, + long timescale, + long presentationTimeOffset, + ByteArrayOutputStream scratchOutputStream) + throws IOException, XmlPullParserException { + long id = parseLong(xpp, "id", 0); + long duration = parseLong(xpp, "duration", C.TIME_UNSET); + long presentationTime = parseLong(xpp, "presentationTime", 0); + long durationMs = Util.scaleLargeTimestamp(duration, C.MILLIS_PER_SECOND, timescale); + long presentationTimesUs = + Util.scaleLargeTimestamp(presentationTime - presentationTimeOffset, C.MICROS_PER_SECOND, timescale); + String messageData = parseString(xpp, "messageData", null); + byte[] eventObject = parseEventObject(xpp, scratchOutputStream); + return Pair.create( + presentationTimesUs, + buildEvent( + schemeIdUri, + value, + id, + durationMs, + messageData == null ? eventObject : Util.getUtf8Bytes(messageData))); + } + + /** + * Parses an event object. + * + * @param xpp The current xml parser. + * @param scratchOutputStream A {@link ByteArrayOutputStream} that's used when parsing the object. + * @return The serialized byte array. + * @throws XmlPullParserException If there is any error parsing this node. + * @throws IOException If there is any error reading from the underlying input stream. + */ + protected byte[] parseEventObject(XmlPullParser xpp, ByteArrayOutputStream scratchOutputStream) + throws XmlPullParserException, IOException { + scratchOutputStream.reset(); + XmlSerializer xmlSerializer = Xml.newSerializer(); + xmlSerializer.setOutput(scratchOutputStream, Charsets.UTF_8.name()); + // Start reading everything between and , and serialize them into an Xml + // byte array. + xpp.nextToken(); + while (!XmlPullParserUtil.isEndTag(xpp, "Event")) { + switch (xpp.getEventType()) { + case (XmlPullParser.START_DOCUMENT): + xmlSerializer.startDocument(null, false); + break; + case (XmlPullParser.END_DOCUMENT): + xmlSerializer.endDocument(); + break; + case (XmlPullParser.START_TAG): + xmlSerializer.startTag(xpp.getNamespace(), xpp.getName()); + for (int i = 0; i < xpp.getAttributeCount(); i++) { + xmlSerializer.attribute( + xpp.getAttributeNamespace(i), xpp.getAttributeName(i), xpp.getAttributeValue(i)); + } + break; + case (XmlPullParser.END_TAG): + xmlSerializer.endTag(xpp.getNamespace(), xpp.getName()); + break; + case (XmlPullParser.TEXT): + xmlSerializer.text(xpp.getText()); + break; + case (XmlPullParser.CDSECT): + xmlSerializer.cdsect(xpp.getText()); + break; + case (XmlPullParser.ENTITY_REF): + xmlSerializer.entityRef(xpp.getText()); + break; + case (XmlPullParser.IGNORABLE_WHITESPACE): + xmlSerializer.ignorableWhitespace(xpp.getText()); + break; + case (XmlPullParser.PROCESSING_INSTRUCTION): + xmlSerializer.processingInstruction(xpp.getText()); + break; + case (XmlPullParser.COMMENT): + xmlSerializer.comment(xpp.getText()); + break; + case (XmlPullParser.DOCDECL): + xmlSerializer.docdecl(xpp.getText()); + break; + default: // fall out + } + xpp.nextToken(); + } + xmlSerializer.flush(); + return scratchOutputStream.toByteArray(); + } + + protected EventMessage buildEvent( + String schemeIdUri, String value, long id, long durationMs, byte[] messageData) { + return new EventMessage(schemeIdUri, value, durationMs, id, messageData); + } + + protected List parseSegmentTimeline( + XmlPullParser xpp, long timescale, long periodDurationMs) + throws XmlPullParserException, IOException { + List segmentTimeline = new ArrayList<>(); + long startTime = 0; + long elementDuration = C.TIME_UNSET; + int elementRepeatCount = 0; + boolean havePreviousTimelineElement = false; + do { + xpp.next(); + if (XmlPullParserUtil.isStartTag(xpp, "S")) { + long newStartTime = parseLong(xpp, "t", C.TIME_UNSET); + if (havePreviousTimelineElement) { + startTime = + addSegmentTimelineElementsToList( + segmentTimeline, + startTime, + elementDuration, + elementRepeatCount, + /* endTime= */ newStartTime); + } + if (newStartTime != C.TIME_UNSET) { + startTime = newStartTime; + } + elementDuration = parseLong(xpp, "d", C.TIME_UNSET); + elementRepeatCount = parseInt(xpp, "r", 0); + havePreviousTimelineElement = true; + } else { + maybeSkipTag(xpp); + } + } while (!XmlPullParserUtil.isEndTag(xpp, "SegmentTimeline")); + if (havePreviousTimelineElement) { + long periodDuration = Util.scaleLargeTimestamp(periodDurationMs, timescale, 1000); + addSegmentTimelineElementsToList( + segmentTimeline, + startTime, + elementDuration, + elementRepeatCount, + /* endTime= */ periodDuration); + } + return segmentTimeline; + } + + /** + * Adds timeline elements for one S tag to the segment timeline. + * + * @param startTime Start time of the first timeline element. + * @param elementDuration Duration of one timeline element. + * @param elementRepeatCount Number of timeline elements minus one. May be negative to indicate + * that the count is determined by the total duration and the element duration. + * @param endTime End time of the last timeline element for this S tag, or {@link C#TIME_UNSET} if + * unknown. Only needed if {@code repeatCount} is negative. + * @return Calculated next start time. + */ + private long addSegmentTimelineElementsToList( + List segmentTimeline, + long startTime, + long elementDuration, + int elementRepeatCount, + long endTime) { + int count = + elementRepeatCount >= 0 + ? 1 + elementRepeatCount + : (int) Util.ceilDivide(endTime - startTime, elementDuration); + for (int i = 0; i < count; i++) { + segmentTimeline.add(buildSegmentTimelineElement(startTime, elementDuration)); + startTime += elementDuration; + } + return startTime; + } + + protected SegmentTimelineElement buildSegmentTimelineElement(long startTime, long duration) { + return new SegmentTimelineElement(startTime, duration); + } + + @Nullable + protected UrlTemplate parseUrlTemplate( + XmlPullParser xpp, String name, @Nullable UrlTemplate defaultValue) { + String valueString = xpp.getAttributeValue(null, name); + if (valueString != null) { + return UrlTemplate.compile(valueString); + } + return defaultValue; + } + + protected RangedUri parseInitialization(XmlPullParser xpp) { + return parseRangedUrl(xpp, "sourceURL", "range"); + } + + protected RangedUri parseSegmentUrl(XmlPullParser xpp) { + return parseRangedUrl(xpp, "media", "mediaRange"); + } + + protected RangedUri parseRangedUrl( + XmlPullParser xpp, String urlAttribute, String rangeAttribute) { + String urlText = xpp.getAttributeValue(null, urlAttribute); + long rangeStart = 0; + long rangeLength = C.LENGTH_UNSET; + String rangeText = xpp.getAttributeValue(null, rangeAttribute); + if (rangeText != null) { + String[] rangeTextArray = rangeText.split("-"); + rangeStart = Long.parseLong(rangeTextArray[0]); + if (rangeTextArray.length == 2) { + rangeLength = Long.parseLong(rangeTextArray[1]) - rangeStart + 1; + } + } + return buildRangedUri(urlText, rangeStart, rangeLength); + } + + protected RangedUri buildRangedUri(String urlText, long rangeStart, long rangeLength) { + return new RangedUri(urlText, rangeStart, rangeLength); + } + + protected ProgramInformation parseProgramInformation(XmlPullParser xpp) + throws IOException, XmlPullParserException { + String title = null; + String source = null; + String copyright = null; + String moreInformationURL = parseString(xpp, "moreInformationURL", null); + String lang = parseString(xpp, "lang", null); + do { + xpp.next(); + if (XmlPullParserUtil.isStartTag(xpp, "Title")) { + title = xpp.nextText(); + } else if (XmlPullParserUtil.isStartTag(xpp, "Source")) { + source = xpp.nextText(); + } else if (XmlPullParserUtil.isStartTag(xpp, "Copyright")) { + copyright = xpp.nextText(); + } else { + maybeSkipTag(xpp); + } + } while (!XmlPullParserUtil.isEndTag(xpp, "ProgramInformation")); + return new ProgramInformation(title, source, copyright, moreInformationURL, lang); + } + + /** + * Parses a Label element. + * + * @param xpp The parser from which to read. + * @throws XmlPullParserException If an error occurs parsing the element. + * @throws IOException If an error occurs reading the element. + * @return The parsed label. + */ + protected String parseLabel(XmlPullParser xpp) throws XmlPullParserException, IOException { + return parseText(xpp, "Label"); + } + + /** + * Parses a BaseURL element. + * + * @param xpp The parser from which to read. + * @param parentBaseUrls The parent base URLs for resolving the parsed URLs. + * @param dvbProfileDeclared Whether the dvb profile is declared. + * @throws XmlPullParserException If an error occurs parsing the element. + * @throws IOException If an error occurs reading the element. + * @return The list of parsed and resolved URLs. + */ + protected List parseBaseUrl(XmlPullParser xpp, List parentBaseUrls, boolean dvbProfileDeclared) + throws XmlPullParserException, IOException { + @Nullable String priorityValue = xpp.getAttributeValue(null, "dvb:priority"); + int priority = + priorityValue != null + ? Integer.parseInt(priorityValue) + : (dvbProfileDeclared ? DEFAULT_DVB_PRIORITY : PRIORITY_UNSET); + @Nullable String weightValue = xpp.getAttributeValue(null, "dvb:weight"); + int weight = weightValue != null ? Integer.parseInt(weightValue) : DEFAULT_WEIGHT; + @Nullable String serviceLocation = xpp.getAttributeValue(null, "serviceLocation"); + String baseUrl = parseText(xpp, "BaseURL"); + if (UriUtil.isAbsolute(baseUrl)) { + if (serviceLocation == null) { + serviceLocation = baseUrl; + } + return Lists.newArrayList(new BaseUrl(baseUrl, serviceLocation, priority, weight)); + } + + List baseUrls = new ArrayList<>(); + for (int i = 0; i < parentBaseUrls.size(); i++) { + BaseUrl parentBaseUrl = parentBaseUrls.get(i); + String resolvedBaseUri = UriUtil.resolve(parentBaseUrl.url, baseUrl); + String resolvedServiceLocation = serviceLocation == null ? resolvedBaseUri : serviceLocation; + if (dvbProfileDeclared) { + // Inherit parent properties only if dvb profile is declared. + priority = parentBaseUrl.priority; + weight = parentBaseUrl.weight; + resolvedServiceLocation = parentBaseUrl.serviceLocation; + } + baseUrls.add(new BaseUrl(resolvedBaseUri, resolvedServiceLocation, priority, weight)); + } + return baseUrls; + } + + /** + * Parses the availabilityTimeOffset value and returns the parsed value or the parent value if it + * doesn't exist. + * + * @param xpp The parser from which to read. + * @param parentAvailabilityTimeOffsetUs The availability time offset of a parent element in + * microseconds. + * @return The parsed availabilityTimeOffset in microseconds. + */ + protected long parseAvailabilityTimeOffsetUs( + XmlPullParser xpp, long parentAvailabilityTimeOffsetUs) { + String value = xpp.getAttributeValue(/* namespace= */ null, "availabilityTimeOffset"); + if (value == null) { + return parentAvailabilityTimeOffsetUs; + } + if ("INF".equals(value)) { + return Long.MAX_VALUE; + } + return (long) (Float.parseFloat(value) * C.MICROS_PER_SECOND); + } + + // AudioChannelConfiguration parsing. + + protected int parseAudioChannelConfiguration(XmlPullParser xpp) + throws XmlPullParserException, IOException { + String schemeIdUri = parseString(xpp, "schemeIdUri", null); + int audioChannels; + switch (schemeIdUri) { + case "urn:mpeg:dash:23003:3:audio_channel_configuration:2011": + audioChannels = parseInt(xpp, "value", Format.NO_VALUE); + break; + case "urn:mpeg:mpegB:cicp:ChannelConfiguration": + audioChannels = parseMpegChannelConfiguration(xpp); + break; + case "tag:dts.com,2014:dash:audio_channel_configuration:2012": + case "urn:dts:dash:audio_channel_configuration:2012": + audioChannels = parseDtsChannelConfiguration(xpp); + break; + case "tag:dts.com,2018:uhd:audio_channel_configuration": + audioChannels = parseDtsxChannelConfiguration(xpp); + break; + case "tag:dolby.com,2014:dash:audio_channel_configuration:2011": + case "urn:dolby:dash:audio_channel_configuration:2011": + audioChannels = parseDolbyChannelConfiguration(xpp); + break; + default: + audioChannels = Format.NO_VALUE; + break; + } + do { + xpp.next(); + } while (!XmlPullParserUtil.isEndTag(xpp, "AudioChannelConfiguration")); + return audioChannels; + } + + // Selection flag parsing. + protected @C.SelectionFlags int parseSelectionFlagsFromRoleDescriptors(List roleDescriptors) { + @C.SelectionFlags int result = 0; + for (int i = 0; i < roleDescriptors.size(); i++) { + Descriptor descriptor = roleDescriptors.get(i); + if (Ascii.equalsIgnoreCase("urn:mpeg:dash:role:2011", descriptor.schemeIdUri)) { + result |= parseSelectionFlagsFromDashRoleScheme(descriptor.value); + } + } + return result; + } + + protected @C.SelectionFlags int parseSelectionFlagsFromDashRoleScheme(@Nullable String value) { + if (value == null) { + return 0; + } + switch (value) { + case "forced_subtitle": + // Support both hyphen and underscore (https://github.com/google/ExoPlayer/issues/9727). + case "forced-subtitle": + return C.SELECTION_FLAG_FORCED; + default: + return 0; + } + } + + // Role and Accessibility parsing. + protected @C.RoleFlags int parseRoleFlagsFromRoleDescriptors(List roleDescriptors) { + @C.RoleFlags int result = 0; + for (int i = 0; i < roleDescriptors.size(); i++) { + Descriptor descriptor = roleDescriptors.get(i); + if (Ascii.equalsIgnoreCase("urn:mpeg:dash:role:2011", descriptor.schemeIdUri)) { + result |= parseRoleFlagsFromDashRoleScheme(descriptor.value); + } + } + return result; + } + + protected @C.RoleFlags int parseRoleFlagsFromAccessibilityDescriptors( + List accessibilityDescriptors) { + @C.RoleFlags int result = 0; + for (int i = 0; i < accessibilityDescriptors.size(); i++) { + Descriptor descriptor = accessibilityDescriptors.get(i); + if (Ascii.equalsIgnoreCase("urn:mpeg:dash:role:2011", descriptor.schemeIdUri)) { + result |= parseRoleFlagsFromDashRoleScheme(descriptor.value); + } else if (Ascii.equalsIgnoreCase( + "urn:tva:metadata:cs:AudioPurposeCS:2007", descriptor.schemeIdUri)) { + result |= parseTvaAudioPurposeCsValue(descriptor.value); + } + } + return result; + } + + protected @C.RoleFlags int parseRoleFlagsFromProperties(List accessibilityDescriptors) { + @C.RoleFlags int result = 0; + for (int i = 0; i < accessibilityDescriptors.size(); i++) { + Descriptor descriptor = accessibilityDescriptors.get(i); + if (Ascii.equalsIgnoreCase( + "http://dashif.org/guidelines/trickmode", descriptor.schemeIdUri)) { + result |= C.ROLE_FLAG_TRICK_PLAY; + } + } + return result; + } + + protected @C.RoleFlags int parseRoleFlagsFromDashRoleScheme(@Nullable String value) { + if (value == null) { + return 0; + } + switch (value) { + case "main": + return C.ROLE_FLAG_MAIN; + case "alternate": + return C.ROLE_FLAG_ALTERNATE; + case "supplementary": + return C.ROLE_FLAG_SUPPLEMENTARY; + case "commentary": + return C.ROLE_FLAG_COMMENTARY; + case "dub": + return C.ROLE_FLAG_DUB; + case "emergency": + return C.ROLE_FLAG_EMERGENCY; + case "caption": + return C.ROLE_FLAG_CAPTION; + case "forced_subtitle": + // Support both hyphen and underscore (https://github.com/google/ExoPlayer/issues/9727). + case "forced-subtitle": + case "subtitle": + return C.ROLE_FLAG_SUBTITLE; + case "sign": + return C.ROLE_FLAG_SIGN; + case "description": + return C.ROLE_FLAG_DESCRIBES_VIDEO; + case "enhanced-audio-intelligibility": + return C.ROLE_FLAG_ENHANCED_DIALOG_INTELLIGIBILITY; + default: + return 0; + } + } + + protected @C.RoleFlags int parseTvaAudioPurposeCsValue(@Nullable String value) { + if (value == null) { + return 0; + } + switch (value) { + case "1": // Audio description for the visually impaired. + return C.ROLE_FLAG_DESCRIBES_VIDEO; + case "2": // Audio description for the hard of hearing. + return C.ROLE_FLAG_ENHANCED_DIALOG_INTELLIGIBILITY; + case "3": // Supplemental commentary. + return C.ROLE_FLAG_SUPPLEMENTARY; + case "4": // Director's commentary. + return C.ROLE_FLAG_COMMENTARY; + case "6": // Main programme audio. + return C.ROLE_FLAG_MAIN; + default: + return 0; + } + } + + // Utility methods. + + /** + * If the provided {@link XmlPullParser} is currently positioned at the start of a tag, skips + * forward to the end of that tag. + * + * @param xpp The {@link XmlPullParser}. + * @throws XmlPullParserException If an error occurs parsing the stream. + * @throws IOException If an error occurs reading the stream. + */ + public static void maybeSkipTag(XmlPullParser xpp) throws IOException, XmlPullParserException { + if (!XmlPullParserUtil.isStartTag(xpp)) { + return; + } + int depth = 1; + while (depth != 0) { + xpp.next(); + if (XmlPullParserUtil.isStartTag(xpp)) { + depth++; + } else if (XmlPullParserUtil.isEndTag(xpp)) { + depth--; + } + } + } + + /** Removes unnecessary {@link SchemeData}s with null {@link SchemeData#data}. */ + private static void filterRedundantIncompleteSchemeDatas(ArrayList schemeDatas) { + for (int i = schemeDatas.size() - 1; i >= 0; i--) { + SchemeData schemeData = schemeDatas.get(i); + if (!schemeData.hasData()) { + for (int j = 0; j < schemeDatas.size(); j++) { + if (schemeDatas.get(j).canReplace(schemeData)) { + // schemeData is incomplete, but there is another matching SchemeData which does contain + // data, so we remove the incomplete one. + schemeDatas.remove(i); + break; + } + } + } + } + } + + private static void fillInClearKeyInformation(ArrayList schemeDatas) { + // Find and remove ClearKey information. + @Nullable String clearKeyLicenseServerUrl = null; + for (int i = 0; i < schemeDatas.size(); i++) { + SchemeData schemeData = schemeDatas.get(i); + if (C.CLEARKEY_UUID.equals(schemeData.uuid) && schemeData.licenseServerUrl != null) { + clearKeyLicenseServerUrl = schemeData.licenseServerUrl; + schemeDatas.remove(i); + break; + } + } + if (clearKeyLicenseServerUrl == null) { + return; + } + // Fill in the ClearKey information into the existing PSSH schema data if applicable. + for (int i = 0; i < schemeDatas.size(); i++) { + SchemeData schemeData = schemeDatas.get(i); + if (C.COMMON_PSSH_UUID.equals(schemeData.uuid) && schemeData.licenseServerUrl == null) { + schemeDatas.set( + i, + new SchemeData( + C.CLEARKEY_UUID, clearKeyLicenseServerUrl, schemeData.mimeType, schemeData.data)); + } + } + } + + /** + * Derives a sample mimeType from a container mimeType and codecs attribute. + * + * @param containerMimeType The mimeType of the container. + * @param codecs The codecs attribute. + * @return The derived sample mimeType, or null if it could not be derived. + */ + @Nullable + private static String getSampleMimeType( + @Nullable String containerMimeType, @Nullable String codecs) { + if (MimeTypes.isAudio(containerMimeType)) { + return MimeTypes.getAudioMediaMimeType(codecs); + } else if (MimeTypes.isVideo(containerMimeType)) { + return MimeTypes.getVideoMediaMimeType(codecs); + } else if (MimeTypes.isText(containerMimeType)) { + // Text types are raw formats. + return containerMimeType; + } else if (MimeTypes.isImage(containerMimeType)) { + // Image types are raw formats. + return containerMimeType; + } else if (MimeTypes.APPLICATION_MP4.equals(containerMimeType)) { + @Nullable String mimeType = MimeTypes.getMediaMimeType(codecs); + return MimeTypes.TEXT_VTT.equals(mimeType) ? MimeTypes.APPLICATION_MP4VTT : mimeType; + } + return null; + } + + /** + * Checks two languages for consistency, returning the consistent language, or throwing an {@link + * IllegalStateException} if the languages are inconsistent. + * + *

Two languages are consistent if they are equal, or if one is null. + * + * @param firstLanguage The first language. + * @param secondLanguage The second language. + * @return The consistent language. + */ + @Nullable + private static String checkLanguageConsistency( + @Nullable String firstLanguage, @Nullable String secondLanguage) { + if (firstLanguage == null) { + return secondLanguage; + } else if (secondLanguage == null) { + return firstLanguage; + } else { + Assertions.checkState(firstLanguage.equals(secondLanguage)); + return firstLanguage; + } + } + + /** + * Checks two adaptation set content types for consistency, returning the consistent type, or + * throwing an {@link IllegalStateException} if the types are inconsistent. + * + *

Two types are consistent if they are equal, or if one is {@link C#TRACK_TYPE_UNKNOWN}. Where + * one of the types is {@link C#TRACK_TYPE_UNKNOWN}, the other is returned. + * + * @param firstType The first type. + * @param secondType The second type. + * @return The consistent type. + */ + private static int checkContentTypeConsistency( + @C.TrackType int firstType, @C.TrackType int secondType) { + if (firstType == C.TRACK_TYPE_UNKNOWN) { + return secondType; + } else if (secondType == C.TRACK_TYPE_UNKNOWN) { + return firstType; + } else { + Assertions.checkState(firstType == secondType); + return firstType; + } + } + + /** + * Parses a {@link Descriptor} from an element. + * + * @param xpp The parser from which to read. + * @param tag The tag of the element being parsed. + * @throws XmlPullParserException If an error occurs parsing the element. + * @throws IOException If an error occurs reading the element. + * @return The parsed {@link Descriptor}. + */ + protected static Descriptor parseDescriptor(XmlPullParser xpp, String tag) + throws XmlPullParserException, IOException { + String schemeIdUri = parseString(xpp, "schemeIdUri", ""); + String value = parseString(xpp, "value", null); + String id = parseString(xpp, "id", null); + do { + xpp.next(); + } while (!XmlPullParserUtil.isEndTag(xpp, tag)); + return new Descriptor(schemeIdUri, value, id); + } + + protected static int parseCea608AccessibilityChannel(List accessibilityDescriptors) { + for (int i = 0; i < accessibilityDescriptors.size(); i++) { + Descriptor descriptor = accessibilityDescriptors.get(i); + if ("urn:scte:dash:cc:cea-608:2015".equals(descriptor.schemeIdUri) + && descriptor.value != null) { + Matcher accessibilityValueMatcher = CEA_608_ACCESSIBILITY_PATTERN.matcher(descriptor.value); + if (accessibilityValueMatcher.matches()) { + return Integer.parseInt(accessibilityValueMatcher.group(1)); + } else { + Log.w(TAG, "Unable to parse CEA-608 channel number from: " + descriptor.value); + } + } + } + return Format.NO_VALUE; + } + + protected static int parseCea708AccessibilityChannel(List accessibilityDescriptors) { + for (int i = 0; i < accessibilityDescriptors.size(); i++) { + Descriptor descriptor = accessibilityDescriptors.get(i); + if ("urn:scte:dash:cc:cea-708:2015".equals(descriptor.schemeIdUri) + && descriptor.value != null) { + Matcher accessibilityValueMatcher = CEA_708_ACCESSIBILITY_PATTERN.matcher(descriptor.value); + if (accessibilityValueMatcher.matches()) { + return Integer.parseInt(accessibilityValueMatcher.group(1)); + } else { + Log.w(TAG, "Unable to parse CEA-708 service block number from: " + descriptor.value); + } + } + } + return Format.NO_VALUE; + } + + protected static String parseEac3SupplementalProperties(List supplementalProperties) { + for (int i = 0; i < supplementalProperties.size(); i++) { + Descriptor descriptor = supplementalProperties.get(i); + String schemeIdUri = descriptor.schemeIdUri; + if (("tag:dolby.com,2018:dash:EC3_ExtensionType:2018".equals(schemeIdUri) + && "JOC".equals(descriptor.value)) + || ("tag:dolby.com,2014:dash:DolbyDigitalPlusExtensionType:2014".equals(schemeIdUri) + && "ec+3".equals(descriptor.value))) { + return MimeTypes.AUDIO_E_AC3_JOC; + } + } + return MimeTypes.AUDIO_E_AC3; + } + + protected static float parseFrameRate(XmlPullParser xpp, float defaultValue) { + float frameRate = defaultValue; + String frameRateAttribute = xpp.getAttributeValue(null, "frameRate"); + if (frameRateAttribute != null) { + Matcher frameRateMatcher = FRAME_RATE_PATTERN.matcher(frameRateAttribute); + if (frameRateMatcher.matches()) { + int numerator = Integer.parseInt(frameRateMatcher.group(1)); + String denominatorString = frameRateMatcher.group(2); + if (!TextUtils.isEmpty(denominatorString)) { + frameRate = (float) numerator / Integer.parseInt(denominatorString); + } else { + frameRate = numerator; + } + } + } + return frameRate; + } + + protected static long parseDuration(XmlPullParser xpp, String name, long defaultValue) { + String value = xpp.getAttributeValue(null, name); + if (value == null) { + return defaultValue; + } else { + return Util.parseXsDuration(value); + } + } + + protected static long parseDateTime(XmlPullParser xpp, String name, long defaultValue) + throws ParserException { + String value = xpp.getAttributeValue(null, name); + if (value == null) { + return defaultValue; + } else { + return Util.parseXsDateTime(value); + } + } + + protected static String parseText(XmlPullParser xpp, String label) + throws XmlPullParserException, IOException { + String text = ""; + do { + xpp.next(); + if (xpp.getEventType() == XmlPullParser.TEXT) { + text = xpp.getText(); + } else { + maybeSkipTag(xpp); + } + } while (!XmlPullParserUtil.isEndTag(xpp, label)); + return text; + } + + protected static int parseInt(XmlPullParser xpp, String name, int defaultValue) { + String value = xpp.getAttributeValue(null, name); + return value == null ? defaultValue : Integer.parseInt(value); + } + + protected static long parseLong(XmlPullParser xpp, String name, long defaultValue) { + String value = xpp.getAttributeValue(null, name); + boolean isFloat = false; + if (value !=null && value.contains(".")) { + value = value.split("\\.")[0]; + isFloat = true; + } + long longValue = (value == null) ? defaultValue : Long.parseLong(value); + if (isFloat) { + return ++longValue; + } + return longValue; + } + + protected static float parseFloat(XmlPullParser xpp, String name, float defaultValue) { + String value = xpp.getAttributeValue(null, name); + return value == null ? defaultValue : Float.parseFloat(value); + } + + protected static String parseString(XmlPullParser xpp, String name, String defaultValue) { + String value = xpp.getAttributeValue(null, name); + return value == null ? defaultValue : value; + } + + /** + * Parses the number of channels from the value attribute of an AudioChannelConfiguration with + * schemeIdUri "urn:mpeg:mpegB:cicp:ChannelConfiguration", as defined by ISO 23001-8 clause 8.1. + * + * @param xpp The parser from which to read. + * @return The parsed number of channels, or {@link Format#NO_VALUE} if the channel count could + * not be parsed. + */ + protected static int parseMpegChannelConfiguration(XmlPullParser xpp) { + int index = parseInt(xpp, "value", C.INDEX_UNSET); + return 0 <= index && index < MPEG_CHANNEL_CONFIGURATION_MAPPING.length + ? MPEG_CHANNEL_CONFIGURATION_MAPPING[index] + : Format.NO_VALUE; + } + + /** + * Parses the number of channels from the value attribute of an AudioChannelConfiguration with + * schemeIdUri "tag:dts.com,2014:dash:audio_channel_configuration:2012" as defined by Annex G + * (3.2) in ETSI TS 102 114 V1.6.1, or by the legacy schemeIdUri + * "urn:dts:dash:audio_channel_configuration:2012". + * + * @param xpp The parser from which to read. + * @return The parsed number of channels, or {@link Format#NO_VALUE} if the channel count could + * not be parsed. + */ + protected static int parseDtsChannelConfiguration(XmlPullParser xpp) { + int channelCount = parseInt(xpp, "value", Format.NO_VALUE); + return 0 < channelCount && channelCount < 33 ? channelCount : Format.NO_VALUE; + } + + /** + * Parses the number of channels from the value attribute of an AudioChannelConfiguration with + * schemeIdUri "tag:dts.com,2018:uhd:audio_channel_configuration" as defined by table B-5 in ETSI + * TS 103 491 v1.2.1. + * + * @param xpp The parser from which to read. + * @return The parsed number of channels, or {@link Format#NO_VALUE} if the channel count could + * not be parsed. + */ + protected static int parseDtsxChannelConfiguration(XmlPullParser xpp) { + @Nullable String value = xpp.getAttributeValue(null, "value"); + if (value == null) { + return Format.NO_VALUE; + } + int channelCount = Integer.bitCount(Integer.parseInt(value, /* radix= */ 16)); + return channelCount == 0 ? Format.NO_VALUE : channelCount; + } + + /** + * Parses the number of channels from the value attribute of an AudioChannelConfiguration with + * schemeIdUri "tag:dolby.com,2014:dash:audio_channel_configuration:2011", as defined by table E.5 + * in ETSI TS 102 366, or the legacy schemeIdUri + * "urn:dolby:dash:audio_channel_configuration:2011". + * + * @param xpp The parser from which to read. + * @return The parsed number of channels, or {@link Format#NO_VALUE} if the channel count could + * not be parsed. + */ + protected static int parseDolbyChannelConfiguration(XmlPullParser xpp) { + @Nullable String value = xpp.getAttributeValue(null, "value"); + if (value == null) { + return Format.NO_VALUE; + } + switch (Ascii.toLowerCase(value)) { + case "4000": + return 1; + case "a000": + return 2; + case "f801": + return 6; + case "fa01": + return 8; + default: + return Format.NO_VALUE; + } + } + + protected static long parseLastSegmentNumberSupplementalProperty( + List supplementalProperties) { + for (int i = 0; i < supplementalProperties.size(); i++) { + Descriptor descriptor = supplementalProperties.get(i); + if (Ascii.equalsIgnoreCase( + "http://dashif.org/guidelines/last-segment-number", descriptor.schemeIdUri)) { + return Long.parseLong(descriptor.value); + } + } + return C.INDEX_UNSET; + } + + private boolean isDvbProfileDeclared(String[] profiles) { + for (String profile : profiles) { + if (profile.startsWith("urn:dvb:dash:profile:dvb-dash:")) { + return true; + } + } + return false; + } + + protected String[] parseProfiles(XmlPullParser xpp, String attributeName, String[] defaultValue) { + @Nullable String attributeValue = xpp.getAttributeValue(/* namespace= */ null, attributeName); + if (attributeValue == null) { + return defaultValue; + } + return attributeValue.split(","); + } + + private static long getFinalAvailabilityTimeOffset( + long baseUrlAvailabilityTimeOffsetUs, long segmentBaseAvailabilityTimeOffsetUs) { + long availabilityTimeOffsetUs = segmentBaseAvailabilityTimeOffsetUs; + if (availabilityTimeOffsetUs == C.TIME_UNSET) { + // Fall back to BaseURL values if no SegmentBase specifies an offset. + availabilityTimeOffsetUs = baseUrlAvailabilityTimeOffsetUs; + } + if (availabilityTimeOffsetUs == Long.MAX_VALUE) { + // Replace INF value with TIME_UNSET to specify that all segments are available immediately. + availabilityTimeOffsetUs = C.TIME_UNSET; + } + return availabilityTimeOffsetUs; + } + + /** A parsed Representation element. */ + protected static final class RepresentationInfo { + + public final Format format; + public final ImmutableList baseUrls; + public final SegmentBase segmentBase; + @Nullable public final String drmSchemeType; + public final ArrayList drmSchemeDatas; + public final ArrayList inbandEventStreams; + public final long revisionId; + public final List essentialProperties; + public final List supplementalProperties; + + public RepresentationInfo( + Format format, + List baseUrls, + SegmentBase segmentBase, + @Nullable String drmSchemeType, + ArrayList drmSchemeDatas, + ArrayList inbandEventStreams, + List essentialProperties, + List supplementalProperties, + long revisionId) { + this.format = format; + this.baseUrls = ImmutableList.copyOf(baseUrls); + this.segmentBase = segmentBase; + this.drmSchemeType = drmSchemeType; + this.drmSchemeDatas = drmSchemeDatas; + this.inbandEventStreams = inbandEventStreams; + this.essentialProperties = essentialProperties; + this.supplementalProperties = supplementalProperties; + this.revisionId = revisionId; + } + } +} diff --git a/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/dashmanifestparser/CustomAdaptationSet.java b/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/dashmanifestparser/CustomAdaptationSet.java new file mode 100644 index 000000000..740223de5 --- /dev/null +++ b/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/dashmanifestparser/CustomAdaptationSet.java @@ -0,0 +1,68 @@ +package com.kaltura.androidx.media3.exoplayer.dashmanifestparser; + +import com.kaltura.androidx.media3.common.C; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.Descriptor; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.Representation; + +import java.util.Collections; +import java.util.List; + +/** + * Represents a set of interchangeable encoded versions of a media content component. + */ +public class CustomAdaptationSet { + + /** + * Value of {@link #id} indicating no value is set.= + */ + public static final int ID_UNSET = -1; + + /** + * A non-negative identifier for the adaptation set that's unique in the scope of its containing + * period, or {@link #ID_UNSET} if not specified. + */ + public final int id; + + /** The {@link C.TrackType track type} of the adaptation set. */ + public final @C.TrackType int type; + + /** + * {@link Representation}s in the adaptation set. + */ + public final List representations; + + /** + * Accessibility descriptors in the adaptation set. + */ + public final List accessibilityDescriptors; + + /** Essential properties in the adaptation set. */ + public final List essentialProperties; + + /** Supplemental properties in the adaptation set. */ + public final List supplementalProperties; + + /** + * @param id A non-negative identifier for the adaptation set that's unique in the scope of its + * containing period, or {@link #ID_UNSET} if not specified. + * @param type The {@link C.TrackType track type} of the adaptation set. + * @param representations {@link Representation}s in the adaptation set. + * @param accessibilityDescriptors Accessibility descriptors in the adaptation set. + * @param essentialProperties Essential properties in the adaptation set. + * @param supplementalProperties Supplemental properties in the adaptation set. + */ + public CustomAdaptationSet( + int id, + @C.TrackType int type, + List representations, + List accessibilityDescriptors, + List essentialProperties, + List supplementalProperties) { + this.id = id; + this.type = type; + this.representations = Collections.unmodifiableList(representations); + this.accessibilityDescriptors = Collections.unmodifiableList(accessibilityDescriptors); + this.essentialProperties = Collections.unmodifiableList(essentialProperties); + this.supplementalProperties = Collections.unmodifiableList(supplementalProperties); + } +} diff --git a/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/dashmanifestparser/CustomDashManifest.java b/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/dashmanifestparser/CustomDashManifest.java new file mode 100644 index 000000000..ac0c9b750 --- /dev/null +++ b/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/dashmanifestparser/CustomDashManifest.java @@ -0,0 +1,205 @@ +package com.kaltura.androidx.media3.exoplayer.dashmanifestparser; + +import android.net.Uri; +import androidx.annotation.Nullable; +import com.kaltura.androidx.media3.common.C; +import com.kaltura.androidx.media3.exoplayer.offline.FilterableManifest; +import com.kaltura.androidx.media3.common.StreamKey; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.ProgramInformation; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.ServiceDescriptionElement; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.UtcTimingElement; +import com.kaltura.androidx.media3.common.util.Util; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; + +/** + * Represents a DASH media presentation description (mpd), as defined by ISO/IEC 23009-1:2014 + * Section 5.3.1.2. + */ +public class CustomDashManifest implements FilterableManifest { + + /** + * The {@code availabilityStartTime} value in milliseconds since epoch, or {@link C#TIME_UNSET} if + * not present. + */ + public final long availabilityStartTimeMs; + + /** + * The duration of the presentation in milliseconds, or {@link C#TIME_UNSET} if not applicable. + */ + public final long durationMs; + + /** + * The {@code minBufferTime} value in milliseconds, or {@link C#TIME_UNSET} if not present. + */ + public final long minBufferTimeMs; + + /** + * Whether the manifest has value "dynamic" for the {@code type} attribute. + */ + public final boolean dynamic; + + /** + * The {@code minimumUpdatePeriod} value in milliseconds, or {@link C#TIME_UNSET} if not + * applicable. + */ + public final long minUpdatePeriodMs; + + /** + * The {@code timeShiftBufferDepth} value in milliseconds, or {@link C#TIME_UNSET} if not + * present. + */ + public final long timeShiftBufferDepthMs; + + /** + * The {@code suggestedPresentationDelay} value in milliseconds, or {@link C#TIME_UNSET} if not + * present. + */ + public final long suggestedPresentationDelayMs; + + /** + * The {@code publishTime} value in milliseconds since epoch, or {@link C#TIME_UNSET} if + * not present. + */ + public final long publishTimeMs; + + /** + * The {@link UtcTimingElement}, or null if not present. Defined in DVB A168:7/2016, Section + * 4.7.2. + */ + @Nullable public final UtcTimingElement utcTiming; + + /** The {@link ServiceDescriptionElement}, or null if not present. */ + @Nullable public final ServiceDescriptionElement serviceDescription; + + /** The location of this manifest, or null if not present. */ + @Nullable public final Uri location; + + /** The {@link ProgramInformation}, or null if not present. */ + @Nullable public final ProgramInformation programInformation; + + private final List periods; + + public CustomDashManifest( + long availabilityStartTimeMs, + long durationMs, + long minBufferTimeMs, + boolean dynamic, + long minUpdatePeriodMs, + long timeShiftBufferDepthMs, + long suggestedPresentationDelayMs, + long publishTimeMs, + @Nullable ProgramInformation programInformation, + @Nullable UtcTimingElement utcTiming, + @Nullable ServiceDescriptionElement serviceDescription, + @Nullable Uri location, + List periods) { + this.availabilityStartTimeMs = availabilityStartTimeMs; + this.durationMs = durationMs; + this.minBufferTimeMs = minBufferTimeMs; + this.dynamic = dynamic; + this.minUpdatePeriodMs = minUpdatePeriodMs; + this.timeShiftBufferDepthMs = timeShiftBufferDepthMs; + this.suggestedPresentationDelayMs = suggestedPresentationDelayMs; + this.publishTimeMs = publishTimeMs; + this.programInformation = programInformation; + this.utcTiming = utcTiming; + this.location = location; + this.serviceDescription = serviceDescription; + this.periods = periods == null ? Collections.emptyList() : periods; + } + + public final int getPeriodCount() { + return periods.size(); + } + + public final CustomPeriod getPeriod(int index) { + return periods.get(index); + } + + public final long getPeriodDurationMs(int index) { + return index == periods.size() - 1 + ? (durationMs == C.TIME_UNSET ? C.TIME_UNSET : (durationMs - periods.get(index).startMs)) + : (periods.get(index + 1).startMs - periods.get(index).startMs); + } + + public final long getPeriodDurationUs(int index) { + return Util.msToUs(getPeriodDurationMs(index)); + } + + @Override + public final CustomDashManifest copy(List streamKeys) { + LinkedList keys = new LinkedList<>(streamKeys); + Collections.sort(keys); + keys.add(new StreamKey(-1, -1, -1)); // Add a stopper key to the end + + ArrayList copyPeriods = new ArrayList<>(); + long shiftMs = 0; + for (int periodIndex = 0; periodIndex < getPeriodCount(); periodIndex++) { + if (keys.peek().periodIndex != periodIndex) { + // No representations selected in this period. + long periodDurationMs = getPeriodDurationMs(periodIndex); + if (periodDurationMs != C.TIME_UNSET) { + shiftMs += periodDurationMs; + } + } else { + CustomPeriod period = getPeriod(periodIndex); + ArrayList copyAdaptationSets = + copyAdaptationSets(period.adaptationSets, keys); + CustomPeriod copiedPeriod = new CustomPeriod(period.id, period.startMs - shiftMs, copyAdaptationSets, + period.eventStreams); + copyPeriods.add(copiedPeriod); + } + } + long newDuration = durationMs != C.TIME_UNSET ? durationMs - shiftMs : C.TIME_UNSET; + return new CustomDashManifest( + availabilityStartTimeMs, + newDuration, + minBufferTimeMs, + dynamic, + minUpdatePeriodMs, + timeShiftBufferDepthMs, + suggestedPresentationDelayMs, + publishTimeMs, + programInformation, + utcTiming, + serviceDescription, + location, + copyPeriods); + } + + private static ArrayList copyAdaptationSets( + List adaptationSets, LinkedList keys) { + StreamKey key = keys.poll(); + int periodIndex = key.periodIndex; + ArrayList copyAdaptationSets = new ArrayList<>(); + do { + int adaptationSetIndex = key.groupIndex; + CustomAdaptationSet adaptationSet = adaptationSets.get(adaptationSetIndex); + + List representations = adaptationSet.representations; + ArrayList copyRepresentations = new ArrayList<>(); + do { + CustomRepresentation representation = representations.get(key.streamIndex); + copyRepresentations.add(representation); + key = keys.poll(); + } while (key.periodIndex == periodIndex && key.groupIndex == adaptationSetIndex); + + copyAdaptationSets.add( + new CustomAdaptationSet( + adaptationSet.id, + adaptationSet.type, + copyRepresentations, + adaptationSet.accessibilityDescriptors, + adaptationSet.essentialProperties, + adaptationSet.supplementalProperties)); + } while(key.periodIndex == periodIndex); + // Add back the last key which doesn't belong to the period being processed + keys.addFirst(key); + return copyAdaptationSets; + } + +} diff --git a/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/dashmanifestparser/CustomDashManifestParser.java b/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/dashmanifestparser/CustomDashManifestParser.java new file mode 100644 index 000000000..1aa7ed05b --- /dev/null +++ b/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/dashmanifestparser/CustomDashManifestParser.java @@ -0,0 +1,2140 @@ +package com.kaltura.androidx.media3.exoplayer.dashmanifestparser; + +import android.net.Uri; +import android.text.TextUtils; +import android.util.Base64; +import android.util.Pair; +import android.util.Xml; + +import androidx.annotation.Nullable; + +import com.google.common.base.Ascii; +import com.google.common.base.Charsets; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Lists; +import com.kaltura.androidx.media3.common.C; +import com.kaltura.androidx.media3.common.Format; +import com.kaltura.androidx.media3.common.ParserException; +import com.kaltura.androidx.media3.common.DrmInitData; +import com.kaltura.androidx.media3.common.DrmInitData.SchemeData; +import com.kaltura.androidx.media3.extractor.mp4.PsshAtomUtil; +import com.kaltura.androidx.media3.extractor.metadata.emsg.EventMessage; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.BaseUrl; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.Descriptor; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.EventStream; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.ProgramInformation; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.RangedUri; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.ServiceDescriptionElement; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.UrlTemplate; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.UtcTimingElement; +import com.kaltura.androidx.media3.common.util.Assertions; +import com.kaltura.androidx.media3.common.util.Log; +import com.kaltura.androidx.media3.common.MimeTypes; +import com.kaltura.androidx.media3.common.util.UriUtil; +import com.kaltura.androidx.media3.common.util.Util; +import com.kaltura.androidx.media3.common.util.XmlPullParserUtil; + +import org.checkerframework.checker.nullness.compatqual.NullableType; +import org.xml.sax.helpers.DefaultHandler; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; +import org.xmlpull.v1.XmlPullParserFactory; +import org.xmlpull.v1.XmlSerializer; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.StringReader; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static com.kaltura.androidx.media3.exoplayer.dash.manifest.BaseUrl.DEFAULT_DVB_PRIORITY; +import static com.kaltura.androidx.media3.exoplayer.dash.manifest.BaseUrl.DEFAULT_WEIGHT; +import static com.kaltura.androidx.media3.exoplayer.dash.manifest.BaseUrl.PRIORITY_UNSET; +import static com.kaltura.androidx.media3.common.MimeTypes.BASE_TYPE_IMAGE; + +/** A parser of media presentation description files. */ +public class CustomDashManifestParser extends DefaultHandler { + + private static final String TAG = "MpdParser"; + + private static final Pattern FRAME_RATE_PATTERN = Pattern.compile("(\\d+)(?:/(\\d+))?"); + + private static final Pattern CEA_608_ACCESSIBILITY_PATTERN = Pattern.compile("CC([1-4])=.*"); + private static final Pattern CEA_708_ACCESSIBILITY_PATTERN = + Pattern.compile("([1-9]|[1-5][0-9]|6[0-3])=.*"); + + /** + * Maps the value attribute of an AudioChannelConfiguration with schemeIdUri + * "urn:mpeg:mpegB:cicp:ChannelConfiguration", as defined by ISO 23001-8 clause 8.1, to a channel + * count. + */ + private static final int[] MPEG_CHANNEL_CONFIGURATION_MAPPING = + new int[] { + Format.NO_VALUE, 1, 2, 3, 4, 5, 6, 8, 2, 3, 4, 7, 8, 24, 8, 12, 10, 12, 14, 12, 14 + }; + + private final XmlPullParserFactory xmlParserFactory; + + public CustomDashManifestParser() { + try { + xmlParserFactory = XmlPullParserFactory.newInstance(); + } catch (XmlPullParserException e) { + throw new RuntimeException("Couldn't create XmlPullParserFactory instance", e); + } + } + + // MPD parsing. + + public CustomDashManifest parse(Uri uri, String dashManifest) throws IOException { + try { + XmlPullParser xpp = xmlParserFactory.newPullParser(); + xpp.setInput(new StringReader(dashManifest)); + int eventType = xpp.next(); + if (eventType != XmlPullParser.START_TAG || !"MPD".equals(xpp.getName())) { + throw ParserException.createForMalformedManifest( + "inputStream does not contain a valid media presentation description", + /* cause= */ null); + } + return parseMediaPresentationDescription(xpp, uri); + } catch (XmlPullParserException e) { + throw ParserException.createForMalformedManifest(/* message= */ null, /* cause= */ e); + } + } + + protected CustomDashManifest parseMediaPresentationDescription( + XmlPullParser xpp, Uri documentBaseUri) throws XmlPullParserException, IOException { + boolean dvbProfileDeclared = + isDvbProfileDeclared(parseProfiles(xpp, "profiles", new String[0])); + long availabilityStartTime = parseDateTime(xpp, "availabilityStartTime", C.TIME_UNSET); + long durationMs = parseDuration(xpp, "mediaPresentationDuration", C.TIME_UNSET); + long minBufferTimeMs = parseDuration(xpp, "minBufferTime", C.TIME_UNSET); + String typeString = xpp.getAttributeValue(null, "type"); + boolean dynamic = "dynamic".equals(typeString); + long minUpdateTimeMs = + dynamic ? parseDuration(xpp, "minimumUpdatePeriod", C.TIME_UNSET) : C.TIME_UNSET; + long timeShiftBufferDepthMs = + dynamic ? parseDuration(xpp, "timeShiftBufferDepth", C.TIME_UNSET) : C.TIME_UNSET; + long suggestedPresentationDelayMs = + dynamic ? parseDuration(xpp, "suggestedPresentationDelay", C.TIME_UNSET) : C.TIME_UNSET; + long publishTimeMs = parseDateTime(xpp, "publishTime", C.TIME_UNSET); + ProgramInformation programInformation = null; + UtcTimingElement utcTiming = null; + Uri location = null; + ServiceDescriptionElement serviceDescription = null; + long baseUrlAvailabilityTimeOffsetUs = dynamic ? 0 : C.TIME_UNSET; + BaseUrl documentBaseUrl = + new BaseUrl( + documentBaseUri.toString(), + /* serviceLocation= */ documentBaseUri.toString(), + dvbProfileDeclared ? DEFAULT_DVB_PRIORITY : PRIORITY_UNSET, + DEFAULT_WEIGHT); + ArrayList parentBaseUrls = Lists.newArrayList(documentBaseUrl); + + List periods = new ArrayList<>(); + ArrayList baseUrls = new ArrayList<>(); + long nextPeriodStartMs = dynamic ? C.TIME_UNSET : 0; + boolean seenEarlyAccessPeriod = false; + boolean seenFirstBaseUrl = false; + do { + xpp.next(); + if (XmlPullParserUtil.isStartTag(xpp, "BaseURL")) { + if (!seenFirstBaseUrl) { + baseUrlAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, baseUrlAvailabilityTimeOffsetUs); + seenFirstBaseUrl = true; + } + baseUrls.addAll(parseBaseUrl(xpp, parentBaseUrls, dvbProfileDeclared)); + } else if (XmlPullParserUtil.isStartTag(xpp, "ProgramInformation")) { + programInformation = parseProgramInformation(xpp); + } else if (XmlPullParserUtil.isStartTag(xpp, "UTCTiming")) { + utcTiming = parseUtcTiming(xpp); + } else if (XmlPullParserUtil.isStartTag(xpp, "Location")) { + location = UriUtil.resolveToUri(documentBaseUri.toString(), xpp.nextText()); + } else if (XmlPullParserUtil.isStartTag(xpp, "ServiceDescription")) { + serviceDescription = parseServiceDescription(xpp); + } else if (XmlPullParserUtil.isStartTag(xpp, "Period") && !seenEarlyAccessPeriod) { + Pair periodWithDurationMs = + parsePeriod( + xpp, + !baseUrls.isEmpty() ? baseUrls : parentBaseUrls, + nextPeriodStartMs, + baseUrlAvailabilityTimeOffsetUs, + availabilityStartTime, + timeShiftBufferDepthMs, + dvbProfileDeclared); + CustomPeriod period = periodWithDurationMs.first; + if (period.startMs == C.TIME_UNSET) { + if (dynamic) { + // This is an early access period. Ignore it. All subsequent periods must also be + // early access. + seenEarlyAccessPeriod = true; + } else { + throw ParserException.createForMalformedManifest( + "Unable to determine start of period " + periods.size(), /* cause= */ null); + } + } else { + long periodDurationMs = periodWithDurationMs.second; + nextPeriodStartMs = + periodDurationMs == C.TIME_UNSET ? C.TIME_UNSET : (period.startMs + periodDurationMs); + periods.add(period); + } + } else { + maybeSkipTag(xpp); + } + } while (!XmlPullParserUtil.isEndTag(xpp, "MPD")); + + if (durationMs == C.TIME_UNSET) { + if (nextPeriodStartMs != C.TIME_UNSET) { + // If we know the end time of the final period, we can use it as the duration. + durationMs = nextPeriodStartMs; + } else if (!dynamic) { + throw ParserException.createForMalformedManifest( + "Unable to determine duration of static manifest.", /* cause= */ null); + } + } + + if (periods.isEmpty()) { + throw ParserException.createForMalformedManifest("No periods found.", /* cause= */ null); + } + + return buildMediaPresentationDescription( + availabilityStartTime, + durationMs, + minBufferTimeMs, + dynamic, + minUpdateTimeMs, + timeShiftBufferDepthMs, + suggestedPresentationDelayMs, + publishTimeMs, + programInformation, + utcTiming, + serviceDescription, + location, + periods); + } + + protected CustomDashManifest buildMediaPresentationDescription( + long availabilityStartTime, + long durationMs, + long minBufferTimeMs, + boolean dynamic, + long minUpdateTimeMs, + long timeShiftBufferDepthMs, + long suggestedPresentationDelayMs, + long publishTimeMs, + @Nullable ProgramInformation programInformation, + @Nullable UtcTimingElement utcTiming, + @Nullable ServiceDescriptionElement serviceDescription, + @Nullable Uri location, + List periods) { + return new CustomDashManifest( + availabilityStartTime, + durationMs, + minBufferTimeMs, + dynamic, + minUpdateTimeMs, + timeShiftBufferDepthMs, + suggestedPresentationDelayMs, + publishTimeMs, + programInformation, + utcTiming, + serviceDescription, + location, + periods); + } + + protected UtcTimingElement parseUtcTiming(XmlPullParser xpp) { + String schemeIdUri = xpp.getAttributeValue(null, "schemeIdUri"); + String value = xpp.getAttributeValue(null, "value"); + return buildUtcTimingElement(schemeIdUri, value); + } + + protected UtcTimingElement buildUtcTimingElement(String schemeIdUri, String value) { + return new UtcTimingElement(schemeIdUri, value); + } + + protected ServiceDescriptionElement parseServiceDescription(XmlPullParser xpp) + throws XmlPullParserException, IOException { + long targetOffsetMs = C.TIME_UNSET; + long minOffsetMs = C.TIME_UNSET; + long maxOffsetMs = C.TIME_UNSET; + float minPlaybackSpeed = C.RATE_UNSET; + float maxPlaybackSpeed = C.RATE_UNSET; + do { + xpp.next(); + if (XmlPullParserUtil.isStartTag(xpp, "Latency")) { + targetOffsetMs = parseLong(xpp, "target", C.TIME_UNSET); + minOffsetMs = parseLong(xpp, "min", C.TIME_UNSET); + maxOffsetMs = parseLong(xpp, "max", C.TIME_UNSET); + } else if (XmlPullParserUtil.isStartTag(xpp, "PlaybackRate")) { + minPlaybackSpeed = parseFloat(xpp, "min", C.RATE_UNSET); + maxPlaybackSpeed = parseFloat(xpp, "max", C.RATE_UNSET); + } + } while (!XmlPullParserUtil.isEndTag(xpp, "ServiceDescription")); + return new ServiceDescriptionElement( + targetOffsetMs, minOffsetMs, maxOffsetMs, minPlaybackSpeed, maxPlaybackSpeed); + } + + protected Pair parsePeriod( + XmlPullParser xpp, + List parentBaseUrls, + long defaultStartMs, + long baseUrlAvailabilityTimeOffsetUs, + long availabilityStartTimeMs, + long timeShiftBufferDepthMs, + boolean dvbProfileDeclared) + throws XmlPullParserException, IOException { + @Nullable String id = xpp.getAttributeValue(null, "id"); + long startMs = parseDuration(xpp, "start", defaultStartMs); + long periodStartUnixTimeMs = + availabilityStartTimeMs != C.TIME_UNSET ? availabilityStartTimeMs + startMs : C.TIME_UNSET; + long durationMs = parseDuration(xpp, "duration", C.TIME_UNSET); + @Nullable CustomSegmentBase segmentBase = null; + @Nullable Descriptor assetIdentifier = null; + List adaptationSets = new ArrayList<>(); + List eventStreams = new ArrayList<>(); + ArrayList baseUrls = new ArrayList<>(); + boolean seenFirstBaseUrl = false; + long segmentBaseAvailabilityTimeOffsetUs = C.TIME_UNSET; + do { + xpp.next(); + if (XmlPullParserUtil.isStartTag(xpp, "BaseURL")) { + if (!seenFirstBaseUrl) { + baseUrlAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, baseUrlAvailabilityTimeOffsetUs); + seenFirstBaseUrl = true; + } + baseUrls.addAll(parseBaseUrl(xpp, parentBaseUrls, dvbProfileDeclared)); + } else if (XmlPullParserUtil.isStartTag(xpp, "AdaptationSet")) { + adaptationSets.add( + parseAdaptationSet( + xpp, + !baseUrls.isEmpty() ? baseUrls : parentBaseUrls, + segmentBase, + durationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs, + periodStartUnixTimeMs, + timeShiftBufferDepthMs, + dvbProfileDeclared)); + } else if (XmlPullParserUtil.isStartTag(xpp, "EventStream")) { + eventStreams.add(parseEventStream(xpp)); + } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentBase")) { + segmentBase = parseSegmentBase(xpp, /* parent= */ null); + } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentList")) { + segmentBaseAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, /* parentAvailabilityTimeOffsetUs= */ C.TIME_UNSET); + segmentBase = + parseSegmentList( + xpp, + /* parent= */ null, + periodStartUnixTimeMs, + durationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs, + timeShiftBufferDepthMs); + } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentTemplate")) { + segmentBaseAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, /* parentAvailabilityTimeOffsetUs= */ C.TIME_UNSET); + segmentBase = + parseSegmentTemplate( + xpp, + /* parent= */ null, + ImmutableList.of(), + periodStartUnixTimeMs, + durationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs, + timeShiftBufferDepthMs); + } else if (XmlPullParserUtil.isStartTag(xpp, "AssetIdentifier")) { + assetIdentifier = parseDescriptor(xpp, "AssetIdentifier"); + } else { + maybeSkipTag(xpp); + } + } while (!XmlPullParserUtil.isEndTag(xpp, "Period")); + + return Pair.create( + buildPeriod(id, startMs, adaptationSets, eventStreams, assetIdentifier), durationMs); + } + + protected CustomPeriod buildPeriod( + @Nullable String id, + long startMs, + List adaptationSets, + List eventStreams, + @Nullable Descriptor assetIdentifier) { + return new CustomPeriod(id, startMs, adaptationSets, eventStreams, assetIdentifier); + } + + // AdaptationSet parsing. + + protected CustomAdaptationSet parseAdaptationSet( + XmlPullParser xpp, + List parentBaseUrls, + @Nullable CustomSegmentBase segmentBase, + long periodDurationMs, + long baseUrlAvailabilityTimeOffsetUs, + long segmentBaseAvailabilityTimeOffsetUs, + long periodStartUnixTimeMs, + long timeShiftBufferDepthMs, + boolean dvbProfileDeclared) + throws XmlPullParserException, IOException { + int id = parseInt(xpp, "id", CustomAdaptationSet.ID_UNSET); + @C.TrackType int contentType = parseContentType(xpp); + + String mimeType = xpp.getAttributeValue(null, "mimeType"); + + if (contentType == -1 && ("image/jpeg".equals(mimeType) || "image/png".equals(mimeType))) { + contentType = C.TRACK_TYPE_IMAGE; + } + + String codecs = xpp.getAttributeValue(null, "codecs"); + int width = parseInt(xpp, "width", Format.NO_VALUE); + int height = parseInt(xpp, "height", Format.NO_VALUE); + float frameRate = parseFrameRate(xpp, Format.NO_VALUE); + int audioChannels = Format.NO_VALUE; + int audioSamplingRate = parseInt(xpp, "audioSamplingRate", Format.NO_VALUE); + String language = xpp.getAttributeValue(null, "lang"); + String label = xpp.getAttributeValue(null, "label"); + String drmSchemeType = null; + ArrayList drmSchemeDatas = new ArrayList<>(); + ArrayList inbandEventStreams = new ArrayList<>(); + ArrayList accessibilityDescriptors = new ArrayList<>(); + ArrayList roleDescriptors = new ArrayList<>(); + ArrayList essentialProperties = new ArrayList<>(); + ArrayList supplementalProperties = new ArrayList<>(); + List representationInfos = new ArrayList<>(); + ArrayList baseUrls = new ArrayList<>(); + + boolean seenFirstBaseUrl = false; + do { + xpp.next(); + if (XmlPullParserUtil.isStartTag(xpp, "BaseURL")) { + if (!seenFirstBaseUrl) { + baseUrlAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, baseUrlAvailabilityTimeOffsetUs); + seenFirstBaseUrl = true; + } + baseUrls.addAll(parseBaseUrl(xpp, parentBaseUrls, dvbProfileDeclared)); + } else if (XmlPullParserUtil.isStartTag(xpp, "ContentProtection")) { + Pair contentProtection = parseContentProtection(xpp); + if (contentProtection.first != null) { + drmSchemeType = contentProtection.first; + } + if (contentProtection.second != null) { + drmSchemeDatas.add(contentProtection.second); + } + } else if (XmlPullParserUtil.isStartTag(xpp, "ContentComponent")) { + language = checkLanguageConsistency(language, xpp.getAttributeValue(null, "lang")); + contentType = checkContentTypeConsistency(contentType, parseContentType(xpp)); + } else if (XmlPullParserUtil.isStartTag(xpp, "Role")) { + roleDescriptors.add(parseDescriptor(xpp, "Role")); + } else if (XmlPullParserUtil.isStartTag(xpp, "AudioChannelConfiguration")) { + audioChannels = parseAudioChannelConfiguration(xpp); + } else if (XmlPullParserUtil.isStartTag(xpp, "Accessibility")) { + accessibilityDescriptors.add(parseDescriptor(xpp, "Accessibility")); + } else if (XmlPullParserUtil.isStartTag(xpp, "EssentialProperty")) { + essentialProperties.add(parseDescriptor(xpp, "EssentialProperty")); + } else if (XmlPullParserUtil.isStartTag(xpp, "SupplementalProperty")) { + supplementalProperties.add(parseDescriptor(xpp, "SupplementalProperty")); + } else if (XmlPullParserUtil.isStartTag(xpp, "Representation")) { + RepresentationInfo representationInfo = + parseRepresentation( + xpp, + !baseUrls.isEmpty() ? baseUrls : parentBaseUrls, + mimeType, + codecs, + width, + height, + frameRate, + audioChannels, + audioSamplingRate, + language, + roleDescriptors, + accessibilityDescriptors, + essentialProperties, + supplementalProperties, + segmentBase, + periodStartUnixTimeMs, + periodDurationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs, + timeShiftBufferDepthMs, + dvbProfileDeclared); + contentType = + checkContentTypeConsistency( + contentType, MimeTypes.getTrackType(representationInfo.format.sampleMimeType)); + representationInfos.add(representationInfo); + } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentBase")) { + segmentBase = parseSegmentBase(xpp, (CustomSegmentBase.SingleSegmentBase) segmentBase); + } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentList")) { + segmentBaseAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, segmentBaseAvailabilityTimeOffsetUs); + segmentBase = + parseSegmentList( + xpp, + (CustomSegmentBase.SegmentList) segmentBase, + periodStartUnixTimeMs, + periodDurationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs, + timeShiftBufferDepthMs); + } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentTemplate")) { + segmentBaseAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, segmentBaseAvailabilityTimeOffsetUs); + segmentBase = + parseSegmentTemplate( + xpp, + (CustomSegmentBase.SegmentTemplate) segmentBase, + supplementalProperties, + periodStartUnixTimeMs, + periodDurationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs, + timeShiftBufferDepthMs); + } else if (XmlPullParserUtil.isStartTag(xpp, "InbandEventStream")) { + inbandEventStreams.add(parseDescriptor(xpp, "InbandEventStream")); + } else if (XmlPullParserUtil.isStartTag(xpp, "Label")) { + label = parseLabel(xpp); + } else if (XmlPullParserUtil.isStartTag(xpp)) { + parseAdaptationSetChild(xpp); + } + } while (!XmlPullParserUtil.isEndTag(xpp, "AdaptationSet")); + + // Build the representations. + List representations = new ArrayList<>(representationInfos.size()); + for (int i = 0; i < representationInfos.size(); i++) { + representations.add( + buildRepresentation( + representationInfos.get(i), + label, + drmSchemeType, + drmSchemeDatas, + inbandEventStreams, + baseUrls)); + } + + return buildAdaptationSet( + id, + contentType, + representations, + accessibilityDescriptors, + essentialProperties, + supplementalProperties); + } + + protected CustomAdaptationSet buildAdaptationSet( + int id, + @C.TrackType int contentType, + List representations, + List accessibilityDescriptors, + List essentialProperties, + List supplementalProperties) { + return new CustomAdaptationSet( + id, + contentType, + representations, + accessibilityDescriptors, + essentialProperties, + supplementalProperties); + } + + protected @C.TrackType int parseContentType(XmlPullParser xpp) { + String contentType = xpp.getAttributeValue(null, "contentType"); + return TextUtils.isEmpty(contentType) ? C.TRACK_TYPE_UNKNOWN + : MimeTypes.BASE_TYPE_AUDIO.equals(contentType) ? C.TRACK_TYPE_AUDIO + : MimeTypes.BASE_TYPE_VIDEO.equals(contentType) ? C.TRACK_TYPE_VIDEO + : MimeTypes.BASE_TYPE_TEXT.equals(contentType) ? C.TRACK_TYPE_TEXT + : BASE_TYPE_IMAGE.equals(contentType) ? C.TRACK_TYPE_IMAGE + : C.TRACK_TYPE_UNKNOWN; + } + + /** + * Parses a ContentProtection element. + * + * @param xpp The parser from which to read. + * @throws XmlPullParserException If an error occurs parsing the element. + * @throws IOException If an error occurs reading the element. + * @return The scheme type and/or {@link SchemeData} parsed from the ContentProtection element. + * Either or both may be null, depending on the ContentProtection element being parsed. + */ + protected Pair<@NullableType String, @NullableType SchemeData> parseContentProtection( + XmlPullParser xpp) throws XmlPullParserException, IOException { + String schemeType = null; + String licenseServerUrl = null; + byte[] data = null; + UUID uuid = null; + + String schemeIdUri = xpp.getAttributeValue(null, "schemeIdUri"); + if (schemeIdUri != null) { + switch (Ascii.toLowerCase(schemeIdUri)) { + case "urn:mpeg:dash:mp4protection:2011": + schemeType = xpp.getAttributeValue(null, "value"); + String defaultKid = XmlPullParserUtil.getAttributeValueIgnorePrefix(xpp, "default_KID"); + if (!TextUtils.isEmpty(defaultKid) + && !"00000000-0000-0000-0000-000000000000".equals(defaultKid)) { + String[] defaultKidStrings = defaultKid.split("\\s+"); + UUID[] defaultKids = new UUID[defaultKidStrings.length]; + for (int i = 0; i < defaultKidStrings.length; i++) { + defaultKids[i] = UUID.fromString(defaultKidStrings[i]); + } + data = PsshAtomUtil.buildPsshAtom(C.COMMON_PSSH_UUID, defaultKids, null); + uuid = C.COMMON_PSSH_UUID; + } + break; + case "urn:uuid:9a04f079-9840-4286-ab92-e65be0885f95": + uuid = C.PLAYREADY_UUID; + break; + case "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed": + uuid = C.WIDEVINE_UUID; + break; + case "urn:uuid:e2719d58-a985-b3c9-781a-b030af78d30e": + uuid = C.CLEARKEY_UUID; + break; + default: + break; + } + } + + do { + xpp.next(); + if (XmlPullParserUtil.isStartTag(xpp, "clearkey:Laurl") && xpp.next() == XmlPullParser.TEXT) { + licenseServerUrl = xpp.getText(); + } else if (XmlPullParserUtil.isStartTag(xpp, "ms:laurl")) { + licenseServerUrl = xpp.getAttributeValue(null, "licenseUrl"); + } else if (data == null + && XmlPullParserUtil.isStartTagIgnorePrefix(xpp, "pssh") + && xpp.next() == XmlPullParser.TEXT) { + // The cenc:pssh element is defined in 23001-7:2015. + data = Base64.decode(xpp.getText(), Base64.DEFAULT); + uuid = PsshAtomUtil.parseUuid(data); + if (uuid == null) { + Log.w(TAG, "Skipping malformed cenc:pssh data"); + data = null; + } + } else if (data == null + && C.PLAYREADY_UUID.equals(uuid) + && XmlPullParserUtil.isStartTag(xpp, "mspr:pro") + && xpp.next() == XmlPullParser.TEXT) { + // The mspr:pro element is defined in DASH Content Protection using Microsoft PlayReady. + data = + PsshAtomUtil.buildPsshAtom( + C.PLAYREADY_UUID, Base64.decode(xpp.getText(), Base64.DEFAULT)); + } else { + maybeSkipTag(xpp); + } + } while (!XmlPullParserUtil.isEndTag(xpp, "ContentProtection")); + SchemeData schemeData = + uuid != null ? new SchemeData(uuid, licenseServerUrl, MimeTypes.VIDEO_MP4, data) : null; + return Pair.create(schemeType, schemeData); + } + + /** + * Parses children of AdaptationSet elements not specifically parsed elsewhere. + * + * @param xpp The XmpPullParser from which the AdaptationSet child should be parsed. + * @throws XmlPullParserException If an error occurs parsing the element. + * @throws IOException If an error occurs reading the element. + */ + protected void parseAdaptationSetChild(XmlPullParser xpp) + throws XmlPullParserException, IOException { + maybeSkipTag(xpp); + } + + // Representation parsing. + + protected RepresentationInfo parseRepresentation( + XmlPullParser xpp, + List parentBaseUrls, + @Nullable String adaptationSetMimeType, + @Nullable String adaptationSetCodecs, + int adaptationSetWidth, + int adaptationSetHeight, + float adaptationSetFrameRate, + int adaptationSetAudioChannels, + int adaptationSetAudioSamplingRate, + @Nullable String adaptationSetLanguage, + List adaptationSetRoleDescriptors, + List adaptationSetAccessibilityDescriptors, + List adaptationSetEssentialProperties, + List adaptationSetSupplementalProperties, + @Nullable CustomSegmentBase segmentBase, + long periodStartUnixTimeMs, + long periodDurationMs, + long baseUrlAvailabilityTimeOffsetUs, + long segmentBaseAvailabilityTimeOffsetUs, + long timeShiftBufferDepthMs, + boolean dvbProfileDeclared) + throws XmlPullParserException, IOException { + String id = xpp.getAttributeValue(null, "id"); + int bandwidth = parseInt(xpp, "bandwidth", Format.NO_VALUE); + + String mimeType = parseString(xpp, "mimeType", adaptationSetMimeType); + String codecs = parseString(xpp, "codecs", adaptationSetCodecs); + int width = parseInt(xpp, "width", adaptationSetWidth); + int height = parseInt(xpp, "height", adaptationSetHeight); + float frameRate = parseFrameRate(xpp, adaptationSetFrameRate); + int audioChannels = adaptationSetAudioChannels; + int audioSamplingRate = parseInt(xpp, "audioSamplingRate", adaptationSetAudioSamplingRate); + String drmSchemeType = null; + ArrayList drmSchemeDatas = new ArrayList<>(); + ArrayList inbandEventStreams = new ArrayList<>(); + ArrayList essentialProperties = new ArrayList<>(adaptationSetEssentialProperties); + ArrayList supplementalProperties = + new ArrayList<>(adaptationSetSupplementalProperties); + ArrayList baseUrls = new ArrayList<>(); + + boolean seenFirstBaseUrl = false; + do { + xpp.next(); + if (XmlPullParserUtil.isStartTag(xpp, "BaseURL")) { + if (!seenFirstBaseUrl) { + baseUrlAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, baseUrlAvailabilityTimeOffsetUs); + seenFirstBaseUrl = true; + } + baseUrls.addAll(parseBaseUrl(xpp, parentBaseUrls, dvbProfileDeclared)); + } else if (XmlPullParserUtil.isStartTag(xpp, "AudioChannelConfiguration")) { + audioChannels = parseAudioChannelConfiguration(xpp); + } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentBase")) { + segmentBase = parseSegmentBase(xpp, (CustomSegmentBase.SingleSegmentBase) segmentBase); + } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentList")) { + segmentBaseAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, segmentBaseAvailabilityTimeOffsetUs); + segmentBase = + parseSegmentList( + xpp, + (CustomSegmentBase.SegmentList) segmentBase, + periodStartUnixTimeMs, + periodDurationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs, + timeShiftBufferDepthMs); + } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentTemplate")) { + segmentBaseAvailabilityTimeOffsetUs = + parseAvailabilityTimeOffsetUs(xpp, segmentBaseAvailabilityTimeOffsetUs); + segmentBase = + parseSegmentTemplate( + xpp, + (CustomSegmentBase.SegmentTemplate) segmentBase, + adaptationSetSupplementalProperties, + periodStartUnixTimeMs, + periodDurationMs, + baseUrlAvailabilityTimeOffsetUs, + segmentBaseAvailabilityTimeOffsetUs, + timeShiftBufferDepthMs); + } else if (XmlPullParserUtil.isStartTag(xpp, "ContentProtection")) { + Pair contentProtection = parseContentProtection(xpp); + if (contentProtection.first != null) { + drmSchemeType = contentProtection.first; + } + if (contentProtection.second != null) { + drmSchemeDatas.add(contentProtection.second); + } + } else if (XmlPullParserUtil.isStartTag(xpp, "InbandEventStream")) { + inbandEventStreams.add(parseDescriptor(xpp, "InbandEventStream")); + } else if (XmlPullParserUtil.isStartTag(xpp, "EssentialProperty")) { + essentialProperties.add(parseDescriptor(xpp, "EssentialProperty")); + } else if (XmlPullParserUtil.isStartTag(xpp, "SupplementalProperty")) { + supplementalProperties.add(parseDescriptor(xpp, "SupplementalProperty")); + } else { + maybeSkipTag(xpp); + } + } while (!XmlPullParserUtil.isEndTag(xpp, "Representation")); + + String baseUrl = (!baseUrls.isEmpty() && baseUrls.size() > 0) ? baseUrls.get(0).url : parentBaseUrls.get(0).url; + + CustomFormat format = + buildFormat( + id, + mimeType, + width, + height, + frameRate, + audioChannels, + audioSamplingRate, + bandwidth, + adaptationSetLanguage, + adaptationSetRoleDescriptors, + adaptationSetAccessibilityDescriptors, + codecs, + essentialProperties, + supplementalProperties, + segmentBase, + baseUrl); + + segmentBase = segmentBase != null ? segmentBase : new CustomSegmentBase.SingleSegmentBase(); + + return new RepresentationInfo( + format, + !baseUrls.isEmpty() ? baseUrls : parentBaseUrls, + segmentBase, + drmSchemeType, + drmSchemeDatas, + inbandEventStreams, + essentialProperties, + supplementalProperties, + CustomRepresentation.REVISION_ID_DEFAULT); + } + + protected CustomFormat buildFormat( + @Nullable String id, + @Nullable String containerMimeType, + int width, + int height, + float frameRate, + int audioChannels, + int audioSamplingRate, + int bitrate, + @Nullable String language, + List roleDescriptors, + List accessibilityDescriptors, + @Nullable String codecs, + List essentialProperties, + List supplementalProperties, + CustomSegmentBase segmentBase, + String baseURL) { + @Nullable String sampleMimeType = getSampleMimeType(containerMimeType, codecs); + if (MimeTypes.AUDIO_E_AC3.equals(sampleMimeType)) { + sampleMimeType = parseEac3SupplementalProperties(supplementalProperties); + if (MimeTypes.AUDIO_E_AC3_JOC.equals(sampleMimeType)) { + codecs = MimeTypes.CODEC_E_AC3_JOC; + } + } + + CustomFormat.FormatThumbnailInfo formatThumbnailInfo = null; + if (isImage(containerMimeType)) { + formatThumbnailInfo = buildFormatThumbnailInfo(baseURL, id, bitrate, segmentBase, essentialProperties); + } + + @C.SelectionFlags int selectionFlags = parseSelectionFlagsFromRoleDescriptors(roleDescriptors); + @C.RoleFlags int roleFlags = parseRoleFlagsFromRoleDescriptors(roleDescriptors); + roleFlags |= parseRoleFlagsFromAccessibilityDescriptors(accessibilityDescriptors); + roleFlags |= parseRoleFlagsFromProperties(essentialProperties); + roleFlags |= parseRoleFlagsFromProperties(supplementalProperties); + + CustomFormat.Builder formatBuilder = + new CustomFormat.Builder() + .setId(id) + .setContainerMimeType(containerMimeType) + .setSampleMimeType(sampleMimeType) + .setCodecs(codecs) + .setPeakBitrate(bitrate) + .setSelectionFlags(selectionFlags) + .setRoleFlags(roleFlags) + .setLanguage(language); + + if (isImage(containerMimeType)) { + formatBuilder.setFormatThumbnailInfo(formatThumbnailInfo); + formatBuilder.setWidth(width).setHeight(height); + } + + if (MimeTypes.isVideo(sampleMimeType)) { + formatBuilder.setWidth(width).setHeight(height).setFrameRate(frameRate); + } else if (MimeTypes.isAudio(sampleMimeType)) { + formatBuilder.setChannelCount(audioChannels).setSampleRate(audioSamplingRate); + } else if (MimeTypes.isText(sampleMimeType)) { + int accessibilityChannel = Format.NO_VALUE; + if (MimeTypes.APPLICATION_CEA608.equals(sampleMimeType)) { + accessibilityChannel = parseCea608AccessibilityChannel(accessibilityDescriptors); + } else if (MimeTypes.APPLICATION_CEA708.equals(sampleMimeType)) { + accessibilityChannel = parseCea708AccessibilityChannel(accessibilityDescriptors); + } + formatBuilder.setAccessibilityChannel(accessibilityChannel); + } else if (MimeTypes.isImage(sampleMimeType)) { + formatBuilder.setWidth(width).setHeight(height); + } + + return formatBuilder.build(); + } + + /** Returns whether the given string is a image MIME type. */ + public static boolean isImage(@Nullable String mimeType) { + return BASE_TYPE_IMAGE.equals(getTopLevelType(mimeType)); + } + + private static String getTopLevelType(@Nullable String mimeType) { + if (mimeType == null) { + return null; + } + int indexOfSlash = mimeType.indexOf('/'); + if (indexOfSlash == -1) { + return null; + } + return mimeType.substring(0, indexOfSlash); + } + + protected CustomRepresentation buildRepresentation( + RepresentationInfo representationInfo, + @Nullable String label, + @Nullable String extraDrmSchemeType, + ArrayList extraDrmSchemeDatas, + ArrayList extraInbandEventStreams, + List parentBaseUrls) { + CustomFormat.Builder formatBuilder = representationInfo.format.buildUpon(); + if (label != null) { + formatBuilder.setLabel(label); + } + + if (isImage(representationInfo.format.containerMimeType)) { + if (representationInfo.format.formatThumbnailInfo == null) { + String baseUrl = (!representationInfo.baseUrls.isEmpty() && representationInfo.baseUrls.size() > 0) ? + representationInfo.baseUrls.get(0).url : + parentBaseUrls.get(0).url; + formatBuilder.setFormatThumbnailInfo(buildFormatThumbnailInfo(baseUrl, representationInfo.format.id, representationInfo.format.bitrate, representationInfo.segmentBase, null)); + } else { + formatBuilder.setFormatThumbnailInfo(representationInfo.format.formatThumbnailInfo); + } + } + + @Nullable String drmSchemeType = representationInfo.drmSchemeType; + if (drmSchemeType == null) { + drmSchemeType = extraDrmSchemeType; + } + ArrayList drmSchemeDatas = representationInfo.drmSchemeDatas; + drmSchemeDatas.addAll(extraDrmSchemeDatas); + if (!drmSchemeDatas.isEmpty()) { + fillInClearKeyInformation(drmSchemeDatas); + filterRedundantIncompleteSchemeDatas(drmSchemeDatas); + formatBuilder.setDrmInitData(new DrmInitData(drmSchemeType, drmSchemeDatas)); + } + ArrayList inbandEventStreams = representationInfo.inbandEventStreams; + inbandEventStreams.addAll(extraInbandEventStreams); + return CustomRepresentation.newInstance( + representationInfo.revisionId, + formatBuilder.build(), + representationInfo.baseUrls, + representationInfo.segmentBase, + inbandEventStreams, + representationInfo.essentialProperties, + representationInfo.supplementalProperties, + /* cacheKey= */ null); + } + + private CustomFormat.FormatThumbnailInfo buildFormatThumbnailInfo(String baseURL, String id, int bitrate, CustomSegmentBase segmentBase, List essentialProperties) { + CustomFormat.FormatThumbnailInfo.Builder thumbnailInfoBuilder = new CustomFormat.FormatThumbnailInfo.Builder(); + String structure = ""; + int tilesHorizontal = 1; + int tilesVertical = 1; + + if (essentialProperties != null && essentialProperties.size() == 1) { + structure = essentialProperties.get(0).value; + if (!TextUtils.isEmpty(structure) && structure.contains("x")) { + String[] structureContent = structure.split("x"); + if (TextUtils.isDigitsOnly(structureContent[0]) && TextUtils.isDigitsOnly(structureContent[1])) { + tilesHorizontal = Integer.parseInt(structureContent[0]); + tilesVertical = Integer.parseInt(structureContent[1]); + } + } + } + long presentationTimeOffset = ((CustomSegmentBase.SegmentTemplate)segmentBase).getPresentationTimeOffset(); + long timeScale = ((CustomSegmentBase.SegmentTemplate)segmentBase).getTimescale(); + long startNumber = ((CustomSegmentBase.SegmentTemplate)segmentBase).getStartNumber(); + long endNumber = ((CustomSegmentBase.SegmentTemplate)segmentBase).getEndNumber(); + UrlTemplate urlTemplate = ((CustomSegmentBase.SegmentTemplate)segmentBase).getInitializationTemplate(); + if (urlTemplate == null) { + urlTemplate = ((CustomSegmentBase.SegmentTemplate)segmentBase).mediaTemplate; + } + String imageTemplateUrl = ""; + if (urlTemplate != null) { + imageTemplateUrl = baseURL + urlTemplate.buildUri(id, 900000009, bitrate, 800000008); + imageTemplateUrl = imageTemplateUrl.replace("900000009", "$Number$").replace("800000008", "$Time$"); + + } + thumbnailInfoBuilder.setImageTemplateUrl(imageTemplateUrl); + thumbnailInfoBuilder.setSegmentDuration(((CustomSegmentBase.SegmentTemplate)segmentBase).duration); + thumbnailInfoBuilder.setStructure(structure); + thumbnailInfoBuilder.setTilesHorizontal(tilesHorizontal); + thumbnailInfoBuilder.setTilesVertical(tilesVertical); + thumbnailInfoBuilder.setPresentationTimeOffset(presentationTimeOffset); + thumbnailInfoBuilder.setTimeScale(timeScale); + thumbnailInfoBuilder.setStartNumber(startNumber); + thumbnailInfoBuilder.setEndNumber(endNumber); + return thumbnailInfoBuilder.build(); + } + + // SegmentBase, SegmentList and SegmentTemplate parsing. + + protected CustomSegmentBase.SingleSegmentBase parseSegmentBase( + XmlPullParser xpp, @Nullable CustomSegmentBase.SingleSegmentBase parent) + throws XmlPullParserException, IOException { + + long timescale = parseLong(xpp, "timescale", parent != null ? parent.timescale : 1); + long presentationTimeOffset = + parseLong( + xpp, "presentationTimeOffset", parent != null ? parent.presentationTimeOffset : 0); + + long indexStart = parent != null ? parent.indexStart : 0; + long indexLength = parent != null ? parent.indexLength : 0; + String indexRangeText = xpp.getAttributeValue(null, "indexRange"); + if (indexRangeText != null) { + String[] indexRange = indexRangeText.split("-"); + indexStart = Long.parseLong(indexRange[0]); + indexLength = Long.parseLong(indexRange[1]) - indexStart + 1; + } + + @Nullable RangedUri initialization = parent != null ? parent.initialization : null; + do { + xpp.next(); + if (XmlPullParserUtil.isStartTag(xpp, "Initialization")) { + initialization = parseInitialization(xpp); + } else { + maybeSkipTag(xpp); + } + } while (!XmlPullParserUtil.isEndTag(xpp, "SegmentBase")); + + return buildSingleSegmentBase( + initialization, timescale, presentationTimeOffset, indexStart, indexLength); + } + + protected CustomSegmentBase.SingleSegmentBase buildSingleSegmentBase( + RangedUri initialization, + long timescale, + long presentationTimeOffset, + long indexStart, + long indexLength) { + return new CustomSegmentBase.SingleSegmentBase( + initialization, timescale, presentationTimeOffset, indexStart, indexLength); + } + + protected CustomSegmentBase.SegmentList parseSegmentList( + XmlPullParser xpp, + @Nullable CustomSegmentBase.SegmentList parent, + long periodStartUnixTimeMs, + long periodDurationMs, + long baseUrlAvailabilityTimeOffsetUs, + long segmentBaseAvailabilityTimeOffsetUs, + long timeShiftBufferDepthMs) + throws XmlPullParserException, IOException { + + long timescale = parseLong(xpp, "timescale", parent != null ? parent.timescale : 1); + long presentationTimeOffset = + parseLong( + xpp, "presentationTimeOffset", parent != null ? parent.presentationTimeOffset : 0); + long duration = parseLong(xpp, "duration", parent != null ? parent.duration : C.TIME_UNSET); + long startNumber = parseLong(xpp, "startNumber", parent != null ? parent.startNumber : 1); + long availabilityTimeOffsetUs = + getFinalAvailabilityTimeOffset( + baseUrlAvailabilityTimeOffsetUs, segmentBaseAvailabilityTimeOffsetUs); + + RangedUri initialization = null; + List timeline = null; + List segments = null; + + do { + xpp.next(); + if (XmlPullParserUtil.isStartTag(xpp, "Initialization")) { + initialization = parseInitialization(xpp); + } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentTimeline")) { + timeline = parseSegmentTimeline(xpp, timescale, periodDurationMs); + } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentURL")) { + if (segments == null) { + segments = new ArrayList<>(); + } + segments.add(parseSegmentUrl(xpp)); + } else { + maybeSkipTag(xpp); + } + } while (!XmlPullParserUtil.isEndTag(xpp, "SegmentList")); + + if (parent != null) { + initialization = initialization != null ? initialization : parent.initialization; + timeline = timeline != null ? timeline : parent.segmentTimeline; + segments = segments != null ? segments : parent.mediaSegments; + } + + return buildSegmentList( + initialization, + timescale, + presentationTimeOffset, + startNumber, + duration, + timeline, + availabilityTimeOffsetUs, + segments, + timeShiftBufferDepthMs, + periodStartUnixTimeMs); + } + + protected CustomSegmentBase.SegmentList buildSegmentList( + RangedUri initialization, + long timescale, + long presentationTimeOffset, + long startNumber, + long duration, + @Nullable List timeline, + long availabilityTimeOffsetUs, + @Nullable List segments, + long timeShiftBufferDepthMs, + long periodStartUnixTimeMs) { + return new CustomSegmentBase.SegmentList( + initialization, + timescale, + presentationTimeOffset, + startNumber, + duration, + timeline, + availabilityTimeOffsetUs, + segments, + Util.msToUs(timeShiftBufferDepthMs), + Util.msToUs(periodStartUnixTimeMs)); + } + + protected CustomSegmentBase.SegmentTemplate parseSegmentTemplate( + XmlPullParser xpp, + @Nullable CustomSegmentBase.SegmentTemplate parent, + List adaptationSetSupplementalProperties, + long periodStartUnixTimeMs, + long periodDurationMs, + long baseUrlAvailabilityTimeOffsetUs, + long segmentBaseAvailabilityTimeOffsetUs, + long timeShiftBufferDepthMs) + throws XmlPullParserException, IOException { + long timescale = parseLong(xpp, "timescale", parent != null ? parent.timescale : 1); + long presentationTimeOffset = + parseLong( + xpp, "presentationTimeOffset", parent != null ? parent.presentationTimeOffset : 0); + long duration = parseLong(xpp, "duration", parent != null ? parent.duration : C.TIME_UNSET); + long startNumber = parseLong(xpp, "startNumber", parent != null ? parent.startNumber : 1); + long endNumber = + parseLastSegmentNumberSupplementalProperty(adaptationSetSupplementalProperties); + long availabilityTimeOffsetUs = + getFinalAvailabilityTimeOffset( + baseUrlAvailabilityTimeOffsetUs, segmentBaseAvailabilityTimeOffsetUs); + + UrlTemplate mediaTemplate = + parseUrlTemplate(xpp, "media", parent != null ? parent.mediaTemplate : null); + UrlTemplate initializationTemplate = + parseUrlTemplate( + xpp, "initialization", parent != null ? parent.initializationTemplate : null); + + RangedUri initialization = null; + List timeline = null; + + do { + xpp.next(); + if (XmlPullParserUtil.isStartTag(xpp, "Initialization")) { + initialization = parseInitialization(xpp); + } else if (XmlPullParserUtil.isStartTag(xpp, "SegmentTimeline")) { + timeline = parseSegmentTimeline(xpp, timescale, periodDurationMs); + } else { + maybeSkipTag(xpp); + } + } while (!XmlPullParserUtil.isEndTag(xpp, "SegmentTemplate")); + + if (parent != null) { + initialization = initialization != null ? initialization : parent.initialization; + timeline = timeline != null ? timeline : parent.segmentTimeline; + } + + return buildSegmentTemplate( + initialization, + timescale, + presentationTimeOffset, + startNumber, + endNumber, + duration, + timeline, + availabilityTimeOffsetUs, + initializationTemplate, + mediaTemplate, + timeShiftBufferDepthMs, + periodStartUnixTimeMs); + } + + protected CustomSegmentBase.SegmentTemplate buildSegmentTemplate( + RangedUri initialization, + long timescale, + long presentationTimeOffset, + long startNumber, + long endNumber, + long duration, + List timeline, + long availabilityTimeOffsetUs, + @Nullable UrlTemplate initializationTemplate, + @Nullable UrlTemplate mediaTemplate, + long timeShiftBufferDepthMs, + long periodStartUnixTimeMs) { + return new CustomSegmentBase.SegmentTemplate( + initialization, + timescale, + presentationTimeOffset, + startNumber, + endNumber, + duration, + timeline, + availabilityTimeOffsetUs, + initializationTemplate, + mediaTemplate, + Util.msToUs(timeShiftBufferDepthMs), + Util.msToUs(periodStartUnixTimeMs)); + } + + /** + * Parses a single EventStream node in the manifest. + * + * @param xpp The current xml parser. + * @return The {@link EventStream} parsed from this EventStream node. + * @throws XmlPullParserException If there is any error parsing this node. + * @throws IOException If there is any error reading from the underlying input stream. + */ + protected EventStream parseEventStream(XmlPullParser xpp) + throws XmlPullParserException, IOException { + String schemeIdUri = parseString(xpp, "schemeIdUri", ""); + String value = parseString(xpp, "value", ""); + long timescale = parseLong(xpp, "timescale", 1); + long presentationTimeOffset = parseLong(xpp, "presentationTimeOffset", 0); + List> eventMessages = new ArrayList<>(); + ByteArrayOutputStream scratchOutputStream = new ByteArrayOutputStream(512); + do { + xpp.next(); + if (XmlPullParserUtil.isStartTag(xpp, "Event")) { + Pair event = + parseEvent(xpp, schemeIdUri, value, timescale, presentationTimeOffset, scratchOutputStream); + eventMessages.add(event); + } else { + maybeSkipTag(xpp); + } + } while (!XmlPullParserUtil.isEndTag(xpp, "EventStream")); + + long[] presentationTimesUs = new long[eventMessages.size()]; + EventMessage[] events = new EventMessage[eventMessages.size()]; + for (int i = 0; i < eventMessages.size(); i++) { + Pair event = eventMessages.get(i); + presentationTimesUs[i] = event.first; + events[i] = event.second; + } + return buildEventStream(schemeIdUri, value, timescale, presentationTimesUs, events); + } + + protected EventStream buildEventStream( + String schemeIdUri, + String value, + long timescale, + long[] presentationTimesUs, + EventMessage[] events) { + return new EventStream(schemeIdUri, value, timescale, presentationTimesUs, events); + } + + /** + * Parses a single Event node in the manifest. + * + * @param xpp The current xml parser. + * @param schemeIdUri The schemeIdUri of the parent EventStream. + * @param value The schemeIdUri of the parent EventStream. + * @param timescale The timescale of the parent EventStream. + * @param presentationTimeOffset The unscaled presentation time offset of the parent EventStream. + * @param scratchOutputStream A {@link ByteArrayOutputStream} that is used when parsing event + * objects. + * @return A pair containing the node's presentation timestamp in microseconds and the parsed + * {@link EventMessage}. + * @throws XmlPullParserException If there is any error parsing this node. + * @throws IOException If there is any error reading from the underlying input stream. + */ + protected Pair parseEvent( + XmlPullParser xpp, + String schemeIdUri, + String value, + long timescale, + long presentationTimeOffset, + ByteArrayOutputStream scratchOutputStream) + throws IOException, XmlPullParserException { + long id = parseLong(xpp, "id", 0); + long duration = parseLong(xpp, "duration", C.TIME_UNSET); + long presentationTime = parseLong(xpp, "presentationTime", 0); + long durationMs = Util.scaleLargeTimestamp(duration, C.MILLIS_PER_SECOND, timescale); + long presentationTimesUs = + Util.scaleLargeTimestamp(presentationTime - presentationTimeOffset, C.MICROS_PER_SECOND, timescale); + String messageData = parseString(xpp, "messageData", null); + byte[] eventObject = parseEventObject(xpp, scratchOutputStream); + return Pair.create( + presentationTimesUs, + buildEvent( + schemeIdUri, + value, + id, + durationMs, + messageData == null ? eventObject : Util.getUtf8Bytes(messageData))); + } + + /** + * Parses an event object. + * + * @param xpp The current xml parser. + * @param scratchOutputStream A {@link ByteArrayOutputStream} that's used when parsing the object. + * @return The serialized byte array. + * @throws XmlPullParserException If there is any error parsing this node. + * @throws IOException If there is any error reading from the underlying input stream. + */ + protected byte[] parseEventObject(XmlPullParser xpp, ByteArrayOutputStream scratchOutputStream) + throws XmlPullParserException, IOException { + scratchOutputStream.reset(); + XmlSerializer xmlSerializer = Xml.newSerializer(); + xmlSerializer.setOutput(scratchOutputStream, Charsets.UTF_8.name()); + // Start reading everything between and , and serialize them into an Xml + // byte array. + xpp.nextToken(); + while (!XmlPullParserUtil.isEndTag(xpp, "Event")) { + switch (xpp.getEventType()) { + case (XmlPullParser.START_DOCUMENT): + xmlSerializer.startDocument(null, false); + break; + case (XmlPullParser.END_DOCUMENT): + xmlSerializer.endDocument(); + break; + case (XmlPullParser.START_TAG): + xmlSerializer.startTag(xpp.getNamespace(), xpp.getName()); + for (int i = 0; i < xpp.getAttributeCount(); i++) { + xmlSerializer.attribute( + xpp.getAttributeNamespace(i), xpp.getAttributeName(i), xpp.getAttributeValue(i)); + } + break; + case (XmlPullParser.END_TAG): + xmlSerializer.endTag(xpp.getNamespace(), xpp.getName()); + break; + case (XmlPullParser.TEXT): + xmlSerializer.text(xpp.getText()); + break; + case (XmlPullParser.CDSECT): + xmlSerializer.cdsect(xpp.getText()); + break; + case (XmlPullParser.ENTITY_REF): + xmlSerializer.entityRef(xpp.getText()); + break; + case (XmlPullParser.IGNORABLE_WHITESPACE): + xmlSerializer.ignorableWhitespace(xpp.getText()); + break; + case (XmlPullParser.PROCESSING_INSTRUCTION): + xmlSerializer.processingInstruction(xpp.getText()); + break; + case (XmlPullParser.COMMENT): + xmlSerializer.comment(xpp.getText()); + break; + case (XmlPullParser.DOCDECL): + xmlSerializer.docdecl(xpp.getText()); + break; + default: // fall out + } + xpp.nextToken(); + } + xmlSerializer.flush(); + return scratchOutputStream.toByteArray(); + } + + protected EventMessage buildEvent( + String schemeIdUri, String value, long id, long durationMs, byte[] messageData) { + return new EventMessage(schemeIdUri, value, durationMs, id, messageData); + } + + protected List parseSegmentTimeline( + XmlPullParser xpp, long timescale, long periodDurationMs) + throws XmlPullParserException, IOException { + List segmentTimeline = new ArrayList<>(); + long startTime = 0; + long elementDuration = C.TIME_UNSET; + int elementRepeatCount = 0; + boolean havePreviousTimelineElement = false; + do { + xpp.next(); + if (XmlPullParserUtil.isStartTag(xpp, "S")) { + long newStartTime = parseLong(xpp, "t", C.TIME_UNSET); + if (havePreviousTimelineElement) { + startTime = + addSegmentTimelineElementsToList( + segmentTimeline, + startTime, + elementDuration, + elementRepeatCount, + /* endTime= */ newStartTime); + } + if (newStartTime != C.TIME_UNSET) { + startTime = newStartTime; + } + elementDuration = parseLong(xpp, "d", C.TIME_UNSET); + elementRepeatCount = parseInt(xpp, "r", 0); + havePreviousTimelineElement = true; + } else { + maybeSkipTag(xpp); + } + } while (!XmlPullParserUtil.isEndTag(xpp, "SegmentTimeline")); + if (havePreviousTimelineElement) { + long periodDuration = Util.scaleLargeTimestamp(periodDurationMs, timescale, 1000); + addSegmentTimelineElementsToList( + segmentTimeline, + startTime, + elementDuration, + elementRepeatCount, + /* endTime= */ periodDuration); + } + return segmentTimeline; + } + + /** + * Adds timeline elements for one S tag to the segment timeline. + * + * @param startTime Start time of the first timeline element. + * @param elementDuration Duration of one timeline element. + * @param elementRepeatCount Number of timeline elements minus one. May be negative to indicate + * that the count is determined by the total duration and the element duration. + * @param endTime End time of the last timeline element for this S tag, or {@link C#TIME_UNSET} if + * unknown. Only needed if {@code repeatCount} is negative. + * @return Calculated next start time. + */ + private long addSegmentTimelineElementsToList( + List segmentTimeline, + long startTime, + long elementDuration, + int elementRepeatCount, + long endTime) { + int count = + elementRepeatCount >= 0 + ? 1 + elementRepeatCount + : (int) Util.ceilDivide(endTime - startTime, elementDuration); + for (int i = 0; i < count; i++) { + segmentTimeline.add(buildSegmentTimelineElement(startTime, elementDuration)); + startTime += elementDuration; + } + return startTime; + } + + protected CustomSegmentBase.SegmentTimelineElement buildSegmentTimelineElement(long startTime, long duration) { + return new CustomSegmentBase.SegmentTimelineElement(startTime, duration); + } + + @Nullable + protected UrlTemplate parseUrlTemplate( + XmlPullParser xpp, String name, @Nullable UrlTemplate defaultValue) { + String valueString = xpp.getAttributeValue(null, name); + if (valueString != null) { + return UrlTemplate.compile(valueString); + } + return defaultValue; + } + + protected RangedUri parseInitialization(XmlPullParser xpp) { + return parseRangedUrl(xpp, "sourceURL", "range"); + } + + protected RangedUri parseSegmentUrl(XmlPullParser xpp) { + return parseRangedUrl(xpp, "media", "mediaRange"); + } + + protected RangedUri parseRangedUrl( + XmlPullParser xpp, String urlAttribute, String rangeAttribute) { + String urlText = xpp.getAttributeValue(null, urlAttribute); + long rangeStart = 0; + long rangeLength = C.LENGTH_UNSET; + String rangeText = xpp.getAttributeValue(null, rangeAttribute); + if (rangeText != null) { + String[] rangeTextArray = rangeText.split("-"); + rangeStart = Long.parseLong(rangeTextArray[0]); + if (rangeTextArray.length == 2) { + rangeLength = Long.parseLong(rangeTextArray[1]) - rangeStart + 1; + } + } + return buildRangedUri(urlText, rangeStart, rangeLength); + } + + protected RangedUri buildRangedUri(String urlText, long rangeStart, long rangeLength) { + return new RangedUri(urlText, rangeStart, rangeLength); + } + + protected ProgramInformation parseProgramInformation(XmlPullParser xpp) + throws IOException, XmlPullParserException { + String title = null; + String source = null; + String copyright = null; + String moreInformationURL = parseString(xpp, "moreInformationURL", null); + String lang = parseString(xpp, "lang", null); + do { + xpp.next(); + if (XmlPullParserUtil.isStartTag(xpp, "Title")) { + title = xpp.nextText(); + } else if (XmlPullParserUtil.isStartTag(xpp, "Source")) { + source = xpp.nextText(); + } else if (XmlPullParserUtil.isStartTag(xpp, "Copyright")) { + copyright = xpp.nextText(); + } else { + maybeSkipTag(xpp); + } + } while (!XmlPullParserUtil.isEndTag(xpp, "ProgramInformation")); + return new ProgramInformation(title, source, copyright, moreInformationURL, lang); + } + + /** + * Parses a Label element. + * + * @param xpp The parser from which to read. + * @throws XmlPullParserException If an error occurs parsing the element. + * @throws IOException If an error occurs reading the element. + * @return The parsed label. + */ + protected String parseLabel(XmlPullParser xpp) throws XmlPullParserException, IOException { + return parseText(xpp, "Label"); + } + + /** + * Parses a BaseURL element. + * + * @param xpp The parser from which to read. + * @param parentBaseUrls The parent base URLs for resolving the parsed URLs. + * @param dvbProfileDeclared Whether the dvb profile is declared. + * @throws XmlPullParserException If an error occurs parsing the element. + * @throws IOException If an error occurs reading the element. + * @return The list of parsed and resolved URLs. + */ + protected List parseBaseUrl(XmlPullParser xpp, List parentBaseUrls, boolean dvbProfileDeclared) + throws XmlPullParserException, IOException { + @Nullable String priorityValue = xpp.getAttributeValue(null, "dvb:priority"); + int priority = + priorityValue != null + ? Integer.parseInt(priorityValue) + : (dvbProfileDeclared ? DEFAULT_DVB_PRIORITY : PRIORITY_UNSET); + @Nullable String weightValue = xpp.getAttributeValue(null, "dvb:weight"); + int weight = weightValue != null ? Integer.parseInt(weightValue) : DEFAULT_WEIGHT; + @Nullable String serviceLocation = xpp.getAttributeValue(null, "serviceLocation"); + String baseUrl = parseText(xpp, "BaseURL"); + if (UriUtil.isAbsolute(baseUrl)) { + if (serviceLocation == null) { + serviceLocation = baseUrl; + } + return Lists.newArrayList(new BaseUrl(baseUrl, serviceLocation, priority, weight)); + } + + List baseUrls = new ArrayList<>(); + for (int i = 0; i < parentBaseUrls.size(); i++) { + BaseUrl parentBaseUrl = parentBaseUrls.get(i); + String resolvedBaseUri = UriUtil.resolve(parentBaseUrl.url, baseUrl); + String resolvedServiceLocation = serviceLocation == null ? resolvedBaseUri : serviceLocation; + if (dvbProfileDeclared) { + // Inherit parent properties only if dvb profile is declared. + priority = parentBaseUrl.priority; + weight = parentBaseUrl.weight; + resolvedServiceLocation = parentBaseUrl.serviceLocation; + } + baseUrls.add(new BaseUrl(resolvedBaseUri, resolvedServiceLocation, priority, weight)); + } + return baseUrls; + } + + /** + * Parses the availabilityTimeOffset value and returns the parsed value or the parent value if it + * doesn't exist. + * + * @param xpp The parser from which to read. + * @param parentAvailabilityTimeOffsetUs The availability time offset of a parent element in + * microseconds. + * @return The parsed availabilityTimeOffset in microseconds. + */ + protected long parseAvailabilityTimeOffsetUs( + XmlPullParser xpp, long parentAvailabilityTimeOffsetUs) { + String value = xpp.getAttributeValue(/* namespace= */ null, "availabilityTimeOffset"); + if (value == null) { + return parentAvailabilityTimeOffsetUs; + } + if ("INF".equals(value)) { + return Long.MAX_VALUE; + } + return (long) (Float.parseFloat(value) * C.MICROS_PER_SECOND); + } + + // AudioChannelConfiguration parsing. + + protected int parseAudioChannelConfiguration(XmlPullParser xpp) + throws XmlPullParserException, IOException { + String schemeIdUri = parseString(xpp, "schemeIdUri", null); + int audioChannels; + switch (schemeIdUri) { + case "urn:mpeg:dash:23003:3:audio_channel_configuration:2011": + audioChannels = parseInt(xpp, "value", Format.NO_VALUE); + break; + case "urn:mpeg:mpegB:cicp:ChannelConfiguration": + audioChannels = parseMpegChannelConfiguration(xpp); + break; + case "tag:dts.com,2014:dash:audio_channel_configuration:2012": + case "urn:dts:dash:audio_channel_configuration:2012": + audioChannels = parseDtsChannelConfiguration(xpp); + break; + case "tag:dts.com,2018:uhd:audio_channel_configuration": + audioChannels = parseDtsxChannelConfiguration(xpp); + break; + case "tag:dolby.com,2014:dash:audio_channel_configuration:2011": + case "urn:dolby:dash:audio_channel_configuration:2011": + audioChannels = parseDolbyChannelConfiguration(xpp); + break; + default: + audioChannels = Format.NO_VALUE; + break; + } + do { + xpp.next(); + } while (!XmlPullParserUtil.isEndTag(xpp, "AudioChannelConfiguration")); + return audioChannels; + } + + // Selection flag parsing. + protected @C.SelectionFlags int parseSelectionFlagsFromRoleDescriptors(List roleDescriptors) { + @C.SelectionFlags int result = 0; + for (int i = 0; i < roleDescriptors.size(); i++) { + Descriptor descriptor = roleDescriptors.get(i); + if (Ascii.equalsIgnoreCase("urn:mpeg:dash:role:2011", descriptor.schemeIdUri)) { + result |= parseSelectionFlagsFromDashRoleScheme(descriptor.value); + } + } + return result; + } + + protected String[] parseProfiles(XmlPullParser xpp, String attributeName, String[] defaultValue) { + @Nullable String attributeValue = xpp.getAttributeValue(/* namespace= */ null, attributeName); + if (attributeValue == null) { + return defaultValue; + } + return attributeValue.split(","); + } + + protected @C.SelectionFlags int parseSelectionFlagsFromDashRoleScheme(@Nullable String value) { + if (value == null) { + return 0; + } + switch (value) { + case "forced_subtitle": + // Support both hyphen and underscore (https://github.com/google/ExoPlayer/issues/9727). + case "forced-subtitle": + return C.SELECTION_FLAG_FORCED; + default: + return 0; + } + } + + // Role and Accessibility parsing. + protected @C.RoleFlags int parseRoleFlagsFromRoleDescriptors(List roleDescriptors) { + @C.RoleFlags int result = 0; + for (int i = 0; i < roleDescriptors.size(); i++) { + Descriptor descriptor = roleDescriptors.get(i); + if (Ascii.equalsIgnoreCase("urn:mpeg:dash:role:2011", descriptor.schemeIdUri)) { + result |= parseRoleFlagsFromDashRoleScheme(descriptor.value); + } + } + return result; + } + + protected @C.RoleFlags int parseRoleFlagsFromAccessibilityDescriptors( + List accessibilityDescriptors) { + @C.RoleFlags int result = 0; + for (int i = 0; i < accessibilityDescriptors.size(); i++) { + Descriptor descriptor = accessibilityDescriptors.get(i); + if (Ascii.equalsIgnoreCase("urn:mpeg:dash:role:2011", descriptor.schemeIdUri)) { + result |= parseRoleFlagsFromDashRoleScheme(descriptor.value); + } else if (Ascii.equalsIgnoreCase( + "urn:tva:metadata:cs:AudioPurposeCS:2007", descriptor.schemeIdUri)) { + result |= parseTvaAudioPurposeCsValue(descriptor.value); + } + } + return result; + } + + protected @C.RoleFlags int parseRoleFlagsFromProperties(List accessibilityDescriptors) { + @C.RoleFlags int result = 0; + for (int i = 0; i < accessibilityDescriptors.size(); i++) { + Descriptor descriptor = accessibilityDescriptors.get(i); + if (Ascii.equalsIgnoreCase( + "http://dashif.org/guidelines/trickmode", descriptor.schemeIdUri)) { + result |= C.ROLE_FLAG_TRICK_PLAY; + } + } + return result; + } + + protected @C.RoleFlags int parseRoleFlagsFromDashRoleScheme(@Nullable String value) { + if (value == null) { + return 0; + } + switch (value) { + case "main": + return C.ROLE_FLAG_MAIN; + case "alternate": + return C.ROLE_FLAG_ALTERNATE; + case "supplementary": + return C.ROLE_FLAG_SUPPLEMENTARY; + case "commentary": + return C.ROLE_FLAG_COMMENTARY; + case "dub": + return C.ROLE_FLAG_DUB; + case "emergency": + return C.ROLE_FLAG_EMERGENCY; + case "caption": + return C.ROLE_FLAG_CAPTION; + case "forced_subtitle": + // Support both hyphen and underscore (https://github.com/google/ExoPlayer/issues/9727). + case "forced-subtitle": + case "subtitle": + return C.ROLE_FLAG_SUBTITLE; + case "sign": + return C.ROLE_FLAG_SIGN; + case "description": + return C.ROLE_FLAG_DESCRIBES_VIDEO; + case "enhanced-audio-intelligibility": + return C.ROLE_FLAG_ENHANCED_DIALOG_INTELLIGIBILITY; + default: + return 0; + } + } + + protected @C.RoleFlags int parseTvaAudioPurposeCsValue(@Nullable String value) { + if (value == null) { + return 0; + } + switch (value) { + case "1": // Audio description for the visually impaired. + return C.ROLE_FLAG_DESCRIBES_VIDEO; + case "2": // Audio description for the hard of hearing. + return C.ROLE_FLAG_ENHANCED_DIALOG_INTELLIGIBILITY; + case "3": // Supplemental commentary. + return C.ROLE_FLAG_SUPPLEMENTARY; + case "4": // Director's commentary. + return C.ROLE_FLAG_COMMENTARY; + case "6": // Main programme audio. + return C.ROLE_FLAG_MAIN; + default: + return 0; + } + } + + // Utility methods. + + /** + * If the provided {@link XmlPullParser} is currently positioned at the start of a tag, skips + * forward to the end of that tag. + * + * @param xpp The {@link XmlPullParser}. + * @throws XmlPullParserException If an error occurs parsing the stream. + * @throws IOException If an error occurs reading the stream. + */ + public static void maybeSkipTag(XmlPullParser xpp) throws IOException, XmlPullParserException { + if (!XmlPullParserUtil.isStartTag(xpp)) { + return; + } + int depth = 1; + while (depth != 0) { + xpp.next(); + if (XmlPullParserUtil.isStartTag(xpp)) { + depth++; + } else if (XmlPullParserUtil.isEndTag(xpp)) { + depth--; + } + } + } + + /** Removes unnecessary {@link SchemeData}s with null {@link SchemeData#data}. */ + private static void filterRedundantIncompleteSchemeDatas(ArrayList schemeDatas) { + for (int i = schemeDatas.size() - 1; i >= 0; i--) { + SchemeData schemeData = schemeDatas.get(i); + if (!schemeData.hasData()) { + for (int j = 0; j < schemeDatas.size(); j++) { + if (schemeDatas.get(j).canReplace(schemeData)) { + // schemeData is incomplete, but there is another matching SchemeData which does contain + // data, so we remove the incomplete one. + schemeDatas.remove(i); + break; + } + } + } + } + } + + private static void fillInClearKeyInformation(ArrayList schemeDatas) { + // Find and remove ClearKey information. + @Nullable String clearKeyLicenseServerUrl = null; + for (int i = 0; i < schemeDatas.size(); i++) { + SchemeData schemeData = schemeDatas.get(i); + if (C.CLEARKEY_UUID.equals(schemeData.uuid) && schemeData.licenseServerUrl != null) { + clearKeyLicenseServerUrl = schemeData.licenseServerUrl; + schemeDatas.remove(i); + break; + } + } + if (clearKeyLicenseServerUrl == null) { + return; + } + // Fill in the ClearKey information into the existing PSSH schema data if applicable. + for (int i = 0; i < schemeDatas.size(); i++) { + SchemeData schemeData = schemeDatas.get(i); + if (C.COMMON_PSSH_UUID.equals(schemeData.uuid) && schemeData.licenseServerUrl == null) { + schemeDatas.set( + i, + new SchemeData( + C.CLEARKEY_UUID, clearKeyLicenseServerUrl, schemeData.mimeType, schemeData.data)); + } + } + } + + /** + * Derives a sample mimeType from a container mimeType and codecs attribute. + * + * @param containerMimeType The mimeType of the container. + * @param codecs The codecs attribute. + * @return The derived sample mimeType, or null if it could not be derived. + */ + @Nullable + private static String getSampleMimeType( + @Nullable String containerMimeType, @Nullable String codecs) { + if (MimeTypes.isAudio(containerMimeType)) { + return MimeTypes.getAudioMediaMimeType(codecs); + } else if (MimeTypes.isVideo(containerMimeType)) { + return MimeTypes.getVideoMediaMimeType(codecs); + } else if (MimeTypes.isText(containerMimeType)) { + // Text types are raw formats. + return containerMimeType; + } else if (MimeTypes.isImage(containerMimeType)) { + // Image types are raw formats. + return containerMimeType; + } else if (MimeTypes.APPLICATION_MP4.equals(containerMimeType)) { + @Nullable String mimeType = MimeTypes.getMediaMimeType(codecs); + return MimeTypes.TEXT_VTT.equals(mimeType) ? MimeTypes.APPLICATION_MP4VTT : mimeType; + } + return null; + } + + /** + * Checks two languages for consistency, returning the consistent language, or throwing an {@link + * IllegalStateException} if the languages are inconsistent. + * + *

Two languages are consistent if they are equal, or if one is null. + * + * @param firstLanguage The first language. + * @param secondLanguage The second language. + * @return The consistent language. + */ + @Nullable + private static String checkLanguageConsistency( + @Nullable String firstLanguage, @Nullable String secondLanguage) { + if (firstLanguage == null) { + return secondLanguage; + } else if (secondLanguage == null) { + return firstLanguage; + } else { + Assertions.checkState(firstLanguage.equals(secondLanguage)); + return firstLanguage; + } + } + + /** + * Checks two adaptation set content types for consistency, returning the consistent type, or + * throwing an {@link IllegalStateException} if the types are inconsistent. + * + *

Two types are consistent if they are equal, or if one is {@link C#TRACK_TYPE_UNKNOWN}. Where + * one of the types is {@link C#TRACK_TYPE_UNKNOWN}, the other is returned. + * + * @param firstType The first type. + * @param secondType The second type. + * @return The consistent type. + */ + private static int checkContentTypeConsistency( + @C.TrackType int firstType, @C.TrackType int secondType) { + if (firstType == C.TRACK_TYPE_UNKNOWN) { + return secondType; + } else if (secondType == C.TRACK_TYPE_UNKNOWN) { + return firstType; + } else { + Assertions.checkState(firstType == secondType); + return firstType; + } + } + + /** + * Parses a {@link Descriptor} from an element. + * + * @param xpp The parser from which to read. + * @param tag The tag of the element being parsed. + * @throws XmlPullParserException If an error occurs parsing the element. + * @throws IOException If an error occurs reading the element. + * @return The parsed {@link Descriptor}. + */ + protected static Descriptor parseDescriptor(XmlPullParser xpp, String tag) + throws XmlPullParserException, IOException { + String schemeIdUri = parseString(xpp, "schemeIdUri", ""); + String value = parseString(xpp, "value", null); + String id = parseString(xpp, "id", null); + do { + xpp.next(); + } while (!XmlPullParserUtil.isEndTag(xpp, tag)); + return new Descriptor(schemeIdUri, value, id); + } + + protected static int parseCea608AccessibilityChannel(List accessibilityDescriptors) { + for (int i = 0; i < accessibilityDescriptors.size(); i++) { + Descriptor descriptor = accessibilityDescriptors.get(i); + if ("urn:scte:dash:cc:cea-608:2015".equals(descriptor.schemeIdUri) + && descriptor.value != null) { + Matcher accessibilityValueMatcher = CEA_608_ACCESSIBILITY_PATTERN.matcher(descriptor.value); + if (accessibilityValueMatcher.matches()) { + return Integer.parseInt(accessibilityValueMatcher.group(1)); + } else { + Log.w(TAG, "Unable to parse CEA-608 channel number from: " + descriptor.value); + } + } + } + return Format.NO_VALUE; + } + + protected static int parseCea708AccessibilityChannel(List accessibilityDescriptors) { + for (int i = 0; i < accessibilityDescriptors.size(); i++) { + Descriptor descriptor = accessibilityDescriptors.get(i); + if ("urn:scte:dash:cc:cea-708:2015".equals(descriptor.schemeIdUri) + && descriptor.value != null) { + Matcher accessibilityValueMatcher = CEA_708_ACCESSIBILITY_PATTERN.matcher(descriptor.value); + if (accessibilityValueMatcher.matches()) { + return Integer.parseInt(accessibilityValueMatcher.group(1)); + } else { + Log.w(TAG, "Unable to parse CEA-708 service block number from: " + descriptor.value); + } + } + } + return Format.NO_VALUE; + } + + protected static String parseEac3SupplementalProperties(List supplementalProperties) { + for (int i = 0; i < supplementalProperties.size(); i++) { + Descriptor descriptor = supplementalProperties.get(i); + String schemeIdUri = descriptor.schemeIdUri; + if (("tag:dolby.com,2018:dash:EC3_ExtensionType:2018".equals(schemeIdUri) + && "JOC".equals(descriptor.value)) + || ("tag:dolby.com,2014:dash:DolbyDigitalPlusExtensionType:2014".equals(schemeIdUri) + && "ec+3".equals(descriptor.value))) { + return MimeTypes.AUDIO_E_AC3_JOC; + } + } + return MimeTypes.AUDIO_E_AC3; + } + + protected static float parseFrameRate(XmlPullParser xpp, float defaultValue) { + float frameRate = defaultValue; + String frameRateAttribute = xpp.getAttributeValue(null, "frameRate"); + if (frameRateAttribute != null) { + Matcher frameRateMatcher = FRAME_RATE_PATTERN.matcher(frameRateAttribute); + if (frameRateMatcher.matches()) { + int numerator = Integer.parseInt(frameRateMatcher.group(1)); + String denominatorString = frameRateMatcher.group(2); + if (!TextUtils.isEmpty(denominatorString)) { + frameRate = (float) numerator / Integer.parseInt(denominatorString); + } else { + frameRate = numerator; + } + } + } + return frameRate; + } + + protected static long parseDuration(XmlPullParser xpp, String name, long defaultValue) { + String value = xpp.getAttributeValue(null, name); + if (value == null) { + return defaultValue; + } else { + return Util.parseXsDuration(value); + } + } + + protected static long parseDateTime(XmlPullParser xpp, String name, long defaultValue) + throws ParserException { + String value = xpp.getAttributeValue(null, name); + if (value == null) { + return defaultValue; + } else { + return Util.parseXsDateTime(value); + } + } + + protected static String parseText(XmlPullParser xpp, String label) + throws XmlPullParserException, IOException { + String text = ""; + do { + xpp.next(); + if (xpp.getEventType() == XmlPullParser.TEXT) { + text = xpp.getText(); + } else { + maybeSkipTag(xpp); + } + } while (!XmlPullParserUtil.isEndTag(xpp, label)); + return text; + } + + private boolean isDvbProfileDeclared(String[] profiles) { + for (String profile : profiles) { + if (profile.startsWith("urn:dvb:dash:profile:dvb-dash:")) { + return true; + } + } + return false; + } + + protected static int parseInt(XmlPullParser xpp, String name, int defaultValue) { + String value = xpp.getAttributeValue(null, name); + return value == null ? defaultValue : Integer.parseInt(value); + } + + protected static long parseLong(XmlPullParser xpp, String name, long defaultValue) { + String value = xpp.getAttributeValue(null, name); + boolean isFloat = false; + if (value !=null && value.contains(".")) { + value = value.split("\\.")[0]; + isFloat = true; + } + long longValue = (value == null) ? defaultValue : Long.parseLong(value); + if (isFloat) { + return ++longValue; + } + return longValue; + } + + protected static float parseFloat(XmlPullParser xpp, String name, float defaultValue) { + String value = xpp.getAttributeValue(null, name); + return value == null ? defaultValue : Float.parseFloat(value); + } + + protected static String parseString(XmlPullParser xpp, String name, String defaultValue) { + String value = xpp.getAttributeValue(null, name); + return value == null ? defaultValue : value; + } + + /** + * Parses the number of channels from the value attribute of an AudioChannelConfiguration with + * schemeIdUri "urn:mpeg:mpegB:cicp:ChannelConfiguration", as defined by ISO 23001-8 clause 8.1. + * + * @param xpp The parser from which to read. + * @return The parsed number of channels, or {@link Format#NO_VALUE} if the channel count could + * not be parsed. + */ + protected static int parseMpegChannelConfiguration(XmlPullParser xpp) { + int index = parseInt(xpp, "value", C.INDEX_UNSET); + return 0 <= index && index < MPEG_CHANNEL_CONFIGURATION_MAPPING.length + ? MPEG_CHANNEL_CONFIGURATION_MAPPING[index] + : Format.NO_VALUE; + } + + + /** + * Parses the number of channels from the value attribute of an AudioChannelConfiguration with + * schemeIdUri "tag:dts.com,2014:dash:audio_channel_configuration:2012" as defined by Annex G + * (3.2) in ETSI TS 102 114 V1.6.1, or by the legacy schemeIdUri + * "urn:dts:dash:audio_channel_configuration:2012". + * + * @param xpp The parser from which to read. + * @return The parsed number of channels, or {@link Format#NO_VALUE} if the channel count could + * not be parsed. + */ + protected static int parseDtsChannelConfiguration(XmlPullParser xpp) { + int channelCount = parseInt(xpp, "value", Format.NO_VALUE); + return 0 < channelCount && channelCount < 33 ? channelCount : Format.NO_VALUE; + } + + /** + * Parses the number of channels from the value attribute of an AudioChannelConfiguration with + * schemeIdUri "tag:dts.com,2018:uhd:audio_channel_configuration" as defined by table B-5 in ETSI + * TS 103 491 v1.2.1. + * + * @param xpp The parser from which to read. + * @return The parsed number of channels, or {@link Format#NO_VALUE} if the channel count could + * not be parsed. + */ + protected static int parseDtsxChannelConfiguration(XmlPullParser xpp) { + @Nullable String value = xpp.getAttributeValue(null, "value"); + if (value == null) { + return Format.NO_VALUE; + } + int channelCount = Integer.bitCount(Integer.parseInt(value, /* radix= */ 16)); + return channelCount == 0 ? Format.NO_VALUE : channelCount; + } + + /** + * Parses the number of channels from the value attribute of an AudioChannelConfiguration with + * schemeIdUri "tag:dolby.com,2014:dash:audio_channel_configuration:2011" as defined by table E.5 + * in ETSI TS 102 366, or by the legacy schemeIdUri + * "urn:dolby:dash:audio_channel_configuration:2011". + * + * @param xpp The parser from which to read. + * @return The parsed number of channels, or {@link Format#NO_VALUE} if the channel count could + * not be parsed. + */ + protected static int parseDolbyChannelConfiguration(XmlPullParser xpp) { + @Nullable String value = xpp.getAttributeValue(null, "value"); + if (value == null) { + return Format.NO_VALUE; + } + switch (Ascii.toLowerCase(value)) { + case "4000": + return 1; + case "a000": + return 2; + case "f801": + return 6; + case "fa01": + return 8; + default: + return Format.NO_VALUE; + } + } + + protected static long parseLastSegmentNumberSupplementalProperty( + List supplementalProperties) { + for (int i = 0; i < supplementalProperties.size(); i++) { + Descriptor descriptor = supplementalProperties.get(i); + if (Ascii.equalsIgnoreCase( + "http://dashif.org/guidelines/last-segment-number", descriptor.schemeIdUri)) { + return Long.parseLong(descriptor.value); + } + } + return C.INDEX_UNSET; + } + + private static long getFinalAvailabilityTimeOffset( + long baseUrlAvailabilityTimeOffsetUs, long segmentBaseAvailabilityTimeOffsetUs) { + long availabilityTimeOffsetUs = segmentBaseAvailabilityTimeOffsetUs; + if (availabilityTimeOffsetUs == C.TIME_UNSET) { + // Fall back to BaseURL values if no SegmentBase specifies an offset. + availabilityTimeOffsetUs = baseUrlAvailabilityTimeOffsetUs; + } + if (availabilityTimeOffsetUs == Long.MAX_VALUE) { + // Replace INF value with TIME_UNSET to specify that all segments are available immediately. + availabilityTimeOffsetUs = C.TIME_UNSET; + } + return availabilityTimeOffsetUs; + } + + /** A parsed Representation element. */ + protected static final class RepresentationInfo { + + public final CustomFormat format; + public final ImmutableList baseUrls; + public final CustomSegmentBase segmentBase; + @Nullable public final String drmSchemeType; + public final ArrayList drmSchemeDatas; + public final ArrayList inbandEventStreams; + public final long revisionId; + public final List essentialProperties; + public final List supplementalProperties; + + public RepresentationInfo( + CustomFormat format, + List baseUrls, + CustomSegmentBase segmentBase, + @Nullable String drmSchemeType, + ArrayList drmSchemeDatas, + ArrayList inbandEventStreams, + List essentialProperties, + List supplementalProperties, + long revisionId) { + this.format = format; + this.baseUrls = ImmutableList.copyOf(baseUrls); + this.segmentBase = segmentBase; + this.drmSchemeType = drmSchemeType; + this.drmSchemeDatas = drmSchemeDatas; + this.inbandEventStreams = inbandEventStreams; + this.essentialProperties = essentialProperties; + this.supplementalProperties = supplementalProperties; + this.revisionId = revisionId; + } + } +} diff --git a/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/dashmanifestparser/CustomFormat.java b/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/dashmanifestparser/CustomFormat.java new file mode 100644 index 000000000..8e01d9991 --- /dev/null +++ b/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/dashmanifestparser/CustomFormat.java @@ -0,0 +1,2040 @@ +package com.kaltura.androidx.media3.exoplayer.dashmanifestparser; + +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; + +import com.google.common.base.Joiner; +import com.kaltura.androidx.media3.common.C; +import com.kaltura.androidx.media3.common.Format; +import com.kaltura.androidx.media3.common.DrmInitData; +import com.kaltura.androidx.media3.common.Metadata; +import com.kaltura.androidx.media3.common.MimeTypes; +import com.kaltura.androidx.media3.common.util.BundleCollectionUtil; +import com.kaltura.androidx.media3.common.util.Util; +import com.kaltura.androidx.media3.common.ColorInfo; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +/** + * Represents a media format. + * + *

When building formats, populate all fields whose values are known and relevant to the type of + * format being constructed. For information about different types of format, see ExoPlayer's Supported formats page. + * + *

Fields commonly relevant to all formats

+ * + *
    + *
  • {@link #id} + *
  • {@link #label} + *
  • {@link #language} + *
  • {@link #selectionFlags} + *
  • {@link #roleFlags} + *
  • {@link #averageBitrate} + *
  • {@link #peakBitrate} + *
  • {@link #codecs} + *
  • {@link #metadata} + *
+ * + *

Fields relevant to container formats

+ * + *
    + *
  • {@link #containerMimeType} + *
  • If the container only contains a single media track, fields + * relevant to sample formats can are also be relevant and can be set to describe the + * sample format of that track. + *
  • If the container only contains one track of a given type (possibly alongside tracks of + * other types), then fields relevant to that track type can be set to describe the properties + * of the track. See the sections below for video, audio and text formats. + *
+ * + *

Fields relevant to sample formats

+ * + *
    + *
  • {@link #sampleMimeType} + *
  • {@link #maxInputSize} + *
  • {@link #initializationData} + *
  • {@link #drmInitData} + *
  • {@link #subsampleOffsetUs} + *
  • Fields relevant to the sample format's track type are also relevant. See the sections below + * for video, audio and text formats. + *
+ * + *

Fields relevant to video formats

+ * + *
    + *
  • {@link #width} + *
  • {@link #height} + *
  • {@link #frameRate} + *
  • {@link #rotationDegrees} + *
  • {@link #pixelWidthHeightRatio} + *
  • {@link #projectionData} + *
  • {@link #stereoMode} + *
  • {@link #colorInfo} + *
+ * + *

Fields relevant to audio formats

+ * + *
    + *
  • {@link #channelCount} + *
  • {@link #sampleRate} + *
  • {@link #pcmEncoding} + *
  • {@link #encoderDelay} + *
  • {@link #encoderPadding} + *
+ * + *

Fields relevant to text formats

+ * + *
    + *
  • {@link #accessibilityChannel} + *
+ */ +public final class CustomFormat { + + /** + * Builds {@link CustomFormat} instances. + * + *

Use CustomFormat#buildUpon() to obtain a builder representing an existing {@link CustomFormat}. + * + *

When building formats, populate all fields whose values are known and relevant to the type + * of format being constructed. See the {@link CustomFormat} Javadoc for information about which fields + * should be set for different types of format. + */ + public static final class Builder { + + @Nullable + private String id; + @Nullable + private String label; + @Nullable + private String language; + private @C.SelectionFlags int selectionFlags; + private @C.RoleFlags int roleFlags; + private int averageBitrate; + private int peakBitrate; + @Nullable + private String codecs; + @Nullable + private Metadata metadata; + @Nullable + FormatThumbnailInfo formatThumbnailInfo; + // Container specific. + + @Nullable + private String containerMimeType; + + // Sample specific. + + @Nullable + private String sampleMimeType; + private int maxInputSize; + @Nullable + private List initializationData; + @Nullable + private DrmInitData drmInitData; + private long subsampleOffsetUs; + + // Video specific. + + private int width; + private int height; + private float frameRate; + private int rotationDegrees; + private float pixelWidthHeightRatio; + @Nullable + private byte[] projectionData; + private int stereoMode; + @Nullable + private ColorInfo colorInfo; + + // Audio specific. + + private int channelCount; + private int sampleRate; + private int pcmEncoding; + private int encoderDelay; + private int encoderPadding; + + // Text specific. + + private int accessibilityChannel; + + // Provided by the source. + + @C.CryptoType + private int cryptoType; + + /** + * Creates a new instance with default values. + */ + public Builder() { + averageBitrate = NO_VALUE; + peakBitrate = NO_VALUE; + // Sample specific. + maxInputSize = NO_VALUE; + subsampleOffsetUs = OFFSET_SAMPLE_RELATIVE; + // Video specific. + width = NO_VALUE; + height = NO_VALUE; + frameRate = NO_VALUE; + pixelWidthHeightRatio = 1.0f; + stereoMode = NO_VALUE; + // Audio specific. + channelCount = NO_VALUE; + sampleRate = NO_VALUE; + pcmEncoding = NO_VALUE; + // Text specific. + accessibilityChannel = NO_VALUE; + // Provided by the source. + cryptoType = C.CRYPTO_TYPE_NONE; + } + + /** + * Creates a new instance to build upon the provided {@link CustomFormat}. + * + * @param format The {@link CustomFormat} to build upon. + */ + private Builder(CustomFormat format) { + this.id = format.id; + this.label = format.label; + this.language = format.language; + this.selectionFlags = format.selectionFlags; + this.roleFlags = format.roleFlags; + this.averageBitrate = format.averageBitrate; + this.peakBitrate = format.peakBitrate; + this.codecs = format.codecs; + this.metadata = format.metadata; + // Container specific. + this.containerMimeType = format.containerMimeType; + // Sample specific. + this.sampleMimeType = format.sampleMimeType; + this.maxInputSize = format.maxInputSize; + this.initializationData = format.initializationData; + this.drmInitData = format.drmInitData; + this.subsampleOffsetUs = format.subsampleOffsetUs; + // Video specific. + this.width = format.width; + this.height = format.height; + this.frameRate = format.frameRate; + this.rotationDegrees = format.rotationDegrees; + this.pixelWidthHeightRatio = format.pixelWidthHeightRatio; + this.projectionData = format.projectionData; + this.stereoMode = format.stereoMode; + this.colorInfo = format.colorInfo; + // Audio specific. + this.channelCount = format.channelCount; + this.sampleRate = format.sampleRate; + this.pcmEncoding = format.pcmEncoding; + this.encoderDelay = format.encoderDelay; + this.encoderPadding = format.encoderPadding; + // Text specific. + this.accessibilityChannel = format.accessibilityChannel; + // Provided by the source. + this.cryptoType = format.cryptoType; + } + + /** + * Sets {@link CustomFormat#id}. The default value is {@code null}. + * + * @param id The {@link CustomFormat#id}. + * @return The builder. + */ + public Builder setId(@Nullable String id) { + this.id = id; + return this; + } + + public Builder setFormatThumbnailInfo(@Nullable FormatThumbnailInfo formatThumbnailInfo) { + this.formatThumbnailInfo = formatThumbnailInfo; + return this; + } + + /** + * Sets {@link CustomFormat#id} to {@link Integer#toString() Integer.toString(id)}. The default value + * is {@code null}. + * + * @param id The {@link CustomFormat#id}. + * @return The builder. + */ + public Builder setId(int id) { + this.id = Integer.toString(id); + return this; + } + + /** + * Sets {@link CustomFormat#label}. The default value is {@code null}. + * + * @param label The {@link CustomFormat#label}. + * @return The builder. + */ + public Builder setLabel(@Nullable String label) { + this.label = label; + return this; + } + + /** + * Sets {@link CustomFormat#language}. The default value is {@code null}. + * + * @param language The {@link CustomFormat#language}. + * @return The builder. + */ + public Builder setLanguage(@Nullable String language) { + this.language = language; + return this; + } + + /** + * Sets {@link CustomFormat#selectionFlags}. The default value is 0. + * + * @param selectionFlags The {@link CustomFormat#selectionFlags}. + * @return The builder. + */ + public Builder setSelectionFlags(@C.SelectionFlags int selectionFlags) { + this.selectionFlags = selectionFlags; + return this; + } + + /** + * Sets {@link CustomFormat#roleFlags}. The default value is 0. + * + * @param roleFlags The {@link CustomFormat#roleFlags}. + * @return The builder. + */ + public Builder setRoleFlags(@C.RoleFlags int roleFlags) { + this.roleFlags = roleFlags; + return this; + } + + /** + * Sets {@link CustomFormat#averageBitrate}. The default value is {@link #NO_VALUE}. + * + * @param averageBitrate The {@link CustomFormat#averageBitrate}. + * @return The builder. + */ + public Builder setAverageBitrate(int averageBitrate) { + this.averageBitrate = averageBitrate; + return this; + } + + /** + * Sets {@link CustomFormat#peakBitrate}. The default value is {@link #NO_VALUE}. + * + * @param peakBitrate The {@link CustomFormat#peakBitrate}. + * @return The builder. + */ + public Builder setPeakBitrate(int peakBitrate) { + this.peakBitrate = peakBitrate; + return this; + } + + /** + * Sets {@link CustomFormat#codecs}. The default value is {@code null}. + * + * @param codecs The {@link CustomFormat#codecs}. + * @return The builder. + */ + public Builder setCodecs(@Nullable String codecs) { + this.codecs = codecs; + return this; + } + + /** + * Sets {@link CustomFormat#metadata}. The default value is {@code null}. + * + * @param metadata The {@link CustomFormat#metadata}. + * @return The builder. + */ + public Builder setMetadata(@Nullable Metadata metadata) { + this.metadata = metadata; + return this; + } + + // Container specific. + + /** + * Sets {@link CustomFormat#containerMimeType}. The default value is {@code null}. + * + * @param containerMimeType The {@link CustomFormat#containerMimeType}. + * @return The builder. + */ + public Builder setContainerMimeType(@Nullable String containerMimeType) { + this.containerMimeType = containerMimeType; + return this; + } + + // Sample specific. + + /** + * Sets {@link CustomFormat#sampleMimeType}. The default value is {@code null}. + * + * @param sampleMimeType {@link CustomFormat#sampleMimeType}. + * @return The builder. + */ + public Builder setSampleMimeType(@Nullable String sampleMimeType) { + this.sampleMimeType = sampleMimeType; + return this; + } + + /** + * Sets {@link CustomFormat#maxInputSize}. The default value is {@link #NO_VALUE}. + * + * @param maxInputSize The {@link CustomFormat#maxInputSize}. + * @return The builder. + */ + public Builder setMaxInputSize(int maxInputSize) { + this.maxInputSize = maxInputSize; + return this; + } + + /** + * Sets {@link CustomFormat#initializationData}. The default value is {@code null}. + * + * @param initializationData The {@link CustomFormat#initializationData}. + * @return The builder. + */ + public Builder setInitializationData(@Nullable List initializationData) { + this.initializationData = initializationData; + return this; + } + + /** + * Sets {@link CustomFormat#drmInitData}. The default value is {@code null}. + * + * @param drmInitData The {@link CustomFormat#drmInitData}. + * @return The builder. + */ + public Builder setDrmInitData(@Nullable DrmInitData drmInitData) { + this.drmInitData = drmInitData; + return this; + } + + /** + * Sets {@link CustomFormat#subsampleOffsetUs}. The default value is {@link #OFFSET_SAMPLE_RELATIVE}. + * + * @param subsampleOffsetUs The {@link CustomFormat#subsampleOffsetUs}. + * @return The builder. + */ + public Builder setSubsampleOffsetUs(long subsampleOffsetUs) { + this.subsampleOffsetUs = subsampleOffsetUs; + return this; + } + + // Video specific. + + /** + * Sets {@link CustomFormat#width}. The default value is {@link #NO_VALUE}. + * + * @param width The {@link CustomFormat#width}. + * @return The builder. + */ + public Builder setWidth(int width) { + this.width = width; + return this; + } + + /** + * Sets {@link CustomFormat#height}. The default value is {@link #NO_VALUE}. + * + * @param height The {@link CustomFormat#height}. + * @return The builder. + */ + public Builder setHeight(int height) { + this.height = height; + return this; + } + + /** + * Sets {@link CustomFormat#frameRate}. The default value is {@link #NO_VALUE}. + * + * @param frameRate The {@link CustomFormat#frameRate}. + * @return The builder. + */ + public Builder setFrameRate(float frameRate) { + this.frameRate = frameRate; + return this; + } + + /** + * Sets {@link CustomFormat#rotationDegrees}. The default value is 0. + * + * @param rotationDegrees The {@link CustomFormat#rotationDegrees}. + * @return The builder. + */ + public Builder setRotationDegrees(int rotationDegrees) { + this.rotationDegrees = rotationDegrees; + return this; + } + + /** + * Sets {@link CustomFormat#pixelWidthHeightRatio}. The default value is 1.0f. + * + * @param pixelWidthHeightRatio The {@link CustomFormat#pixelWidthHeightRatio}. + * @return The builder. + */ + public Builder setPixelWidthHeightRatio(float pixelWidthHeightRatio) { + this.pixelWidthHeightRatio = pixelWidthHeightRatio; + return this; + } + + /** + * Sets {@link CustomFormat#projectionData}. The default value is {@code null}. + * + * @param projectionData The {@link CustomFormat#projectionData}. + * @return The builder. + */ + public Builder setProjectionData(@Nullable byte[] projectionData) { + this.projectionData = projectionData; + return this; + } + + /** + * Sets {@link CustomFormat#stereoMode}. The default value is {@link #NO_VALUE}. + * + * @param stereoMode The {@link CustomFormat#stereoMode}. + * @return The builder. + */ + public Builder setStereoMode(@C.StereoMode int stereoMode) { + this.stereoMode = stereoMode; + return this; + } + + /** + * Sets {@link CustomFormat#colorInfo}. The default value is {@code null}. + * + * @param colorInfo The {@link CustomFormat#colorInfo}. + * @return The builder. + */ + public Builder setColorInfo(@Nullable ColorInfo colorInfo) { + this.colorInfo = colorInfo; + return this; + } + + // Audio specific. + + /** + * Sets {@link CustomFormat#channelCount}. The default value is {@link #NO_VALUE}. + * + * @param channelCount The {@link CustomFormat#channelCount}. + * @return The builder. + */ + public Builder setChannelCount(int channelCount) { + this.channelCount = channelCount; + return this; + } + + /** + * Sets {@link CustomFormat#sampleRate}. The default value is {@link #NO_VALUE}. + * + * @param sampleRate The {@link CustomFormat#sampleRate}. + * @return The builder. + */ + public Builder setSampleRate(int sampleRate) { + this.sampleRate = sampleRate; + return this; + } + + /** + * Sets {@link CustomFormat#pcmEncoding}. The default value is {@link #NO_VALUE}. + * + * @param pcmEncoding The {@link CustomFormat#pcmEncoding}. + * @return The builder. + */ + public Builder setPcmEncoding(@C.PcmEncoding int pcmEncoding) { + this.pcmEncoding = pcmEncoding; + return this; + } + + /** + * Sets {@link CustomFormat#encoderDelay}. The default value is 0. + * + * @param encoderDelay The {@link CustomFormat#encoderDelay}. + * @return The builder. + */ + public Builder setEncoderDelay(int encoderDelay) { + this.encoderDelay = encoderDelay; + return this; + } + + /** + * Sets {@link CustomFormat#encoderPadding}. The default value is 0. + * + * @param encoderPadding The {@link CustomFormat#encoderPadding}. + * @return The builder. + */ + public Builder setEncoderPadding(int encoderPadding) { + this.encoderPadding = encoderPadding; + return this; + } + + // Text specific. + + /** + * Sets {@link CustomFormat#accessibilityChannel}. The default value is {@link #NO_VALUE}. + * + * @param accessibilityChannel The {@link CustomFormat#accessibilityChannel}. + * @return The builder. + */ + public Builder setAccessibilityChannel(int accessibilityChannel) { + this.accessibilityChannel = accessibilityChannel; + return this; + } + + // Provided by source. + + /** + * Sets {@link Format#cryptoType}. The default value is {@link C#CRYPTO_TYPE_NONE}. + * + * @param cryptoType The {@link C.CryptoType}. + * @return The builder. + */ + public Builder setCryptoType(@C.CryptoType int cryptoType) { + this.cryptoType = cryptoType; + return this; + } + + // Build. + + public CustomFormat build() { + return new CustomFormat(/* builder= */ this); + } + } + + /** + * A value for various fields to indicate that the field's value is unknown or not applicable. + */ + public static final int NO_VALUE = -1; + + /** + * A value for {@link #subsampleOffsetUs} to indicate that subsample timestamps are relative to + * the timestamps of their parent samples. + */ + public static final long OFFSET_SAMPLE_RELATIVE = Long.MAX_VALUE; + + private static final CustomFormat DEFAULT = new Builder().build(); + + /** + * An identifier for the format, or null if unknown or not applicable. + */ + @Nullable + public final String id; + /** + * The human readable label, or null if unknown or not applicable. + */ + @Nullable + public final String label; + /** + * The language as an IETF BCP 47 conformant tag, or null if unknown or not applicable. + */ + @Nullable + public final String language; + /** + * Track selection flags. + */ + public final @C.SelectionFlags int selectionFlags; + /** + * Track role flags. + */ + public final @C.RoleFlags int roleFlags; + /** + * The average bitrate in bits per second, or {@link #NO_VALUE} if unknown or not applicable. The + * way in which this field is populated depends on the type of media to which the format + * corresponds: + * + *

    + *
  • DASH representations: Always {@link CustomFormat#NO_VALUE}. + *
  • HLS variants: The {@code AVERAGE-BANDWIDTH} attribute defined on the corresponding {@code + * EXT-X-STREAM-INF} tag in the master playlist, or {@link CustomFormat#NO_VALUE} if not present. + *
  • SmoothStreaming track elements: The {@code Bitrate} attribute defined on the + * corresponding {@code TrackElement} in the manifest, or {@link CustomFormat#NO_VALUE} if not + * present. + *
  • Progressive container formats: Often {@link CustomFormat#NO_VALUE}, but may be populated with + * the average bitrate of the container if known. + *
  • Sample formats: Often {@link CustomFormat#NO_VALUE}, but may be populated with the average + * bitrate of the stream of samples with type {@link #sampleMimeType} if known. Note that if + * {@link #sampleMimeType} is a compressed format (e.g., {@link MimeTypes#AUDIO_AAC}), then + * this bitrate is for the stream of still compressed samples. + *
+ */ + public final int averageBitrate; + /** + * The peak bitrate in bits per second, or {@link #NO_VALUE} if unknown or not applicable. The way + * in which this field is populated depends on the type of media to which the format corresponds: + * + *
    + *
  • DASH representations: The {@code @bandwidth} attribute of the corresponding {@code + * Representation} element in the manifest. + *
  • HLS variants: The {@code BANDWIDTH} attribute defined on the corresponding {@code + * EXT-X-STREAM-INF} tag. + *
  • SmoothStreaming track elements: Always {@link CustomFormat#NO_VALUE}. + *
  • Progressive container formats: Often {@link CustomFormat#NO_VALUE}, but may be populated with + * the peak bitrate of the container if known. + *
  • Sample formats: Often {@link CustomFormat#NO_VALUE}, but may be populated with the peak bitrate + * of the stream of samples with type {@link #sampleMimeType} if known. Note that if {@link + * #sampleMimeType} is a compressed format (e.g., {@link MimeTypes#AUDIO_AAC}), then this + * bitrate is for the stream of still compressed samples. + *
+ */ + public final int peakBitrate; + /** + * The bitrate in bits per second. This is the peak bitrate if known, or else the average bitrate + * if known, or else {@link CustomFormat#NO_VALUE}. Equivalent to: {@code peakBitrate != NO_VALUE ? + * peakBitrate : averageBitrate}. + */ + public final int bitrate; + /** + * Codecs of the format as described in RFC 6381, or null if unknown or not applicable. + */ + @Nullable + public final String codecs; + /** + * Metadata, or null if unknown or not applicable. + */ + @Nullable + public final Metadata metadata; + @Nullable + public final FormatThumbnailInfo formatThumbnailInfo; + // Container specific. + + /** + * The mime type of the container, or null if unknown or not applicable. + */ + @Nullable + public final String containerMimeType; + + // Sample specific. + + /** + * The sample mime type, or null if unknown or not applicable. + */ + @Nullable + public final String sampleMimeType; + /** + * The maximum size of a buffer of data (typically one sample), or {@link #NO_VALUE} if unknown or + * not applicable. + */ + public final int maxInputSize; + /** + * Initialization data that must be provided to the decoder. Will not be null, but may be empty + * if initialization data is not required. + */ + public final List initializationData; + /** + * DRM initialization data if the stream is protected, or null otherwise. + */ + @Nullable + public final DrmInitData drmInitData; + + /** + * For samples that contain subsamples, this is an offset that should be added to subsample + * timestamps. A value of {@link #OFFSET_SAMPLE_RELATIVE} indicates that subsample timestamps are + * relative to the timestamps of their parent samples. + */ + public final long subsampleOffsetUs; + + // Video specific. + + /** + * The width of the video in pixels, or {@link #NO_VALUE} if unknown or not applicable. + */ + public final int width; + /** + * The height of the video in pixels, or {@link #NO_VALUE} if unknown or not applicable. + */ + public final int height; + /** + * The frame rate in frames per second, or {@link #NO_VALUE} if unknown or not applicable. + */ + public final float frameRate; + /** + * The clockwise rotation that should be applied to the video for it to be rendered in the correct + * orientation, or 0 if unknown or not applicable. Only 0, 90, 180 and 270 are supported. + */ + public final int rotationDegrees; + /** + * The width to height ratio of pixels in the video, or 1.0 if unknown or not applicable. + */ + public final float pixelWidthHeightRatio; + /** + * The projection data for 360/VR video, or null if not applicable. + */ + @Nullable + public final byte[] projectionData; + /** + * The stereo layout for 360/3D/VR video, or {@link #NO_VALUE} if not applicable. Valid stereo + * modes are {@link C#STEREO_MODE_MONO}, {@link C#STEREO_MODE_TOP_BOTTOM}, {@link + * C#STEREO_MODE_LEFT_RIGHT}, {@link C#STEREO_MODE_STEREO_MESH}. + */ + @C.StereoMode + public final int stereoMode; + /** + * The color metadata associated with the video, or null if not applicable. + */ + @Nullable + public final ColorInfo colorInfo; + + // Audio specific. + + /** + * The number of audio channels, or {@link #NO_VALUE} if unknown or not applicable. + */ + public final int channelCount; + /** + * The audio sampling rate in Hz, or {@link #NO_VALUE} if unknown or not applicable. + */ + public final int sampleRate; + /** + * The {@link C.PcmEncoding} for PCM audio. Set to {@link #NO_VALUE} for other media types. + */ + @C.PcmEncoding + public final int pcmEncoding; + /** + * The number of frames to trim from the start of the decoded audio stream, or 0 if not + * applicable. + */ + public final int encoderDelay; + /** + * The number of frames to trim from the end of the decoded audio stream, or 0 if not applicable. + */ + public final int encoderPadding; + + // Text specific. + + /** + * The Accessibility channel, or {@link #NO_VALUE} if not known or applicable. + */ + public final int accessibilityChannel; + + // Provided by source. + + /** + * The type of crypto that must be used to decode samples associated with this format, or {@link + * C#CRYPTO_TYPE_NONE} if the content is not encrypted. Cannot be {@link C#CRYPTO_TYPE_NONE} if + * {@link #drmInitData} is non-null, but may be {@link C#CRYPTO_TYPE_UNSUPPORTED} to indicate that + * the samples are encrypted using an unsupported crypto type. + */ + @C.CryptoType + public final int cryptoType; + + // Lazily initialized hashcode. + private int hashCode; + + // Video. + + /** + * @deprecated Use {@link CustomFormat.Builder}. + */ + @Deprecated + public static CustomFormat createVideoContainerFormat( + @Nullable String id, + @Nullable String label, + @Nullable String containerMimeType, + @Nullable String sampleMimeType, + @Nullable String codecs, + @Nullable Metadata metadata, + @Nullable FormatThumbnailInfo formatThumbnailInfo, + int bitrate, + int width, + int height, + float frameRate, + @Nullable List initializationData, + @C.SelectionFlags int selectionFlags, + @C.RoleFlags int roleFlags) { + return new Builder() + .setId(id) + .setLabel(label) + .setSelectionFlags(selectionFlags) + .setRoleFlags(roleFlags) + .setAverageBitrate(bitrate) + .setPeakBitrate(bitrate) + .setCodecs(codecs) + .setMetadata(metadata) + .setFormatThumbnailInfo(formatThumbnailInfo) + .setContainerMimeType(containerMimeType) + .setSampleMimeType(sampleMimeType) + .setInitializationData(initializationData) + .setWidth(width) + .setHeight(height) + .setFrameRate(frameRate) + .build(); + } + + /** + * @deprecated Use {@link CustomFormat.Builder}. + */ + @Deprecated + public static CustomFormat createVideoSampleFormat( + @Nullable String id, + @Nullable String sampleMimeType, + @Nullable String codecs, + int bitrate, + int maxInputSize, + int width, + int height, + float frameRate, + @Nullable List initializationData, + @Nullable DrmInitData drmInitData) { + return new Builder() + .setId(id) + .setAverageBitrate(bitrate) + .setPeakBitrate(bitrate) + .setCodecs(codecs) + .setSampleMimeType(sampleMimeType) + .setMaxInputSize(maxInputSize) + .setInitializationData(initializationData) + .setDrmInitData(drmInitData) + .setWidth(width) + .setHeight(height) + .setFrameRate(frameRate) + .build(); + } + + /** + * @deprecated Use {@link CustomFormat.Builder}. + */ + @Deprecated + public static CustomFormat createVideoSampleFormat( + @Nullable String id, + @Nullable String sampleMimeType, + @Nullable String codecs, + int bitrate, + int maxInputSize, + int width, + int height, + float frameRate, + @Nullable List initializationData, + int rotationDegrees, + float pixelWidthHeightRatio, + @Nullable DrmInitData drmInitData) { + return new Builder() + .setId(id) + .setAverageBitrate(bitrate) + .setPeakBitrate(bitrate) + .setCodecs(codecs) + .setSampleMimeType(sampleMimeType) + .setMaxInputSize(maxInputSize) + .setInitializationData(initializationData) + .setDrmInitData(drmInitData) + .setWidth(width) + .setHeight(height) + .setFrameRate(frameRate) + .setRotationDegrees(rotationDegrees) + .setPixelWidthHeightRatio(pixelWidthHeightRatio) + .build(); + } + + /** + * @deprecated Use {@link CustomFormat.Builder}. + */ + @Deprecated + public static CustomFormat createVideoSampleFormat( + @Nullable String id, + @Nullable String sampleMimeType, + @Nullable String codecs, + int bitrate, + int maxInputSize, + int width, + int height, + float frameRate, + @Nullable List initializationData, + int rotationDegrees, + float pixelWidthHeightRatio, + @Nullable byte[] projectionData, + @C.StereoMode int stereoMode, + @Nullable ColorInfo colorInfo, + @Nullable DrmInitData drmInitData) { + return new Builder() + .setId(id) + .setAverageBitrate(bitrate) + .setPeakBitrate(bitrate) + .setCodecs(codecs) + .setSampleMimeType(sampleMimeType) + .setMaxInputSize(maxInputSize) + .setInitializationData(initializationData) + .setDrmInitData(drmInitData) + .setWidth(width) + .setHeight(height) + .setFrameRate(frameRate) + .setRotationDegrees(rotationDegrees) + .setPixelWidthHeightRatio(pixelWidthHeightRatio) + .setProjectionData(projectionData) + .setStereoMode(stereoMode) + .setColorInfo(colorInfo) + .build(); + } + + // Audio. + + /** + * @deprecated Use {@link CustomFormat.Builder}. + */ + @Deprecated + public static CustomFormat createAudioContainerFormat( + @Nullable String id, + @Nullable String label, + @Nullable String containerMimeType, + @Nullable String sampleMimeType, + @Nullable String codecs, + @Nullable Metadata metadata, + int bitrate, + int channelCount, + int sampleRate, + @Nullable List initializationData, + @C.SelectionFlags int selectionFlags, + @C.RoleFlags int roleFlags, + @Nullable String language) { + return new Builder() + .setId(id) + .setLabel(label) + .setLanguage(language) + .setSelectionFlags(selectionFlags) + .setRoleFlags(roleFlags) + .setAverageBitrate(bitrate) + .setPeakBitrate(bitrate) + .setCodecs(codecs) + .setMetadata(metadata) + .setContainerMimeType(containerMimeType) + .setSampleMimeType(sampleMimeType) + .setInitializationData(initializationData) + .setChannelCount(channelCount) + .setSampleRate(sampleRate) + .build(); + } + + /** + * @deprecated Use {@link CustomFormat.Builder}. + */ + @Deprecated + public static CustomFormat createAudioSampleFormat( + @Nullable String id, + @Nullable String sampleMimeType, + @Nullable String codecs, + int bitrate, + int maxInputSize, + int channelCount, + int sampleRate, + @Nullable List initializationData, + @Nullable DrmInitData drmInitData, + @C.SelectionFlags int selectionFlags, + @Nullable String language) { + return new Builder() + .setId(id) + .setLanguage(language) + .setSelectionFlags(selectionFlags) + .setAverageBitrate(bitrate) + .setPeakBitrate(bitrate) + .setCodecs(codecs) + .setSampleMimeType(sampleMimeType) + .setMaxInputSize(maxInputSize) + .setInitializationData(initializationData) + .setDrmInitData(drmInitData) + .setChannelCount(channelCount) + .setSampleRate(sampleRate) + .build(); + } + + /** + * @deprecated Use {@link CustomFormat.Builder}. + */ + @Deprecated + public static CustomFormat createAudioSampleFormat( + @Nullable String id, + @Nullable String sampleMimeType, + @Nullable String codecs, + int bitrate, + int maxInputSize, + int channelCount, + int sampleRate, + @C.PcmEncoding int pcmEncoding, + @Nullable List initializationData, + @Nullable DrmInitData drmInitData, + @C.SelectionFlags int selectionFlags, + @Nullable String language) { + return new Builder() + .setId(id) + .setLanguage(language) + .setSelectionFlags(selectionFlags) + .setAverageBitrate(bitrate) + .setPeakBitrate(bitrate) + .setCodecs(codecs) + .setSampleMimeType(sampleMimeType) + .setMaxInputSize(maxInputSize) + .setInitializationData(initializationData) + .setDrmInitData(drmInitData) + .setChannelCount(channelCount) + .setSampleRate(sampleRate) + .setPcmEncoding(pcmEncoding) + .build(); + } + + /** + * @deprecated Use {@link CustomFormat.Builder}. + */ + @Deprecated + public static CustomFormat createAudioSampleFormat( + @Nullable String id, + @Nullable String sampleMimeType, + @Nullable String codecs, + int bitrate, + int maxInputSize, + int channelCount, + int sampleRate, + @C.PcmEncoding int pcmEncoding, + int encoderDelay, + int encoderPadding, + @Nullable List initializationData, + @Nullable DrmInitData drmInitData, + @C.SelectionFlags int selectionFlags, + @Nullable String language, + @Nullable Metadata metadata) { + return new Builder() + .setId(id) + .setLanguage(language) + .setSelectionFlags(selectionFlags) + .setAverageBitrate(bitrate) + .setPeakBitrate(bitrate) + .setCodecs(codecs) + .setMetadata(metadata) + .setSampleMimeType(sampleMimeType) + .setMaxInputSize(maxInputSize) + .setInitializationData(initializationData) + .setDrmInitData(drmInitData) + .setChannelCount(channelCount) + .setSampleRate(sampleRate) + .setPcmEncoding(pcmEncoding) + .setEncoderDelay(encoderDelay) + .setEncoderPadding(encoderPadding) + .build(); + } + + // Text. + + /** + * @deprecated Use {@link CustomFormat.Builder}. + */ + @Deprecated + public static CustomFormat createTextContainerFormat( + @Nullable String id, + @Nullable String label, + @Nullable String containerMimeType, + @Nullable String sampleMimeType, + @Nullable String codecs, + int bitrate, + @C.SelectionFlags int selectionFlags, + @C.RoleFlags int roleFlags, + @Nullable String language) { + return new Builder() + .setId(id) + .setLabel(label) + .setLanguage(language) + .setSelectionFlags(selectionFlags) + .setRoleFlags(roleFlags) + .setAverageBitrate(bitrate) + .setPeakBitrate(bitrate) + .setCodecs(codecs) + .setContainerMimeType(containerMimeType) + .setSampleMimeType(sampleMimeType) + .build(); + } + + /** + * @deprecated Use {@link CustomFormat.Builder}. + */ + @Deprecated + public static CustomFormat createTextContainerFormat( + @Nullable String id, + @Nullable String label, + @Nullable String containerMimeType, + @Nullable String sampleMimeType, + @Nullable String codecs, + int bitrate, + @C.SelectionFlags int selectionFlags, + @C.RoleFlags int roleFlags, + @Nullable String language, + int accessibilityChannel) { + return new Builder() + .setId(id) + .setLabel(label) + .setLanguage(language) + .setSelectionFlags(selectionFlags) + .setRoleFlags(roleFlags) + .setAverageBitrate(bitrate) + .setPeakBitrate(bitrate) + .setCodecs(codecs) + .setContainerMimeType(containerMimeType) + .setSampleMimeType(sampleMimeType) + .setAccessibilityChannel(accessibilityChannel) + .build(); + } + + /** + * @deprecated Use {@link CustomFormat.Builder}. + */ + @Deprecated + public static CustomFormat createTextSampleFormat( + @Nullable String id, + @Nullable String sampleMimeType, + @C.SelectionFlags int selectionFlags, + @Nullable String language) { + return new Builder() + .setId(id) + .setLanguage(language) + .setSelectionFlags(selectionFlags) + .setSampleMimeType(sampleMimeType) + .build(); + } + + /** + * @deprecated Use {@link CustomFormat.Builder}. + */ + @Deprecated + public static CustomFormat createTextSampleFormat( + @Nullable String id, + @Nullable String sampleMimeType, + @C.SelectionFlags int selectionFlags, + @Nullable String language, + int accessibilityChannel, + long subsampleOffsetUs, + @Nullable List initializationData) { + return new Builder() + .setId(id) + .setLanguage(language) + .setSelectionFlags(selectionFlags) + .setSampleMimeType(sampleMimeType) + .setInitializationData(initializationData) + .setSubsampleOffsetUs(subsampleOffsetUs) + .setAccessibilityChannel(accessibilityChannel) + .build(); + } + + // Image. + + /** + * @deprecated Use {@link CustomFormat.Builder}. + */ + @Deprecated + public static CustomFormat createImageSampleFormat( + @Nullable String id, + @Nullable String sampleMimeType, + @C.SelectionFlags int selectionFlags, + @Nullable List initializationData, + @Nullable String language) { + return new Builder() + .setId(id) + .setLanguage(language) + .setSelectionFlags(selectionFlags) + .setSampleMimeType(sampleMimeType) + .setInitializationData(initializationData) + .build(); + } + + // Generic. + + /** + * @deprecated Use {@link CustomFormat.Builder}. + */ + @Deprecated + public static CustomFormat createContainerFormat( + @Nullable String id, + @Nullable String label, + @Nullable String containerMimeType, + @Nullable String sampleMimeType, + @Nullable String codecs, + int bitrate, + @C.SelectionFlags int selectionFlags, + @C.RoleFlags int roleFlags, + @Nullable String language) { + return new Builder() + .setId(id) + .setLabel(label) + .setLanguage(language) + .setSelectionFlags(selectionFlags) + .setRoleFlags(roleFlags) + .setAverageBitrate(bitrate) + .setPeakBitrate(bitrate) + .setCodecs(codecs) + .setContainerMimeType(containerMimeType) + .setSampleMimeType(sampleMimeType) + .build(); + } + + /** + * @deprecated Use {@link CustomFormat.Builder}. + */ + @Deprecated + public static CustomFormat createSampleFormat(@Nullable String id, @Nullable String sampleMimeType) { + return new Builder().setId(id).setSampleMimeType(sampleMimeType).build(); + } + + private CustomFormat(Builder builder) { + id = builder.id; + label = builder.label; + language = Util.normalizeLanguageCode(builder.language); + selectionFlags = builder.selectionFlags; + roleFlags = builder.roleFlags; + averageBitrate = builder.averageBitrate; + peakBitrate = builder.peakBitrate; + bitrate = peakBitrate != NO_VALUE ? peakBitrate : averageBitrate; + codecs = builder.codecs; + metadata = builder.metadata; + formatThumbnailInfo = builder.formatThumbnailInfo; + // Container specific. + containerMimeType = builder.containerMimeType; + // Sample specific. + sampleMimeType = builder.sampleMimeType; + maxInputSize = builder.maxInputSize; + initializationData = + builder.initializationData == null ? Collections.emptyList() : builder.initializationData; + drmInitData = builder.drmInitData; + subsampleOffsetUs = builder.subsampleOffsetUs; + // Video specific. + width = builder.width; + height = builder.height; + frameRate = builder.frameRate; + rotationDegrees = builder.rotationDegrees == NO_VALUE ? 0 : builder.rotationDegrees; + pixelWidthHeightRatio = + builder.pixelWidthHeightRatio == NO_VALUE ? 1 : builder.pixelWidthHeightRatio; + projectionData = builder.projectionData; + stereoMode = builder.stereoMode; + colorInfo = builder.colorInfo; + // Audio specific. + channelCount = builder.channelCount; + sampleRate = builder.sampleRate; + pcmEncoding = builder.pcmEncoding; + encoderDelay = builder.encoderDelay == NO_VALUE ? 0 : builder.encoderDelay; + encoderPadding = builder.encoderPadding == NO_VALUE ? 0 : builder.encoderPadding; + // Text specific. + accessibilityChannel = builder.accessibilityChannel; + // Provided by source. + if (builder.cryptoType == C.CRYPTO_TYPE_NONE && drmInitData != null) { + // Encrypted content cannot use CRYPTO_TYPE_NONE. + cryptoType = C.CRYPTO_TYPE_UNSUPPORTED; + } else { + cryptoType = builder.cryptoType; + } + } + + /** + * Returns a {@link CustomFormat.Builder} initialized with the values of this instance. + */ + public Builder buildUpon() { + return new Builder(this); + } + + /** + * @deprecated Use {@link #buildUpon()} and {@link Builder#setMaxInputSize(int)}. + */ + @Deprecated + public CustomFormat copyWithMaxInputSize(int maxInputSize) { + return buildUpon().setMaxInputSize(maxInputSize).build(); + } + + /** + * @deprecated Use {@link #buildUpon()} and {@link Builder#setSubsampleOffsetUs(long)}. + */ + @Deprecated + public CustomFormat copyWithSubsampleOffsetUs(long subsampleOffsetUs) { + return buildUpon().setSubsampleOffsetUs(subsampleOffsetUs).build(); + } + + /** + * @deprecated Use {@link #buildUpon()} and {@link Builder#setLabel(String)} . + */ + @Deprecated + public CustomFormat copyWithLabel(@Nullable String label) { + return buildUpon().setLabel(label).build(); + } + + /** + * @deprecated Use {@link #withManifestFormatInfo(CustomFormat)}. + */ + @Deprecated + public CustomFormat copyWithManifestFormatInfo(CustomFormat manifestFormat) { + return withManifestFormatInfo(manifestFormat); + } + + @SuppressWarnings("ReferenceEquality") + public CustomFormat withManifestFormatInfo(CustomFormat manifestFormat) { + if (this == manifestFormat) { + // No need to copy from ourselves. + return this; + } + + @C.TrackType int trackType = MimeTypes.getTrackType(sampleMimeType); + + // Use manifest value only. + @Nullable String id = manifestFormat.id; + + // Prefer manifest values, but fill in from sample format if missing. + @Nullable String label = manifestFormat.label != null ? manifestFormat.label : this.label; + @Nullable String language = this.language; + if ((trackType == C.TRACK_TYPE_TEXT || trackType == C.TRACK_TYPE_AUDIO) + && manifestFormat.language != null) { + language = manifestFormat.language; + } + + // Prefer sample format values, but fill in from manifest if missing. + int averageBitrate = + this.averageBitrate == NO_VALUE ? manifestFormat.averageBitrate : this.averageBitrate; + int peakBitrate = this.peakBitrate == NO_VALUE ? manifestFormat.peakBitrate : this.peakBitrate; + @Nullable String codecs = this.codecs; + if (codecs == null) { + // The manifest format may be muxed, so filter only codecs of this format's type. If we still + // have more than one codec then we're unable to uniquely identify which codec to fill in. + @Nullable String codecsOfType = Util.getCodecsOfType(manifestFormat.codecs, trackType); + if (Util.splitCodecs(codecsOfType).length == 1) { + codecs = codecsOfType; + } + } + + @Nullable + Metadata metadata = + this.metadata == null + ? manifestFormat.metadata + : this.metadata.copyWithAppendedEntriesFrom(manifestFormat.metadata); + + float frameRate = this.frameRate; + if (frameRate == NO_VALUE && trackType == C.TRACK_TYPE_VIDEO) { + frameRate = manifestFormat.frameRate; + } + + // Merge manifest and sample format values. + @C.SelectionFlags int selectionFlags = this.selectionFlags | manifestFormat.selectionFlags; + @C.RoleFlags int roleFlags = this.roleFlags | manifestFormat.roleFlags; + @Nullable + DrmInitData drmInitData = + DrmInitData.createSessionCreationData(manifestFormat.drmInitData, this.drmInitData); + + return buildUpon() + .setId(id) + .setLabel(label) + .setLanguage(language) + .setSelectionFlags(selectionFlags) + .setRoleFlags(roleFlags) + .setAverageBitrate(averageBitrate) + .setPeakBitrate(peakBitrate) + .setCodecs(codecs) + .setMetadata(metadata) + .setDrmInitData(drmInitData) + .setFrameRate(frameRate) + .build(); + } + + /** + * @deprecated Use {@link #buildUpon()}, {@link Builder#setEncoderDelay(int)} and {@link + * Builder#setEncoderPadding(int)}. + */ + @Deprecated + public CustomFormat copyWithGaplessInfo(int encoderDelay, int encoderPadding) { + return buildUpon().setEncoderDelay(encoderDelay).setEncoderPadding(encoderPadding).build(); + } + + /** + * @deprecated Use {@link #buildUpon()} and {@link Builder#setFrameRate(float)}. + */ + @Deprecated + public CustomFormat copyWithFrameRate(float frameRate) { + return buildUpon().setFrameRate(frameRate).build(); + } + + /** + * @deprecated Use {@link #buildUpon()} and {@link Builder#setDrmInitData(DrmInitData)}. + */ + @Deprecated + public CustomFormat copyWithDrmInitData(@Nullable DrmInitData drmInitData) { + return buildUpon().setDrmInitData(drmInitData).build(); + } + + /** + * @deprecated Use {@link #buildUpon()} and {@link Builder#setMetadata(Metadata)}. + */ + @Deprecated + public CustomFormat copyWithMetadata(@Nullable Metadata metadata) { + return buildUpon().setMetadata(metadata).build(); + } + + /** + * @deprecated Use {@link #buildUpon()} and {@link Builder#setAverageBitrate(int)} and {@link + * Builder#setPeakBitrate(int)}. + */ + @Deprecated + public CustomFormat copyWithBitrate(int bitrate) { + return buildUpon().setAverageBitrate(bitrate).setPeakBitrate(bitrate).build(); + } + + /** + * @deprecated Use {@link #buildUpon()}, {@link Builder#setWidth(int)} and {@link + * Builder#setHeight(int)}. + */ + @Deprecated + public CustomFormat copyWithVideoSize(int width, int height) { + return buildUpon().setWidth(width).setHeight(height).build(); + } + + /** + * Returns a copy of this format with the specified {@link #cryptoType}. + */ + public CustomFormat copyWithCryptoType(@C.CryptoType int cryptoType) { + return buildUpon().setCryptoType(cryptoType).build(); + } + + /** + * Returns the number of pixels if this is a video format whose {@link #width} and {@link #height} + * are known, or {@link #NO_VALUE} otherwise + */ + public int getPixelCount() { + return width == NO_VALUE || height == NO_VALUE ? NO_VALUE : (width * height); + } + + @Override + public String toString() { + return "Format(" + + id + + ", " + + label + + ", " + + containerMimeType + + ", " + + sampleMimeType + + ", " + + codecs + + ", " + + bitrate + + ", " + + language + + ", [" + + width + + ", " + + height + + ", " + + frameRate + + "]" + + ", [" + + channelCount + + ", " + + sampleRate + + "])"; + } + + @Override + public int hashCode() { + if (hashCode == 0) { + // Some fields for which hashing is expensive are deliberately omitted. + int result = 17; + result = 31 * result + (id == null ? 0 : id.hashCode()); + result = 31 * result + (label != null ? label.hashCode() : 0); + result = 31 * result + (language == null ? 0 : language.hashCode()); + result = 31 * result + selectionFlags; + result = 31 * result + roleFlags; + result = 31 * result + averageBitrate; + result = 31 * result + peakBitrate; + result = 31 * result + (codecs == null ? 0 : codecs.hashCode()); + result = 31 * result + (metadata == null ? 0 : metadata.hashCode()); + // Container specific. + result = 31 * result + (containerMimeType == null ? 0 : containerMimeType.hashCode()); + // Sample specific. + result = 31 * result + (sampleMimeType == null ? 0 : sampleMimeType.hashCode()); + result = 31 * result + maxInputSize; + // [Omitted] initializationData. + // [Omitted] drmInitData. + result = 31 * result + (int) subsampleOffsetUs; + // Video specific. + result = 31 * result + width; + result = 31 * result + height; + result = 31 * result + Float.floatToIntBits(frameRate); + result = 31 * result + rotationDegrees; + result = 31 * result + Float.floatToIntBits(pixelWidthHeightRatio); + // [Omitted] projectionData. + result = 31 * result + stereoMode; + // [Omitted] colorInfo. + // Audio specific. + result = 31 * result + channelCount; + result = 31 * result + sampleRate; + result = 31 * result + pcmEncoding; + result = 31 * result + encoderDelay; + result = 31 * result + encoderPadding; + // Text specific. + result = 31 * result + accessibilityChannel; + // Provided by the source. + result = 31 * result + cryptoType; + hashCode = result; + } + return hashCode; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + CustomFormat other = (CustomFormat) obj; + if (hashCode != 0 && other.hashCode != 0 && hashCode != other.hashCode) { + return false; + } + // Field equality checks ordered by type, with the cheapest checks first. + return selectionFlags == other.selectionFlags + && roleFlags == other.roleFlags + && averageBitrate == other.averageBitrate + && peakBitrate == other.peakBitrate + && maxInputSize == other.maxInputSize + && subsampleOffsetUs == other.subsampleOffsetUs + && width == other.width + && height == other.height + && rotationDegrees == other.rotationDegrees + && stereoMode == other.stereoMode + && channelCount == other.channelCount + && sampleRate == other.sampleRate + && pcmEncoding == other.pcmEncoding + && encoderDelay == other.encoderDelay + && encoderPadding == other.encoderPadding + && accessibilityChannel == other.accessibilityChannel + && cryptoType == other.cryptoType + && Float.compare(frameRate, other.frameRate) == 0 + && Float.compare(pixelWidthHeightRatio, other.pixelWidthHeightRatio) == 0 + && Util.areEqual(id, other.id) + && Util.areEqual(label, other.label) + && Util.areEqual(codecs, other.codecs) + && Util.areEqual(containerMimeType, other.containerMimeType) + && Util.areEqual(sampleMimeType, other.sampleMimeType) + && Util.areEqual(language, other.language) + && Arrays.equals(projectionData, other.projectionData) + && Util.areEqual(metadata, other.metadata) + && Util.areEqual(colorInfo, other.colorInfo) + && Util.areEqual(drmInitData, other.drmInitData) + && initializationDataEquals(other); + } + + /** + * Returns whether the {@link #initializationData}s belonging to this format and {@code other} are + * equal. + * + * @param other The other format whose {@link #initializationData} is being compared. + * @return Whether the {@link #initializationData}s belonging to this format and {@code other} are + * equal. + */ + public boolean initializationDataEquals(CustomFormat other) { + if (initializationData.size() != other.initializationData.size()) { + return false; + } + for (int i = 0; i < initializationData.size(); i++) { + if (!Arrays.equals(initializationData.get(i), other.initializationData.get(i))) { + return false; + } + } + return true; + } + + // Utility methods + + /** + * Returns a prettier {@link String} than {@link #toString()}, intended for logging. + */ + public static String toLogString(@Nullable CustomFormat format) { + if (format == null) { + return "null"; + } + StringBuilder builder = new StringBuilder(); + builder.append("id=").append(format.id).append(", mimeType=").append(format.sampleMimeType); + if (format.bitrate != NO_VALUE) { + builder.append(", bitrate=").append(format.bitrate); + } + if (format.codecs != null) { + builder.append(", codecs=").append(format.codecs); + } + if (format.drmInitData != null) { + Set schemes = new LinkedHashSet<>(); + for (int i = 0; i < format.drmInitData.schemeDataCount; i++) { + UUID schemeUuid = format.drmInitData.get(i).uuid; + if (schemeUuid.equals(C.COMMON_PSSH_UUID)) { + schemes.add("cenc"); + } else if (schemeUuid.equals(C.CLEARKEY_UUID)) { + schemes.add("clearkey"); + } else if (schemeUuid.equals(C.PLAYREADY_UUID)) { + schemes.add("playready"); + } else if (schemeUuid.equals(C.WIDEVINE_UUID)) { + schemes.add("widevine"); + } else if (schemeUuid.equals(C.UUID_NIL)) { + schemes.add("universal"); + } else { + schemes.add("unknown (" + schemeUuid + ")"); + } + } + builder.append(", drm=[").append(Joiner.on(',').join(schemes)).append(']'); + } + if (format.width != NO_VALUE && format.height != NO_VALUE) { + builder.append(", res=").append(format.width).append("x").append(format.height); + } + if (format.frameRate != NO_VALUE) { + builder.append(", fps=").append(format.frameRate); + } + if (format.channelCount != NO_VALUE) { + builder.append(", channels=").append(format.channelCount); + } + if (format.sampleRate != NO_VALUE) { + builder.append(", sample_rate=").append(format.sampleRate); + } + if (format.language != null) { + builder.append(", language=").append(format.language); + } + if (format.label != null) { + builder.append(", label=").append(format.label); + } + if ((format.roleFlags & C.ROLE_FLAG_TRICK_PLAY) != 0) { + builder.append(", trick-play-track"); + } + return builder.toString(); + } + + // Bundleable implementation. + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + FIELD_ID, + FIELD_LABEL, + FIELD_LANGUAGE, + FIELD_SELECTION_FLAGS, + FIELD_ROLE_FLAGS, + FIELD_AVERAGE_BITRATE, + FIELD_PEAK_BITRATE, + FIELD_CODECS, + FIELD_METADATA, + FIELD_CONTAINER_MIME_TYPE, + FIELD_SAMPLE_MIME_TYPE, + FIELD_MAX_INPUT_SIZE, + FIELD_INITIALIZATION_DATA, + FIELD_DRM_INIT_DATA, + FIELD_SUBSAMPLE_OFFSET_US, + FIELD_WIDTH, + FIELD_HEIGHT, + FIELD_FRAME_RATE, + FIELD_ROTATION_DEGREES, + FIELD_PIXEL_WIDTH_HEIGHT_RATIO, + FIELD_PROJECTION_DATA, + FIELD_STEREO_MODE, + FIELD_COLOR_INFO, + FIELD_CHANNEL_COUNT, + FIELD_SAMPLE_RATE, + FIELD_PCM_ENCODING, + FIELD_ENCODER_DELAY, + FIELD_ENCODER_PADDING, + FIELD_ACCESSIBILITY_CHANNEL, + FIELD_CRYPTO_TYPE, + }) + private @interface FieldNumber { + } + + private static final int FIELD_ID = 0; + private static final int FIELD_LABEL = 1; + private static final int FIELD_LANGUAGE = 2; + private static final int FIELD_SELECTION_FLAGS = 3; + private static final int FIELD_ROLE_FLAGS = 4; + private static final int FIELD_AVERAGE_BITRATE = 5; + private static final int FIELD_PEAK_BITRATE = 6; + private static final int FIELD_CODECS = 7; + private static final int FIELD_METADATA = 8; + private static final int FIELD_CONTAINER_MIME_TYPE = 9; + private static final int FIELD_SAMPLE_MIME_TYPE = 10; + private static final int FIELD_MAX_INPUT_SIZE = 11; + private static final int FIELD_INITIALIZATION_DATA = 12; + private static final int FIELD_DRM_INIT_DATA = 13; + private static final int FIELD_SUBSAMPLE_OFFSET_US = 14; + private static final int FIELD_WIDTH = 15; + private static final int FIELD_HEIGHT = 16; + private static final int FIELD_FRAME_RATE = 17; + private static final int FIELD_ROTATION_DEGREES = 18; + private static final int FIELD_PIXEL_WIDTH_HEIGHT_RATIO = 19; + private static final int FIELD_PROJECTION_DATA = 20; + private static final int FIELD_STEREO_MODE = 21; + private static final int FIELD_COLOR_INFO = 22; + private static final int FIELD_CHANNEL_COUNT = 23; + private static final int FIELD_SAMPLE_RATE = 24; + private static final int FIELD_PCM_ENCODING = 25; + private static final int FIELD_ENCODER_DELAY = 26; + private static final int FIELD_ENCODER_PADDING = 27; + private static final int FIELD_ACCESSIBILITY_CHANNEL = 28; + private static final int FIELD_CRYPTO_TYPE = 29; + + public Bundle toBundle() { + Bundle bundle = new Bundle(); + bundle.putString(keyForField(FIELD_ID), id); + bundle.putString(keyForField(FIELD_LABEL), label); + bundle.putString(keyForField(FIELD_LANGUAGE), language); + bundle.putInt(keyForField(FIELD_SELECTION_FLAGS), selectionFlags); + bundle.putInt(keyForField(FIELD_ROLE_FLAGS), roleFlags); + bundle.putInt(keyForField(FIELD_AVERAGE_BITRATE), averageBitrate); + bundle.putInt(keyForField(FIELD_PEAK_BITRATE), peakBitrate); + bundle.putString(keyForField(FIELD_CODECS), codecs); + // Metadata is currently not Bundleable because Metadata.Entry is an Interface, + // which would be difficult to unbundle in a backward compatible way. + // The entries are additionally of limited usefulness to remote processes. + bundle.putParcelable(keyForField(FIELD_METADATA), metadata); + // Container specific. + bundle.putString(keyForField(FIELD_CONTAINER_MIME_TYPE), containerMimeType); + // Sample specific. + bundle.putString(keyForField(FIELD_SAMPLE_MIME_TYPE), sampleMimeType); + bundle.putInt(keyForField(FIELD_MAX_INPUT_SIZE), maxInputSize); + for (int i = 0; i < initializationData.size(); i++) { + bundle.putByteArray(keyForInitializationData(i), initializationData.get(i)); + } + // DrmInitData doesn't need to be Bundleable as it's only used in the playing process to + // initialize the decoder. + bundle.putParcelable(keyForField(FIELD_DRM_INIT_DATA), drmInitData); + bundle.putLong(keyForField(FIELD_SUBSAMPLE_OFFSET_US), subsampleOffsetUs); + // Video specific. + bundle.putInt(keyForField(FIELD_WIDTH), width); + bundle.putInt(keyForField(FIELD_HEIGHT), height); + bundle.putFloat(keyForField(FIELD_FRAME_RATE), frameRate); + bundle.putInt(keyForField(FIELD_ROTATION_DEGREES), rotationDegrees); + bundle.putFloat(keyForField(FIELD_PIXEL_WIDTH_HEIGHT_RATIO), pixelWidthHeightRatio); + bundle.putByteArray(keyForField(FIELD_PROJECTION_DATA), projectionData); + bundle.putInt(keyForField(FIELD_STEREO_MODE), stereoMode); + if (colorInfo != null) { + bundle.putBundle(keyForField(FIELD_COLOR_INFO), colorInfo.toBundle()); + } + // Audio specific. + bundle.putInt(keyForField(FIELD_CHANNEL_COUNT), channelCount); + bundle.putInt(keyForField(FIELD_SAMPLE_RATE), sampleRate); + bundle.putInt(keyForField(FIELD_PCM_ENCODING), pcmEncoding); + bundle.putInt(keyForField(FIELD_ENCODER_DELAY), encoderDelay); + bundle.putInt(keyForField(FIELD_ENCODER_PADDING), encoderPadding); + // Text specific. + bundle.putInt(keyForField(FIELD_ACCESSIBILITY_CHANNEL), accessibilityChannel); + // Source specific. + bundle.putInt(keyForField(FIELD_CRYPTO_TYPE), cryptoType); + return bundle; + } + + private static CustomFormat fromBundle(Bundle bundle) { + Builder builder = new Builder(); + BundleCollectionUtil.ensureClassLoader(bundle); + builder + .setId(defaultIfNull(bundle.getString(keyForField(FIELD_ID)), DEFAULT.id)) + .setLabel(defaultIfNull(bundle.getString(keyForField(FIELD_LABEL)), DEFAULT.label)) + .setLanguage(defaultIfNull(bundle.getString(keyForField(FIELD_LANGUAGE)), DEFAULT.language)) + .setSelectionFlags( + bundle.getInt(keyForField(FIELD_SELECTION_FLAGS), DEFAULT.selectionFlags)) + .setRoleFlags(bundle.getInt(keyForField(FIELD_ROLE_FLAGS), DEFAULT.roleFlags)) + .setAverageBitrate( + bundle.getInt(keyForField(FIELD_AVERAGE_BITRATE), DEFAULT.averageBitrate)) + .setPeakBitrate(bundle.getInt(keyForField(FIELD_PEAK_BITRATE), DEFAULT.peakBitrate)) + .setCodecs(defaultIfNull(bundle.getString(keyForField(FIELD_CODECS)), DEFAULT.codecs)) + .setMetadata( + defaultIfNull(bundle.getParcelable(keyForField(FIELD_METADATA)), DEFAULT.metadata)) + // Container specific. + .setContainerMimeType( + defaultIfNull( + bundle.getString(keyForField(FIELD_CONTAINER_MIME_TYPE)), + DEFAULT.containerMimeType)) + // Sample specific. + .setSampleMimeType( + defaultIfNull( + bundle.getString(keyForField(FIELD_SAMPLE_MIME_TYPE)), DEFAULT.sampleMimeType)) + .setMaxInputSize(bundle.getInt(keyForField(FIELD_MAX_INPUT_SIZE), DEFAULT.maxInputSize)); + + List initializationData = new ArrayList<>(); + for (int i = 0; ; i++) { + @Nullable byte[] data = bundle.getByteArray(keyForInitializationData(i)); + if (data == null) { + break; + } + initializationData.add(data); + } + builder + .setInitializationData(initializationData) + .setDrmInitData(bundle.getParcelable(keyForField(FIELD_DRM_INIT_DATA))) + .setSubsampleOffsetUs( + bundle.getLong(keyForField(FIELD_SUBSAMPLE_OFFSET_US), DEFAULT.subsampleOffsetUs)) + // Video specific. + .setWidth(bundle.getInt(keyForField(FIELD_WIDTH), DEFAULT.width)) + .setHeight(bundle.getInt(keyForField(FIELD_HEIGHT), DEFAULT.height)) + .setFrameRate(bundle.getFloat(keyForField(FIELD_FRAME_RATE), DEFAULT.frameRate)) + .setRotationDegrees( + bundle.getInt(keyForField(FIELD_ROTATION_DEGREES), DEFAULT.rotationDegrees)) + .setPixelWidthHeightRatio( + bundle.getFloat( + keyForField(FIELD_PIXEL_WIDTH_HEIGHT_RATIO), DEFAULT.pixelWidthHeightRatio)) + .setProjectionData(bundle.getByteArray(keyForField(FIELD_PROJECTION_DATA))) + .setStereoMode(bundle.getInt(keyForField(FIELD_STEREO_MODE), DEFAULT.stereoMode)); + + Bundle colorInfoBundle = bundle.getBundle(keyForField(FIELD_COLOR_INFO)); + if (colorInfoBundle != null) { + builder.setColorInfo(ColorInfo.fromBundle(colorInfoBundle)); + } + + // Audio specific. + builder + .setChannelCount(bundle.getInt(keyForField(FIELD_CHANNEL_COUNT), DEFAULT.channelCount)) + .setSampleRate(bundle.getInt(keyForField(FIELD_SAMPLE_RATE), DEFAULT.sampleRate)) + .setPcmEncoding(bundle.getInt(keyForField(FIELD_PCM_ENCODING), DEFAULT.pcmEncoding)) + .setEncoderDelay(bundle.getInt(keyForField(FIELD_ENCODER_DELAY), DEFAULT.encoderDelay)) + .setEncoderPadding( + bundle.getInt(keyForField(FIELD_ENCODER_PADDING), DEFAULT.encoderPadding)) + // Text specific. + .setAccessibilityChannel( + bundle.getInt(keyForField(FIELD_ACCESSIBILITY_CHANNEL), DEFAULT.accessibilityChannel)) + // Source specific. + .setCryptoType(bundle.getInt(keyForField(FIELD_CRYPTO_TYPE), DEFAULT.cryptoType)); + + return builder.build(); + } + + private static String keyForField(@FieldNumber int field) { + return Integer.toString(field, Character.MAX_RADIX); + } + + private static String keyForInitializationData(int initialisationDataIndex) { + return keyForField(FIELD_INITIALIZATION_DATA) + + "_" + + Integer.toString(initialisationDataIndex, Character.MAX_RADIX); + } + + @Nullable + private static T defaultIfNull(@Nullable T value, @Nullable T defaultValue) { + return value != null ? value : defaultValue; + } + + public static class FormatThumbnailInfo implements Parcelable { + public String structure; + public int tilesHorizontal; + public int tilesVertical; + public long presentationTimeOffset; + public long timeScale; + public long startNumber; + public long endNumber; + public String imageTemplateUrl; + public long segmentDuration; + + FormatThumbnailInfo(Parcel in) { + structure = in.readString(); + tilesHorizontal = in.readInt(); + tilesVertical = in.readInt(); + presentationTimeOffset = in.readLong(); + timeScale = in.readLong(); + startNumber = in.readLong(); + endNumber = in.readLong(); + imageTemplateUrl = in.readString(); + segmentDuration = in.readLong(); + } + + private FormatThumbnailInfo(Builder builder) { + structure = builder.structure; + tilesHorizontal = builder.tilesHorizontal; + tilesVertical = builder.tilesVertical; + presentationTimeOffset = builder.presentationTimeOffset; + timeScale = builder.timeScale; + startNumber = builder.startNumber; + endNumber = builder.endNumber; + imageTemplateUrl = builder.imageTemplateUrl; + segmentDuration = builder.segmentDuration; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(structure); + dest.writeInt(tilesHorizontal); + dest.writeInt(tilesVertical); + dest.writeLong(presentationTimeOffset); + dest.writeLong(timeScale); + dest.writeLong(startNumber); + dest.writeLong(endNumber); + dest.writeString(imageTemplateUrl); + dest.writeLong(segmentDuration); + } + + @Override + public int describeContents() { + return 0; + } + + public static final Creator CREATOR = new Creator() { + @Override + public FormatThumbnailInfo createFromParcel(Parcel in) { + return new FormatThumbnailInfo(in); + } + + @Override + public FormatThumbnailInfo[] newArray(int size) { + return new FormatThumbnailInfo[size]; + } + }; + + public static class Builder { + + private String structure; + private int tilesHorizontal; + private int tilesVertical; + private long presentationTimeOffset; + private long timeScale; + private long startNumber; + private long endNumber; + private String imageTemplateUrl; + private long segmentDuration; + + public FormatThumbnailInfo.Builder setStructure(String structure) { + this.structure = structure; + return this; + } + + public FormatThumbnailInfo.Builder setTilesHorizontal(int tilesHorizontal) { + this.tilesHorizontal = tilesHorizontal; + return this; + } + + public FormatThumbnailInfo.Builder setTilesVertical(int tilesVertical) { + this.tilesVertical = tilesVertical; + return this; + } + + public FormatThumbnailInfo.Builder setPresentationTimeOffset(long presentationTimeOffset) { + this.presentationTimeOffset = presentationTimeOffset; + return this; + } + + public FormatThumbnailInfo.Builder setTimeScale(long timeScale) { + this.timeScale = timeScale; + return this; + } + + public FormatThumbnailInfo.Builder setStartNumber(long startNumber) { + this.startNumber = startNumber; + return this; + } + + public FormatThumbnailInfo.Builder setEndNumber(long endNumber) { + this.endNumber = endNumber; + return this; + } + + public FormatThumbnailInfo.Builder setImageTemplateUrl(String imageTemplateUrl) { + this.imageTemplateUrl = imageTemplateUrl; + return this; + } + + public FormatThumbnailInfo.Builder setSegmentDuration(long segmentDuration) { + this.segmentDuration = segmentDuration; + return this; + } + + public FormatThumbnailInfo build() { + return new FormatThumbnailInfo(this); + } + } + } +} diff --git a/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/dashmanifestparser/CustomPeriod.java b/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/dashmanifestparser/CustomPeriod.java new file mode 100644 index 000000000..95aef936b --- /dev/null +++ b/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/dashmanifestparser/CustomPeriod.java @@ -0,0 +1,99 @@ +package com.kaltura.androidx.media3.exoplayer.dashmanifestparser; + +import androidx.annotation.Nullable; +import com.kaltura.androidx.media3.common.C; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.Descriptor; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.EventStream; + +import java.util.Collections; +import java.util.List; + +/** + * Encapsulates media content components over a contiguous period of time. + */ +public class CustomPeriod { + + /** + * The period identifier, if one exists. + */ + @Nullable public final String id; + + /** + * The start time of the period in milliseconds. + */ + public final long startMs; + + /** + * The adaptation sets belonging to the period. + */ + public final List adaptationSets; + + /** + * The event stream belonging to the period. + */ + public final List eventStreams; + + /** The asset identifier for this period, if one exists */ + @Nullable public final Descriptor assetIdentifier; + + /** + * @param id The period identifier. May be null. + * @param startMs The start time of the period in milliseconds. + * @param adaptationSets The adaptation sets belonging to the period. + */ + public CustomPeriod(@Nullable String id, long startMs, List adaptationSets) { + this(id, startMs, adaptationSets, Collections.emptyList(), /* assetIdentifier= */ null); + } + + /** + * @param id The period identifier. May be null. + * @param startMs The start time of the period in milliseconds. + * @param adaptationSets The adaptation sets belonging to the period. + * @param eventStreams The {@link EventStream}s belonging to the period. + */ + public CustomPeriod( + @Nullable String id, + long startMs, + List adaptationSets, + List eventStreams) { + this(id, startMs, adaptationSets, eventStreams, /* assetIdentifier= */ null); + } + + /** + * @param id The period identifier. May be null. + * @param startMs The start time of the period in milliseconds. + * @param adaptationSets The adaptation sets belonging to the period. + * @param eventStreams The {@link EventStream}s belonging to the period. + * @param assetIdentifier The asset identifier for this period + */ + public CustomPeriod( + @Nullable String id, + long startMs, + List adaptationSets, + List eventStreams, + @Nullable Descriptor assetIdentifier) { + this.id = id; + this.startMs = startMs; + this.adaptationSets = Collections.unmodifiableList(adaptationSets); + this.eventStreams = Collections.unmodifiableList(eventStreams); + this.assetIdentifier = assetIdentifier; + } + + /** + * Returns the index of the first adaptation set of a given type, or {@link C#INDEX_UNSET} if no + * adaptation set of the specified type exists. + * + * @param type An adaptation set type. + * @return The index of the first adaptation set of the specified type, or {@link C#INDEX_UNSET}. + */ + public int getAdaptationSetIndex(int type) { + int adaptationCount = adaptationSets.size(); + for (int i = 0; i < adaptationCount; i++) { + if (adaptationSets.get(i).type == type) { + return i; + } + } + return C.INDEX_UNSET; + } + +} diff --git a/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/dashmanifestparser/CustomRepresentation.java b/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/dashmanifestparser/CustomRepresentation.java new file mode 100644 index 000000000..1ae653a7d --- /dev/null +++ b/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/dashmanifestparser/CustomRepresentation.java @@ -0,0 +1,381 @@ +package com.kaltura.androidx.media3.exoplayer.dashmanifestparser; + +import android.net.Uri; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import com.google.common.collect.ImmutableList; +import com.kaltura.androidx.media3.common.C; +import com.kaltura.androidx.media3.exoplayer.dash.DashSegmentIndex; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.BaseUrl; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.Descriptor; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.RangedUri; + +import java.util.Collections; +import java.util.List; + +import static com.kaltura.androidx.media3.common.util.Assertions.checkArgument; + +/** + * A DASH representation. + */ +public abstract class CustomRepresentation { + + /** A default value for {@link #revisionId}. */ + public static final long REVISION_ID_DEFAULT = -1; + + /** + * Identifies the revision of the media contained within the representation. If the media can + * change over time (e.g. as a result of it being re-encoded), then this identifier can be set to + * uniquely identify the revision of the media. The timestamp at which the media was encoded is + * often a suitable. + */ + public final long revisionId; + /** The format of the representation. */ + public final CustomFormat format; + /** The base URLs of the representation. */ + public final ImmutableList baseUrls; + /** The offset of the presentation timestamps in the media stream relative to media time. */ + public final long presentationTimeOffsetUs; + /** The in-band event streams in the representation. May be empty. */ + public final List inbandEventStreams; + /** Essential properties in the representation. May be empty. */ + public final List essentialProperties; + /** Supplemental properties in the adaptation set. May be empty. */ + public final List supplementalProperties; + + private final RangedUri initializationUri; + + /** + * Constructs a new instance. + * + * @param revisionId Identifies the revision of the content. + * @param format The format of the representation. + * @param baseUrls The list of base URLs of the representation. + * @param segmentBase A segment base element for the representation. + * @return The constructed instance. + */ + public static CustomRepresentation newInstance( + long revisionId, CustomFormat format, List baseUrls, CustomSegmentBase segmentBase) { + return newInstance( + revisionId, + format, + baseUrls, + segmentBase, + /* inbandEventStreams= */ null, + /* essentialProperties= */ ImmutableList.of(), + /* supplementalProperties= */ ImmutableList.of(), + /* cacheKey= */ null); + } + + /** + * Constructs a new instance. + * + * @param revisionId Identifies the revision of the content. + * @param format The format of the representation. + * @param baseUrls The list of base URLs of the representation. + * @param segmentBase A segment base element for the representation. + * @param inbandEventStreams The in-band event streams in the representation. May be null. + * @param essentialProperties Essential properties in the representation. May be empty. + * @param supplementalProperties Supplemental properties in the representation. May be empty. + * @param cacheKey An optional key to be returned from {@link #getCacheKey()}, or null. This + * parameter is ignored if {@code segmentBase} consists of multiple segments. + * @return The constructed instance. + */ + public static CustomRepresentation newInstance( + long revisionId, + CustomFormat format, + List baseUrls, + CustomSegmentBase segmentBase, + @Nullable List inbandEventStreams, + List essentialProperties, + List supplementalProperties, + @Nullable String cacheKey) { + if (segmentBase instanceof CustomSegmentBase.SingleSegmentBase) { + return new SingleSegmentRepresentation( + revisionId, + format, + baseUrls, + (CustomSegmentBase.SingleSegmentBase) segmentBase, + inbandEventStreams, + essentialProperties, + supplementalProperties, + cacheKey, + /* contentLength= */ C.LENGTH_UNSET); + } else if (segmentBase instanceof CustomSegmentBase.MultiSegmentBase) { + return new MultiSegmentRepresentation( + revisionId, + format, + baseUrls, + (CustomSegmentBase.MultiSegmentBase) segmentBase, + inbandEventStreams, + essentialProperties, + supplementalProperties); + } else { + throw new IllegalArgumentException( + "segmentBase must be of type SingleSegmentBase or " + "MultiSegmentBase"); + } + } + + private CustomRepresentation( + long revisionId, + CustomFormat format, + List baseUrls, + CustomSegmentBase segmentBase, + @Nullable List inbandEventStreams, + List essentialProperties, + List supplementalProperties) { + checkArgument(!baseUrls.isEmpty()); + this.revisionId = revisionId; + this.format = format; + this.baseUrls = ImmutableList.copyOf(baseUrls); + this.inbandEventStreams = + inbandEventStreams == null + ? Collections.emptyList() + : Collections.unmodifiableList(inbandEventStreams); + this.essentialProperties = essentialProperties; + this.supplementalProperties = supplementalProperties; + initializationUri = segmentBase.getInitialization(this); + presentationTimeOffsetUs = segmentBase.getPresentationTimeOffsetUs(); + } + + /** + * Returns a {@link RangedUri} defining the location of the representation's initialization data, + * or null if no initialization data exists. + */ + @Nullable + public RangedUri getInitializationUri() { + return initializationUri; + } + + /** + * Returns a {@link RangedUri} defining the location of the representation's segment index, or + * null if the representation provides an index directly. + */ + @Nullable + public abstract RangedUri getIndexUri(); + + /** Returns an index if the representation provides one directly, or null otherwise. */ + @Nullable + public abstract DashSegmentIndex getIndex(); + + /** Returns a cache key for the representation if set, or null. */ + @Nullable + public abstract String getCacheKey(); + + /** A DASH representation consisting of a single segment. */ + public static class SingleSegmentRepresentation extends CustomRepresentation { + + /** The uri of the single segment. */ + public final Uri uri; + /** The content length, or {@link C#LENGTH_UNSET} if unknown. */ + public final long contentLength; + + @Nullable private final String cacheKey; + @Nullable private final RangedUri indexUri; + @Nullable private final CustomSingleSegmentIndex segmentIndex; + + /** + * @param revisionId Identifies the revision of the content. + * @param format The format of the representation. + * @param uri The uri of the media. + * @param initializationStart The offset of the first byte of initialization data. + * @param initializationEnd The offset of the last byte of initialization data. + * @param indexStart The offset of the first byte of index data. + * @param indexEnd The offset of the last byte of index data. + * @param inbandEventStreams The in-band event streams in the representation. May be null. + * @param cacheKey An optional key to be returned from {@link #getCacheKey()}, or null. + * @param contentLength The content length, or {@link C#LENGTH_UNSET} if unknown. + */ + public static SingleSegmentRepresentation newInstance( + long revisionId, + CustomFormat format, + String uri, + long initializationStart, + long initializationEnd, + long indexStart, + long indexEnd, + List inbandEventStreams, + @Nullable String cacheKey, + long contentLength) { + RangedUri rangedUri = + new RangedUri(null, initializationStart, initializationEnd - initializationStart + 1); + CustomSegmentBase.SingleSegmentBase segmentBase = + new CustomSegmentBase.SingleSegmentBase(rangedUri, 1, 0, indexStart, indexEnd - indexStart + 1); + ImmutableList baseUrls = ImmutableList.of(new BaseUrl(uri)); + return new SingleSegmentRepresentation( + revisionId, + format, + baseUrls, + segmentBase, + inbandEventStreams, + /* essentialProperties= */ ImmutableList.of(), + /* supplementalProperties= */ ImmutableList.of(), + cacheKey, + contentLength); + } + + /** + * @param revisionId Identifies the revision of the content. + * @param format The format of the representation. + * @param baseUrls The base urls of the representation. + * @param segmentBase The segment base underlying the representation. + * @param inbandEventStreams The in-band event streams in the representation. May be null. + * @param essentialProperties Essential properties in the representation. May be empty. + * @param supplementalProperties Supplemental properties in the representation. May be empty. + * @param cacheKey An optional key to be returned from {@link #getCacheKey()}, or null. + * @param contentLength The content length, or {@link C#LENGTH_UNSET} if unknown. + */ + public SingleSegmentRepresentation( + long revisionId, + CustomFormat format, + List baseUrls, + CustomSegmentBase.SingleSegmentBase segmentBase, + @Nullable List inbandEventStreams, + List essentialProperties, + List supplementalProperties, + @Nullable String cacheKey, + long contentLength) { + super( + revisionId, + format, + baseUrls, + segmentBase, + inbandEventStreams, + essentialProperties, + supplementalProperties); + this.uri = Uri.parse(baseUrls.get(0).url); + this.indexUri = segmentBase.getIndex(); + this.cacheKey = cacheKey; + this.contentLength = contentLength; + // If we have an index uri then the index is defined externally, and we shouldn't return one + // directly. If we don't, then we can't do better than an index defining a single segment. + segmentIndex = + indexUri != null ? null : new CustomSingleSegmentIndex(new RangedUri(null, 0, contentLength)); + } + + @Override + @Nullable + public RangedUri getIndexUri() { + return indexUri; + } + + @Override + @Nullable + public DashSegmentIndex getIndex() { + return segmentIndex; + } + + @Override + @Nullable + public String getCacheKey() { + return cacheKey; + } + } + + /** A DASH representation consisting of multiple segments. */ + public static class MultiSegmentRepresentation extends CustomRepresentation + implements DashSegmentIndex { + + @VisibleForTesting /* package */ final CustomSegmentBase.MultiSegmentBase segmentBase; + + /** + * Creates the multi-segment Representation. + * + * @param revisionId Identifies the revision of the content. + * @param format The format of the representation. + * @param baseUrls The base URLs of the representation. + * @param segmentBase The segment base underlying the representation. + * @param inbandEventStreams The in-band event streams in the representation. May be null. + * @param essentialProperties Essential properties in the representation. May be empty. + * @param supplementalProperties Supplemental properties in the representation. May be empty. + */ + public MultiSegmentRepresentation( + long revisionId, + CustomFormat format, + List baseUrls, + CustomSegmentBase.MultiSegmentBase segmentBase, + @Nullable List inbandEventStreams, + List essentialProperties, + List supplementalProperties) { + super( + revisionId, + format, + baseUrls, + segmentBase, + inbandEventStreams, + essentialProperties, + supplementalProperties); + this.segmentBase = segmentBase; + } + + @Override + @Nullable + public RangedUri getIndexUri() { + return null; + } + + @Override + public DashSegmentIndex getIndex() { + return this; + } + + @Override + @Nullable + public String getCacheKey() { + return null; + } + + // DashSegmentIndex implementation. + + @Override + public RangedUri getSegmentUrl(long segmentNum) { + return segmentBase.getSegmentUrl(this, segmentNum); + } + + @Override + public long getSegmentNum(long timeUs, long periodDurationUs) { + return segmentBase.getSegmentNum(timeUs, periodDurationUs); + } + + @Override + public long getTimeUs(long segmentNum) { + return segmentBase.getSegmentTimeUs(segmentNum); + } + + @Override + public long getDurationUs(long segmentNum, long periodDurationUs) { + return segmentBase.getSegmentDurationUs(segmentNum, periodDurationUs); + } + + @Override + public long getFirstSegmentNum() { + return segmentBase.getFirstSegmentNum(); + } + + @Override + public long getFirstAvailableSegmentNum(long periodDurationUs, long nowUnixTimeUs) { + return segmentBase.getFirstAvailableSegmentNum(periodDurationUs, nowUnixTimeUs); + } + + @Override + public long getSegmentCount(long periodDurationUs) { + return segmentBase.getSegmentCount(periodDurationUs); + } + + @Override + public long getAvailableSegmentCount(long periodDurationUs, long nowUnixTimeUs) { + return segmentBase.getAvailableSegmentCount(periodDurationUs, nowUnixTimeUs); + } + + @Override + public long getNextSegmentAvailableTimeUs(long periodDurationUs, long nowUnixTimeUs) { + return segmentBase.getNextSegmentAvailableTimeUs(periodDurationUs, nowUnixTimeUs); + } + + @Override + public boolean isExplicit() { + return segmentBase.isExplicit(); + } + } +} diff --git a/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/dashmanifestparser/CustomSegmentBase.java b/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/dashmanifestparser/CustomSegmentBase.java new file mode 100644 index 000000000..f47cd73f9 --- /dev/null +++ b/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/dashmanifestparser/CustomSegmentBase.java @@ -0,0 +1,573 @@ +package com.kaltura.androidx.media3.exoplayer.dashmanifestparser; + +import static com.kaltura.androidx.media3.exoplayer.dash.DashSegmentIndex.INDEX_UNBOUNDED; +import static java.lang.Math.max; +import static java.lang.Math.min; + +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import com.google.common.math.BigIntegerMath; +import com.kaltura.androidx.media3.common.C; +import com.kaltura.androidx.media3.exoplayer.dash.DashSegmentIndex; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.RangedUri; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.Representation; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.UrlTemplate; +import com.kaltura.androidx.media3.common.util.Util; + +import java.math.BigInteger; +import java.math.RoundingMode; +import java.util.List; + +/** + * An approximate representation of a CustomSegmentBase manifest element. + */ +public abstract class CustomSegmentBase { + + @Nullable /* package */ final RangedUri initialization; + /* package */ final long timescale; + /* package */ final long presentationTimeOffset; + + /** + * @param initialization A {@link RangedUri} corresponding to initialization data, if such data + * exists. + * @param timescale The timescale in units per second. + * @param presentationTimeOffset The presentation time offset. The value in seconds is the + * division of this value and {@code timescale}. + */ + public CustomSegmentBase( + @Nullable RangedUri initialization, long timescale, long presentationTimeOffset) { + this.initialization = initialization; + this.timescale = timescale; + this.presentationTimeOffset = presentationTimeOffset; + } + + @Nullable + public RangedUri getInitialization() { + return initialization; + } + + public long getTimescale() { + return timescale; + } + + public long getPresentationTimeOffset() { + return presentationTimeOffset; + } + + /** + * Returns the {@link RangedUri} defining the location of initialization data for a given + * representation, or null if no initialization data exists. + * + * @param representation The {@link Representation} for which initialization data is required. + * @return A {@link RangedUri} defining the location of the initialization data, or null. + */ + @Nullable + public RangedUri getInitialization(CustomRepresentation representation) { + return initialization; + } + + /** + * Returns the presentation time offset, in microseconds. + */ + public long getPresentationTimeOffsetUs() { + return Util.scaleLargeTimestamp(presentationTimeOffset, C.MICROS_PER_SECOND, timescale); + } + + /** + * A {@link CustomSegmentBase} that defines a single segment. + */ + public static class SingleSegmentBase extends CustomSegmentBase { + + /* package */ final long indexStart; + /* package */ final long indexLength; + + /** + * @param initialization A {@link RangedUri} corresponding to initialization data, if such data + * exists. + * @param timescale The timescale in units per second. + * @param presentationTimeOffset The presentation time offset. The value in seconds is the + * division of this value and {@code timescale}. + * @param indexStart The byte offset of the index data in the segment. + * @param indexLength The length of the index data in bytes. + */ + public SingleSegmentBase( + @Nullable RangedUri initialization, + long timescale, + long presentationTimeOffset, + long indexStart, + long indexLength) { + super(initialization, timescale, presentationTimeOffset); + this.indexStart = indexStart; + this.indexLength = indexLength; + } + + public long getIndexStart() { + return indexStart; + } + + public long getIndexLength() { + return indexLength; + } + + public SingleSegmentBase() { + this( + /* initialization= */ null, + /* timescale= */ 1, + /* presentationTimeOffset= */ 0, + /* indexStart= */ 0, + /* indexLength= */ 0); + } + + @Nullable + public RangedUri getIndex() { + return indexLength <= 0 + ? null + : new RangedUri(/* referenceUri= */ null, indexStart, indexLength); + } + + } + + /** + * A {@link CustomSegmentBase} that consists of multiple segments. + */ + public abstract static class MultiSegmentBase extends CustomSegmentBase { + + /* package */ final long startNumber; + /* package */ final long duration; + @Nullable /* package */ final List segmentTimeline; + private final long timeShiftBufferDepthUs; + private final long periodStartUnixTimeUs; + + /** + * Offset to the current realtime at which segments become available, in microseconds, or {@link + * C#TIME_UNSET} if all segments are available immediately. + * + *

Segments will be available once their end time ≤ currentRealTime + + * availabilityTimeOffset. + */ + @VisibleForTesting /* package */ final long availabilityTimeOffsetUs; + + /** + * @param initialization A {@link RangedUri} corresponding to initialization data, if such data + * exists. + * @param timescale The timescale in units per second. + * @param presentationTimeOffset The presentation time offset. The value in seconds is the + * division of this value and {@code timescale}. + * @param startNumber The sequence number of the first segment. + * @param duration The duration of each segment in the case of fixed duration segments. The + * value in seconds is the division of this value and {@code timescale}. If {@code + * segmentTimeline} is non-null then this parameter is ignored. + * @param segmentTimeline A segment timeline corresponding to the segments. If null, then + * segments are assumed to be of fixed duration as specified by the {@code duration} + * parameter. + * @param availabilityTimeOffsetUs The offset to the current realtime at which segments become + * available in microseconds, or {@link C#TIME_UNSET} if not applicable. + * @param timeShiftBufferDepthUs The time shift buffer depth in microseconds. + * @param periodStartUnixTimeUs The start of the enclosing period in microseconds since the Unix + * epoch. + */ + public MultiSegmentBase( + @Nullable RangedUri initialization, + long timescale, + long presentationTimeOffset, + long startNumber, + long duration, + @Nullable List segmentTimeline, + long availabilityTimeOffsetUs, + long timeShiftBufferDepthUs, + long periodStartUnixTimeUs) { + super(initialization, timescale, presentationTimeOffset); + this.startNumber = startNumber; + this.duration = duration; + this.segmentTimeline = segmentTimeline; + this.availabilityTimeOffsetUs = availabilityTimeOffsetUs; + this.timeShiftBufferDepthUs = timeShiftBufferDepthUs; + this.periodStartUnixTimeUs = periodStartUnixTimeUs; + } + + public long getStartNumber() { + return startNumber; + } + + public long getDuration() { + return duration; + } + + @Nullable + public List getSegmentTimeline() { + return segmentTimeline; + } + + public long getTimeShiftBufferDepthUs() { + return timeShiftBufferDepthUs; + } + + public long getPeriodStartUnixTimeUs() { + return periodStartUnixTimeUs; + } + + public long getAvailabilityTimeOffsetUs() { + return availabilityTimeOffsetUs; + } + + /** See {@link DashSegmentIndex#getSegmentNum(long, long)}. */ + public long getSegmentNum(long timeUs, long periodDurationUs) { + final long firstSegmentNum = getFirstSegmentNum(); + final long segmentCount = getSegmentCount(periodDurationUs); + if (segmentCount == 0) { + return firstSegmentNum; + } + if (segmentTimeline == null) { + // All segments are of equal duration (with the possible exception of the last one). + long durationUs = (duration * C.MICROS_PER_SECOND) / timescale; + long segmentNum = startNumber + timeUs / durationUs; + // Ensure we stay within bounds. + return segmentNum < firstSegmentNum + ? firstSegmentNum + : segmentCount == INDEX_UNBOUNDED + ? segmentNum + : min(segmentNum, firstSegmentNum + segmentCount - 1); + } else { + // The index cannot be unbounded. Identify the segment using binary search. + long lowIndex = firstSegmentNum; + long highIndex = firstSegmentNum + segmentCount - 1; + while (lowIndex <= highIndex) { + long midIndex = lowIndex + (highIndex - lowIndex) / 2; + long midTimeUs = getSegmentTimeUs(midIndex); + if (midTimeUs < timeUs) { + lowIndex = midIndex + 1; + } else if (midTimeUs > timeUs) { + highIndex = midIndex - 1; + } else { + return midIndex; + } + } + return lowIndex == firstSegmentNum ? lowIndex : highIndex; + } + } + + /** See {@link DashSegmentIndex#getDurationUs(long, long)}. */ + public final long getSegmentDurationUs(long sequenceNumber, long periodDurationUs) { + if (segmentTimeline != null) { + long duration = segmentTimeline.get((int) (sequenceNumber - startNumber)).duration; + return (duration * C.MICROS_PER_SECOND) / timescale; + } else { + long segmentCount = getSegmentCount(periodDurationUs); + return segmentCount != INDEX_UNBOUNDED + && sequenceNumber == (getFirstSegmentNum() + segmentCount - 1) + ? (periodDurationUs - getSegmentTimeUs(sequenceNumber)) + : ((duration * C.MICROS_PER_SECOND) / timescale); + } + } + + /** See {@link DashSegmentIndex#getTimeUs(long)}. */ + public final long getSegmentTimeUs(long sequenceNumber) { + long unscaledSegmentTime; + if (segmentTimeline != null) { + unscaledSegmentTime = + segmentTimeline.get((int) (sequenceNumber - startNumber)).startTime + - presentationTimeOffset; + } else { + unscaledSegmentTime = (sequenceNumber - startNumber) * duration; + } + return Util.scaleLargeTimestamp(unscaledSegmentTime, C.MICROS_PER_SECOND, timescale); + } + + /** + * Returns a {@link RangedUri} defining the location of a segment for the given index in the + * given representation. + * + *

See {@link DashSegmentIndex#getSegmentUrl(long)}. + */ + public abstract RangedUri getSegmentUrl(CustomRepresentation representation, long index); + + /** See {@link DashSegmentIndex#getFirstSegmentNum()}. */ + public long getFirstSegmentNum() { + return startNumber; + } + + /** See {@link DashSegmentIndex#getFirstAvailableSegmentNum(long, long)}. */ + public long getFirstAvailableSegmentNum(long periodDurationUs, long nowUnixTimeUs) { + long segmentCount = getSegmentCount(periodDurationUs); + if (segmentCount != INDEX_UNBOUNDED || timeShiftBufferDepthUs == C.TIME_UNSET) { + return getFirstSegmentNum(); + } + // The index is itself unbounded. We need to use the current time to calculate the range of + // available segments. + long liveEdgeTimeInPeriodUs = nowUnixTimeUs - periodStartUnixTimeUs; + long timeShiftBufferStartInPeriodUs = liveEdgeTimeInPeriodUs - timeShiftBufferDepthUs; + long timeShiftBufferStartSegmentNum = + getSegmentNum(timeShiftBufferStartInPeriodUs, periodDurationUs); + return max(getFirstSegmentNum(), timeShiftBufferStartSegmentNum); + } + + /** See {@link DashSegmentIndex#getAvailableSegmentCount(long, long)}. */ + public long getAvailableSegmentCount(long periodDurationUs, long nowUnixTimeUs) { + long segmentCount = getSegmentCount(periodDurationUs); + if (segmentCount != INDEX_UNBOUNDED) { + return segmentCount; + } + // The index is itself unbounded. We need to use the current time to calculate the range of + // available segments. + long liveEdgeTimeInPeriodUs = nowUnixTimeUs - periodStartUnixTimeUs; + long availabilityTimeOffsetUs = liveEdgeTimeInPeriodUs + this.availabilityTimeOffsetUs; + // getSegmentNum(availabilityTimeOffsetUs) will not be completed yet. + long firstIncompleteSegmentNum = getSegmentNum(availabilityTimeOffsetUs, periodDurationUs); + long firstAvailableSegmentNum = getFirstAvailableSegmentNum(periodDurationUs, nowUnixTimeUs); + return (int) (firstIncompleteSegmentNum - firstAvailableSegmentNum); + } + + /** See {@link DashSegmentIndex#getNextSegmentAvailableTimeUs(long, long)}. */ + public long getNextSegmentAvailableTimeUs(long periodDurationUs, long nowUnixTimeUs) { + if (segmentTimeline != null) { + return C.TIME_UNSET; + } + long firstIncompleteSegmentNum = + getFirstAvailableSegmentNum(periodDurationUs, nowUnixTimeUs) + + getAvailableSegmentCount(periodDurationUs, nowUnixTimeUs); + return getSegmentTimeUs(firstIncompleteSegmentNum) + + getSegmentDurationUs(firstIncompleteSegmentNum, periodDurationUs) + - availabilityTimeOffsetUs; + } + + /** See {@link DashSegmentIndex#isExplicit()} */ + public boolean isExplicit() { + return segmentTimeline != null; + } + + /** See {@link DashSegmentIndex#getSegmentCount(long)}. */ + public abstract long getSegmentCount(long periodDurationUs); + } + + /** A {@link MultiSegmentBase} that uses a SegmentList to define its segments. */ + public static final class SegmentList extends CustomSegmentBase.MultiSegmentBase { + + @Nullable /* package */ final List mediaSegments; + + /** + * @param initialization A {@link RangedUri} corresponding to initialization data, if such data + * exists. + * @param timescale The timescale in units per second. + * @param presentationTimeOffset The presentation time offset. The value in seconds is the + * division of this value and {@code timescale}. + * @param startNumber The sequence number of the first segment. + * @param duration The duration of each segment in the case of fixed duration segments. The + * value in seconds is the division of this value and {@code timescale}. If {@code + * segmentTimeline} is non-null then this parameter is ignored. + * @param segmentTimeline A segment timeline corresponding to the segments. If null, then + * segments are assumed to be of fixed duration as specified by the {@code duration} + * parameter. + * @param availabilityTimeOffsetUs The offset to the current realtime at which segments become + * available in microseconds, or {@link C#TIME_UNSET} if not applicable. + * @param mediaSegments A list of {@link RangedUri}s indicating the locations of the segments. + * @param timeShiftBufferDepthUs The time shift buffer depth in microseconds. + * @param periodStartUnixTimeUs The start of the enclosing period in microseconds since the Unix + * epoch. + */ + public SegmentList( + RangedUri initialization, + long timescale, + long presentationTimeOffset, + long startNumber, + long duration, + @Nullable List segmentTimeline, + long availabilityTimeOffsetUs, + @Nullable List mediaSegments, + long timeShiftBufferDepthUs, + long periodStartUnixTimeUs) { + super( + initialization, + timescale, + presentationTimeOffset, + startNumber, + duration, + segmentTimeline, + availabilityTimeOffsetUs, + timeShiftBufferDepthUs, + periodStartUnixTimeUs); + this.mediaSegments = mediaSegments; + } + + @Override + public RangedUri getSegmentUrl(CustomRepresentation representation, long sequenceNumber) { + return mediaSegments.get((int) (sequenceNumber - startNumber)); + } + + @Override + public long getSegmentCount(long periodDurationUs) { + return mediaSegments.size(); + } + + @Override + public boolean isExplicit() { + return true; + } + + } + + /** A {@link MultiSegmentBase} that uses a SegmentTemplate to define its segments. */ + public static final class SegmentTemplate extends MultiSegmentBase { + + @Nullable /* package */ final UrlTemplate initializationTemplate; + @Nullable /* package */ final UrlTemplate mediaTemplate; + /* package */ final long endNumber; + + /** + * @param initialization A {@link RangedUri} corresponding to initialization data, if such data + * exists. The value of this parameter is ignored if {@code initializationTemplate} is + * non-null. + * @param timescale The timescale in units per second. + * @param presentationTimeOffset The presentation time offset. The value in seconds is the + * division of this value and {@code timescale}. + * @param startNumber The sequence number of the first segment. + * @param endNumber The sequence number of the last segment as specified by the + * SupplementalProperty with schemeIdUri="http://dashif.org/guidelines/last-segment-number", + * or {@link C#INDEX_UNSET}. + * @param duration The duration of each segment in the case of fixed duration segments. The + * value in seconds is the division of this value and {@code timescale}. If {@code + * segmentTimeline} is non-null then this parameter is ignored. + * @param segmentTimeline A segment timeline corresponding to the segments. If null, then + * segments are assumed to be of fixed duration as specified by the {@code duration} + * parameter. + * @param availabilityTimeOffsetUs The offset to the current realtime at which segments become + * available in microseconds, or {@link C#TIME_UNSET} if not applicable. + * @param initializationTemplate A template defining the location of initialization data, if + * such data exists. If non-null then the {@code initialization} parameter is ignored. If + * null then {@code initialization} will be used. + * @param mediaTemplate A template defining the location of each media segment. + * @param timeShiftBufferDepthUs The time shift buffer depth in microseconds. + * @param periodStartUnixTimeUs The start of the enclosing period in microseconds since the Unix + * epoch. + */ + public SegmentTemplate( + RangedUri initialization, + long timescale, + long presentationTimeOffset, + long startNumber, + long endNumber, + long duration, + @Nullable List segmentTimeline, + long availabilityTimeOffsetUs, + @Nullable UrlTemplate initializationTemplate, + @Nullable UrlTemplate mediaTemplate, + long timeShiftBufferDepthUs, + long periodStartUnixTimeUs) { + super( + initialization, + timescale, + presentationTimeOffset, + startNumber, + duration, + segmentTimeline, + availabilityTimeOffsetUs, + timeShiftBufferDepthUs, + periodStartUnixTimeUs); + this.initializationTemplate = initializationTemplate; + this.mediaTemplate = mediaTemplate; + this.endNumber = endNumber; + } + + @Nullable + public UrlTemplate getInitializationTemplate() { + return initializationTemplate; + } + + @Nullable + public UrlTemplate getMediaTemplate() { + return mediaTemplate; + } + + public long getEndNumber() { + return endNumber; + } + + @Override + @Nullable + public RangedUri getInitialization(CustomRepresentation representation) { + if (initializationTemplate != null) { + String urlString = + initializationTemplate.buildUri( + representation.format.id, 0, representation.format.bitrate, 0); + return new RangedUri(urlString, 0, C.LENGTH_UNSET); + } else { + return super.getInitialization(representation); + } + } + + @Override + public RangedUri getSegmentUrl(CustomRepresentation representation, long sequenceNumber) { + long time; + if (segmentTimeline != null) { + time = segmentTimeline.get((int) (sequenceNumber - startNumber)).startTime; + } else { + time = (sequenceNumber - startNumber) * duration; + } + String uriString = + mediaTemplate.buildUri( + representation.format.id, sequenceNumber, representation.format.bitrate, time); + return new RangedUri(uriString, 0, C.LENGTH_UNSET); + } + + @Override + public long getSegmentCount(long periodDurationUs) { + if (segmentTimeline != null) { + return segmentTimeline.size(); + } else if (endNumber != C.INDEX_UNSET) { + return endNumber - startNumber + 1; + } else if (periodDurationUs != C.TIME_UNSET) { + BigInteger numerator = + BigInteger.valueOf(periodDurationUs).multiply(BigInteger.valueOf(timescale)); + BigInteger denominator = + BigInteger.valueOf(duration).multiply(BigInteger.valueOf(C.MICROS_PER_SECOND)); + return BigIntegerMath.divide(numerator, denominator, RoundingMode.CEILING).longValue(); + } else { + return INDEX_UNBOUNDED; + } + } + } + + /** Represents a timeline segment from the MPD's SegmentTimeline list. */ + public static final class SegmentTimelineElement { + + /* package */ final long startTime; + /* package */ final long duration; + + /** + * @param startTime The start time of the element. The value in seconds is the division of this + * value and the {@code timescale} of the enclosing element. + * @param duration The duration of the element. The value in seconds is the division of this + * value and the {@code timescale} of the enclosing element. + */ + public SegmentTimelineElement(long startTime, long duration) { + this.startTime = startTime; + this.duration = duration; + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + SegmentTimelineElement that = (SegmentTimelineElement) o; + return startTime == that.startTime && duration == that.duration; + } + + public long getStartTime() { + return startTime; + } + + public long getDuration() { + return duration; + } + + @Override + public int hashCode() { + return 31 * (int) startTime + (int) duration; + } + } + +} diff --git a/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/dashmanifestparser/CustomSingleSegmentIndex.java b/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/dashmanifestparser/CustomSingleSegmentIndex.java new file mode 100644 index 000000000..ce76f2bbb --- /dev/null +++ b/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/dashmanifestparser/CustomSingleSegmentIndex.java @@ -0,0 +1,72 @@ +package com.kaltura.androidx.media3.exoplayer.dashmanifestparser; + + +import com.kaltura.androidx.media3.common.C; +import com.kaltura.androidx.media3.exoplayer.dash.DashSegmentIndex; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.RangedUri; + +/** + * A {@link DashSegmentIndex} that defines a single segment. + */ +/* package */ final class CustomSingleSegmentIndex implements DashSegmentIndex { + + private final RangedUri uri; + + /** + * @param uri A {@link RangedUri} defining the location of the segment data. + */ + public CustomSingleSegmentIndex(RangedUri uri) { + this.uri = uri; + } + + @Override + public long getSegmentNum(long timeUs, long periodDurationUs) { + return 0; + } + + @Override + public long getTimeUs(long segmentNum) { + return 0; + } + + @Override + public long getDurationUs(long segmentNum, long periodDurationUs) { + return periodDurationUs; + } + + @Override + public RangedUri getSegmentUrl(long segmentNum) { + return uri; + } + + @Override + public long getFirstSegmentNum() { + return 0; + } + + @Override + public long getFirstAvailableSegmentNum(long periodDurationUs, long nowUnixTimeUs) { + return 0; + } + + @Override + public long getSegmentCount(long periodDurationUs) { + return 1; + } + + @Override + public long getAvailableSegmentCount(long periodDurationUs, long nowUnixTimeUs) { + return 1; + } + + @Override + public long getNextSegmentAvailableTimeUs(long periodDurationUs, long nowUnixTimeUs) { + return C.TIME_UNSET; + } + + @Override + public boolean isExplicit() { + return true; + } + +} diff --git a/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/video/ConfigurableLoadControl.java b/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/video/ConfigurableLoadControl.java new file mode 100644 index 000000000..9e67f835d --- /dev/null +++ b/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/video/ConfigurableLoadControl.java @@ -0,0 +1,492 @@ +package com.kaltura.androidx.media3.exoplayer.video; + +import static com.kaltura.androidx.media3.common.util.Assertions.checkState; +import static java.lang.Math.max; +import static java.lang.Math.min; + +import androidx.annotation.Nullable; + +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import com.kaltura.androidx.media3.common.C; +import com.kaltura.androidx.media3.exoplayer.LoadControl; +import com.kaltura.androidx.media3.exoplayer.Renderer; +import com.kaltura.androidx.media3.exoplayer.source.TrackGroupArray; +import com.kaltura.androidx.media3.exoplayer.trackselection.ExoTrackSelection; +import com.kaltura.androidx.media3.exoplayer.upstream.Allocator; +import com.kaltura.androidx.media3.exoplayer.upstream.DefaultAllocator; +import com.kaltura.androidx.media3.common.util.Assertions; +import com.kaltura.androidx.media3.common.util.Util; +import com.kaltura.playkit.PKLog; + +/** The default {@link LoadControl} implementation. */ +public class ConfigurableLoadControl implements LoadControl { + + /** + * The default minimum duration of media that the player will attempt to ensure is buffered at all + * times, in milliseconds. + */ + public static final int DEFAULT_MIN_BUFFER_MS = 50_000; + + /** + * The default maximum duration of media that the player will attempt to buffer, in milliseconds. + */ + public static final int DEFAULT_MAX_BUFFER_MS = 50_000; + + /** + * The default duration of media that must be buffered for playback to start or resume following a + * user action such as a seek, in milliseconds. + */ + public static final int DEFAULT_BUFFER_FOR_PLAYBACK_MS = 2500; + + /** + * The default duration of media that must be buffered for playback to resume after a rebuffer, in + * milliseconds. A rebuffer is defined to be caused by buffer depletion rather than a user action. + */ + public static final int DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS = 5000; + + /** + * The default target buffer size in bytes. The value ({@link C#LENGTH_UNSET}) means that the load + * control will calculate the target buffer size based on the selected tracks. + */ + public static final int DEFAULT_TARGET_BUFFER_BYTES = C.LENGTH_UNSET; + + /** The default prioritization of buffer time constraints over size constraints. */ + public static final boolean DEFAULT_PRIORITIZE_TIME_OVER_SIZE_THRESHOLDS = false; + + /** The default back buffer duration in milliseconds. */ + public static final int DEFAULT_BACK_BUFFER_DURATION_MS = 0; + + /** The default for whether the back buffer is retained from the previous keyframe. */ + public static final boolean DEFAULT_RETAIN_BACK_BUFFER_FROM_KEYFRAME = false; + + /** A default size in bytes for a video buffer. */ + public static final int DEFAULT_VIDEO_BUFFER_SIZE = 2000 * C.DEFAULT_BUFFER_SEGMENT_SIZE; + + /** A default size in bytes for an audio buffer. */ + public static final int DEFAULT_AUDIO_BUFFER_SIZE = 200 * C.DEFAULT_BUFFER_SEGMENT_SIZE; + + /** A default size in bytes for a text buffer. */ + public static final int DEFAULT_TEXT_BUFFER_SIZE = 2 * C.DEFAULT_BUFFER_SEGMENT_SIZE; + + /** A default size in bytes for a metadata buffer. */ + public static final int DEFAULT_METADATA_BUFFER_SIZE = 2 * C.DEFAULT_BUFFER_SEGMENT_SIZE; + + /** A default size in bytes for a camera motion buffer. */ + public static final int DEFAULT_CAMERA_MOTION_BUFFER_SIZE = 2 * C.DEFAULT_BUFFER_SEGMENT_SIZE; + + /** A default size in bytes for an image buffer. */ + public static final int DEFAULT_IMAGE_BUFFER_SIZE = 2 * C.DEFAULT_BUFFER_SEGMENT_SIZE; + + /** A default size in bytes for a muxed buffer (e.g. containing video, audio and text). */ + public static final int DEFAULT_MUXED_BUFFER_SIZE = + DEFAULT_VIDEO_BUFFER_SIZE + DEFAULT_AUDIO_BUFFER_SIZE + DEFAULT_TEXT_BUFFER_SIZE; + + /** + * The buffer size in bytes that will be used as a minimum target buffer in all cases. This is + * also the default target buffer before tracks are selected. + */ + public static final int DEFAULT_MIN_BUFFER_SIZE = 200 * C.DEFAULT_BUFFER_SEGMENT_SIZE; + + /** Builder for {@link ConfigurableLoadControl}. */ + public static final class Builder { + + @Nullable private DefaultAllocator allocator; + private int minBufferMs; + private int maxBufferMs; + private int bufferForPlaybackMs; + private int bufferForPlaybackAfterRebufferMs; + private int targetBufferBytes; + private boolean prioritizeTimeOverSizeThresholds; + private int backBufferDurationMs; + private boolean retainBackBufferFromKeyframe; + private boolean buildCalled; + + /** Constructs a new instance. */ + public Builder() { + minBufferMs = DEFAULT_MIN_BUFFER_MS; + maxBufferMs = DEFAULT_MAX_BUFFER_MS; + bufferForPlaybackMs = DEFAULT_BUFFER_FOR_PLAYBACK_MS; + bufferForPlaybackAfterRebufferMs = DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS; + targetBufferBytes = DEFAULT_TARGET_BUFFER_BYTES; + prioritizeTimeOverSizeThresholds = DEFAULT_PRIORITIZE_TIME_OVER_SIZE_THRESHOLDS; + backBufferDurationMs = DEFAULT_BACK_BUFFER_DURATION_MS; + retainBackBufferFromKeyframe = DEFAULT_RETAIN_BACK_BUFFER_FROM_KEYFRAME; + } + + /** + * Sets the {@link DefaultAllocator} used by the loader. + * + * @param allocator The {@link DefaultAllocator}. + * @return This builder, for convenience. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + @CanIgnoreReturnValue + public Builder setAllocator(DefaultAllocator allocator) { + checkState(!buildCalled); + this.allocator = allocator; + return this; + } + + /** + * Sets the buffer duration parameters. + * + * @param minBufferMs The minimum duration of media that the player will attempt to ensure is + * buffered at all times, in milliseconds. + * @param maxBufferMs The maximum duration of media that the player will attempt to buffer, in + * milliseconds. + * @param bufferForPlaybackMs The duration of media that must be buffered for playback to start + * or resume following a user action such as a seek, in milliseconds. + * @param bufferForPlaybackAfterRebufferMs The default duration of media that must be buffered + * for playback to resume after a rebuffer, in milliseconds. A rebuffer is defined to be + * caused by buffer depletion rather than a user action. + * @return This builder, for convenience. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + @CanIgnoreReturnValue + public Builder setBufferDurationsMs( + int minBufferMs, + int maxBufferMs, + int bufferForPlaybackMs, + int bufferForPlaybackAfterRebufferMs) { + checkState(!buildCalled); + assertGreaterOrEqual(bufferForPlaybackMs, 0, "bufferForPlaybackMs", "0"); + assertGreaterOrEqual( + bufferForPlaybackAfterRebufferMs, 0, "bufferForPlaybackAfterRebufferMs", "0"); + assertGreaterOrEqual(minBufferMs, bufferForPlaybackMs, "minBufferMs", "bufferForPlaybackMs"); + assertGreaterOrEqual( + minBufferMs, + bufferForPlaybackAfterRebufferMs, + "minBufferMs", + "bufferForPlaybackAfterRebufferMs"); + assertGreaterOrEqual(maxBufferMs, minBufferMs, "maxBufferMs", "minBufferMs"); + this.minBufferMs = minBufferMs; + this.maxBufferMs = maxBufferMs; + this.bufferForPlaybackMs = bufferForPlaybackMs; + this.bufferForPlaybackAfterRebufferMs = bufferForPlaybackAfterRebufferMs; + return this; + } + + /** + * Sets the target buffer size in bytes. If set to {@link C#LENGTH_UNSET}, the target buffer + * size will be calculated based on the selected tracks. + * + * @param targetBufferBytes The target buffer size in bytes. + * @return This builder, for convenience. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + @CanIgnoreReturnValue + public Builder setTargetBufferBytes(int targetBufferBytes) { + checkState(!buildCalled); + this.targetBufferBytes = targetBufferBytes; + return this; + } + + /** + * Sets whether the load control prioritizes buffer time constraints over buffer size + * constraints. + * + * @param prioritizeTimeOverSizeThresholds Whether the load control prioritizes buffer time + * constraints over buffer size constraints. + * @return This builder, for convenience. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + @CanIgnoreReturnValue + public Builder setPrioritizeTimeOverSizeThresholds(boolean prioritizeTimeOverSizeThresholds) { + checkState(!buildCalled); + this.prioritizeTimeOverSizeThresholds = prioritizeTimeOverSizeThresholds; + return this; + } + + /** + * Sets the back buffer duration, and whether the back buffer is retained from the previous + * keyframe. + * + * @param backBufferDurationMs The back buffer duration in milliseconds. + * @param retainBackBufferFromKeyframe Whether the back buffer is retained from the previous + * keyframe. + * @return This builder, for convenience. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + @CanIgnoreReturnValue + public Builder setBackBuffer(int backBufferDurationMs, boolean retainBackBufferFromKeyframe) { + checkState(!buildCalled); + assertGreaterOrEqual(backBufferDurationMs, 0, "backBufferDurationMs", "0"); + this.backBufferDurationMs = backBufferDurationMs; + this.retainBackBufferFromKeyframe = retainBackBufferFromKeyframe; + return this; + } + + /** + * @deprecated use {@link #build} instead. + */ + @Deprecated + public ConfigurableLoadControl createDefaultLoadControl() { + return build(); + } + + /** Creates a {@link ConfigurableLoadControl}. */ + public ConfigurableLoadControl build() { + checkState(!buildCalled); + buildCalled = true; + if (allocator == null) { + allocator = new DefaultAllocator(/* trimOnReset= */ true, C.DEFAULT_BUFFER_SEGMENT_SIZE); + } + return new ConfigurableLoadControl( + allocator, + minBufferMs, + maxBufferMs, + bufferForPlaybackMs, + bufferForPlaybackAfterRebufferMs, + targetBufferBytes, + prioritizeTimeOverSizeThresholds, + backBufferDurationMs, + retainBackBufferFromKeyframe); + } + } + + private static final PKLog log = PKLog.get("ConfigurableLoadControl"); + + private final DefaultAllocator allocator; + + private long minBufferUs; + private long maxBufferUs; + private long bufferForPlaybackUs; + private long bufferForPlaybackAfterRebufferUs; + private int targetBufferBytesOverwrite; + private boolean prioritizeTimeOverSizeThresholds; + private long backBufferDurationUs; + private boolean retainBackBufferFromKeyframe; + + private int targetBufferBytes; + private boolean isLoading; + + /** Constructs a new instance, using the {@code DEFAULT_*} constants defined in this class. */ + public ConfigurableLoadControl() { + this( + new DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE), + DEFAULT_MIN_BUFFER_MS, + DEFAULT_MAX_BUFFER_MS, + DEFAULT_BUFFER_FOR_PLAYBACK_MS, + DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS, + DEFAULT_TARGET_BUFFER_BYTES, + DEFAULT_PRIORITIZE_TIME_OVER_SIZE_THRESHOLDS, + DEFAULT_BACK_BUFFER_DURATION_MS, + DEFAULT_RETAIN_BACK_BUFFER_FROM_KEYFRAME); + } + + public ConfigurableLoadControl( + DefaultAllocator allocator, + int minBufferMs, + int maxBufferMs, + int bufferForPlaybackMs, + int bufferForPlaybackAfterRebufferMs, + int targetBufferBytes, + boolean prioritizeTimeOverSizeThresholds, + int backBufferDurationMs, + boolean retainBackBufferFromKeyframe) { + assertGreaterOrEqual(bufferForPlaybackMs, 0, "bufferForPlaybackMs", "0"); + assertGreaterOrEqual( + bufferForPlaybackAfterRebufferMs, 0, "bufferForPlaybackAfterRebufferMs", "0"); + assertGreaterOrEqual(minBufferMs, bufferForPlaybackMs, "minBufferMs", "bufferForPlaybackMs"); + assertGreaterOrEqual( + minBufferMs, + bufferForPlaybackAfterRebufferMs, + "minBufferMs", + "bufferForPlaybackAfterRebufferMs"); + assertGreaterOrEqual(maxBufferMs, minBufferMs, "maxBufferMs", "minBufferMs"); + assertGreaterOrEqual(backBufferDurationMs, 0, "backBufferDurationMs", "0"); + + this.allocator = allocator; + this.minBufferUs = Util.msToUs(minBufferMs); + this.maxBufferUs = Util.msToUs(maxBufferMs); + this.bufferForPlaybackUs = Util.msToUs(bufferForPlaybackMs); + this.bufferForPlaybackAfterRebufferUs = Util.msToUs(bufferForPlaybackAfterRebufferMs); + this.targetBufferBytesOverwrite = targetBufferBytes; + this.targetBufferBytes = + targetBufferBytesOverwrite != C.LENGTH_UNSET + ? targetBufferBytesOverwrite + : DEFAULT_MIN_BUFFER_SIZE; + this.prioritizeTimeOverSizeThresholds = prioritizeTimeOverSizeThresholds; + this.backBufferDurationUs = Util.msToUs(backBufferDurationMs); + this.retainBackBufferFromKeyframe = retainBackBufferFromKeyframe; + } + + @Override + public void onPrepared() { + reset(false); + } + + @Override + public void onTracksSelected( + Renderer[] renderers, TrackGroupArray trackGroups, ExoTrackSelection[] trackSelections) { + targetBufferBytes = + targetBufferBytesOverwrite == C.LENGTH_UNSET + ? calculateTargetBufferBytes(renderers, trackSelections) + : targetBufferBytesOverwrite; + allocator.setTargetBufferSize(targetBufferBytes); + } + + @Override + public void onStopped() { + reset(true); + } + + @Override + public void onReleased() { + reset(true); + } + + @Override + public Allocator getAllocator() { + return allocator; + } + + @Override + public long getBackBufferDurationUs() { + return backBufferDurationUs; + } + + @Override + public boolean retainBackBufferFromKeyframe() { + return retainBackBufferFromKeyframe; + } + + @Override + public boolean shouldContinueLoading( + long playbackPositionUs, long bufferedDurationUs, float playbackSpeed) { + boolean targetBufferSizeReached = allocator.getTotalBytesAllocated() >= targetBufferBytes; + long minBufferUs = this.minBufferUs; + if (playbackSpeed > 1) { + // The playback speed is faster than real time, so scale up the minimum required media + // duration to keep enough media buffered for a playout duration of minBufferUs. + long mediaDurationMinBufferUs = + Util.getMediaDurationForPlayoutDuration(minBufferUs, playbackSpeed); + minBufferUs = min(mediaDurationMinBufferUs, maxBufferUs); + } + // Prevent playback from getting stuck if minBufferUs is too small. + minBufferUs = max(minBufferUs, 500_000); + if (bufferedDurationUs < minBufferUs) { + isLoading = prioritizeTimeOverSizeThresholds || !targetBufferSizeReached; + if (!isLoading && bufferedDurationUs < 500_000) { + log.w( + "ConfigurableLoadControl", + "Target buffer size reached with less than 500ms of buffered media data."); + } + } else if (bufferedDurationUs >= maxBufferUs || targetBufferSizeReached) { + isLoading = false; + } // Else don't change the loading state. + return isLoading; + } + + @Override + public boolean shouldStartPlayback( + long bufferedDurationUs, float playbackSpeed, boolean rebuffering, long targetLiveOffsetUs) { + bufferedDurationUs = Util.getPlayoutDurationForMediaDuration(bufferedDurationUs, playbackSpeed); + long minBufferDurationUs = rebuffering ? bufferForPlaybackAfterRebufferUs : bufferForPlaybackUs; + if (targetLiveOffsetUs != C.TIME_UNSET) { + minBufferDurationUs = min(targetLiveOffsetUs / 2, minBufferDurationUs); + } + return minBufferDurationUs <= 0 + || bufferedDurationUs >= minBufferDurationUs + || (!prioritizeTimeOverSizeThresholds + && allocator.getTotalBytesAllocated() >= targetBufferBytes); + } + + /** + * Calculate target buffer size in bytes based on the selected tracks. The player will try not to + * exceed this target buffer. Only used when {@code targetBufferBytes} is {@link C#LENGTH_UNSET}. + * + * @param renderers The renderers for which the track were selected. + * @param trackSelectionArray The selected tracks. + * @return The target buffer size in bytes. + */ + protected int calculateTargetBufferBytes( + Renderer[] renderers, ExoTrackSelection[] trackSelectionArray) { + int targetBufferSize = 0; + for (int i = 0; i < renderers.length; i++) { + if (trackSelectionArray[i] != null) { + targetBufferSize += getDefaultBufferSize(renderers[i].getTrackType()); + } + } + return max(DEFAULT_MIN_BUFFER_SIZE, targetBufferSize); + } + + private void reset(boolean resetAllocator) { + targetBufferBytes = + targetBufferBytesOverwrite == C.LENGTH_UNSET + ? DEFAULT_MIN_BUFFER_SIZE + : targetBufferBytesOverwrite; + isLoading = false; + if (resetAllocator) { + allocator.reset(); + } + } + + private static int getDefaultBufferSize(@C.TrackType int trackType) { + switch (trackType) { + case C.TRACK_TYPE_DEFAULT: + return DEFAULT_MUXED_BUFFER_SIZE; + case C.TRACK_TYPE_AUDIO: + return DEFAULT_AUDIO_BUFFER_SIZE; + case C.TRACK_TYPE_VIDEO: + return DEFAULT_VIDEO_BUFFER_SIZE; + case C.TRACK_TYPE_TEXT: + return DEFAULT_TEXT_BUFFER_SIZE; + case C.TRACK_TYPE_METADATA: + return DEFAULT_METADATA_BUFFER_SIZE; + case C.TRACK_TYPE_CAMERA_MOTION: + return DEFAULT_CAMERA_MOTION_BUFFER_SIZE; + case C.TRACK_TYPE_IMAGE: + return DEFAULT_IMAGE_BUFFER_SIZE; + case C.TRACK_TYPE_NONE: + return 0; + case C.TRACK_TYPE_UNKNOWN: + default: + throw new IllegalArgumentException(); + } + } + + private static void assertGreaterOrEqual(int value1, int value2, String name1, String name2) { + Assertions.checkArgument(value1 >= value2, name1 + " cannot be less than " + name2); + } + + public void setMinBufferUs(long minBufferUs) { + this.minBufferUs = minBufferUs; + } + + public void setMaxBufferUs(long maxBufferUs) { + this.maxBufferUs = maxBufferUs; + } + + public void setBufferForPlaybackUs(long bufferForPlaybackUs) { + this.bufferForPlaybackUs = bufferForPlaybackUs; + } + + public void setBufferForPlaybackAfterRebufferUs(long bufferForPlaybackAfterRebufferUs) { + this.bufferForPlaybackAfterRebufferUs = bufferForPlaybackAfterRebufferUs; + } + + public void setTargetBufferBytesOverwrite(int targetBufferBytesOverwrite) { + this.targetBufferBytesOverwrite = targetBufferBytesOverwrite; + } + + public void setPrioritizeTimeOverSizeThresholds(boolean prioritizeTimeOverSizeThresholds) { + this.prioritizeTimeOverSizeThresholds = prioritizeTimeOverSizeThresholds; + } + + public void setBackBufferDurationUs(long backBufferDurationUs) { + this.backBufferDurationUs = backBufferDurationUs; + } + + public void setRetainBackBufferFromKeyframe(boolean retainBackBufferFromKeyframe) { + this.retainBackBufferFromKeyframe = retainBackBufferFromKeyframe; + } + + public void setTargetBufferBytes(int targetBufferBytes) { + this.targetBufferBytesOverwrite = targetBufferBytes; + this.targetBufferBytes = + this.targetBufferBytesOverwrite != C.LENGTH_UNSET + ? this.targetBufferBytesOverwrite + : DEFAULT_MIN_BUFFER_SIZE; + } +} diff --git a/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/video/KMediaCodecVideoRenderer.java b/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/video/KMediaCodecVideoRenderer.java new file mode 100644 index 000000000..18defc447 --- /dev/null +++ b/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/video/KMediaCodecVideoRenderer.java @@ -0,0 +1,83 @@ +package com.kaltura.androidx.media3.exoplayer.video; + +import android.content.Context; +import android.os.Handler; + +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + + +import com.kaltura.androidx.media3.common.Format; +import com.kaltura.androidx.media3.exoplayer.ExoPlaybackException; +import com.kaltura.androidx.media3.exoplayer.mediacodec.MediaCodecAdapter; +import com.kaltura.androidx.media3.exoplayer.mediacodec.MediaCodecSelector; +import com.kaltura.androidx.media3.exoplayer.mediacodec.MediaCodecUtil; +import com.kaltura.playkit.PKLog; + +public class KMediaCodecVideoRenderer extends MediaCodecVideoRenderer { + + private boolean renderedFirstFrameAfterResetAfterReady = false; + + private boolean shouldNotifyRenderedFirstFrameAfterStarted = false; + + private static final PKLog log = PKLog.get("KMediaCodecVideoRenderer"); + + @Nullable private KVideoRendererFirstFrameWhenStartedEventListener rendererFirstFrameWhenStartedEventListener; + private final MediaCodecSupportFormatHelper mediaCodecSupportFormatHelper; + + public KMediaCodecVideoRenderer(Context context, + MediaCodecAdapter.Factory codecAdapterFactory, + MediaCodecSelector mediaCodecSelector, + long allowedJoiningTimeMs, + boolean enableDecoderFallback, + @Nullable Handler eventHandler, + @Nullable VideoRendererEventListener eventListener, + int maxDroppedFramesToNotify, + KVideoRendererFirstFrameWhenStartedEventListener rendererFirstFrameWhenStartedEventListener) { + super(context, codecAdapterFactory, mediaCodecSelector, allowedJoiningTimeMs, enableDecoderFallback, eventHandler, eventListener, maxDroppedFramesToNotify); + this.rendererFirstFrameWhenStartedEventListener = rendererFirstFrameWhenStartedEventListener; + this.mediaCodecSupportFormatHelper = new MediaCodecSupportFormatHelper(context); + } + + // TODO: This should be revisited once migartion to media3 is complete. + // Seems like it's not needed anymore for media3 +// @Override +// void maybeNotifyRenderedFirstFrame() { +// super.maybeNotifyRenderedFirstFrame(); +// if (this.shouldNotifyRenderedFirstFrameAfterStarted) { +// log.d("KMediaCodecVideoRenderer", "maybeNotifyRenderedFirstFrame"); +// this.shouldNotifyRenderedFirstFrameAfterStarted = false; +// new Handler(Looper.getMainLooper()).post(() -> { +// if (rendererFirstFrameWhenStartedEventListener != null) { +// log.d("KMediaCodecVideoRenderer", "onRenderedFirstFrameWhenStarted"); +// rendererFirstFrameWhenStartedEventListener.onRenderedFirstFrameWhenStarted(); +// } +// }); +// } +// } + + @Override + protected void onPositionReset(long positionUs, boolean joining) throws ExoPlaybackException { + log.d("KMediaCodecVideoRenderer", "onPositionReset() called with: positionUs = [" + positionUs + "], joining = [" + joining + "]"); + super.onPositionReset(positionUs, joining); + this.renderedFirstFrameAfterResetAfterReady = false; + this.shouldNotifyRenderedFirstFrameAfterStarted = false; + } + + @RequiresApi(21) + @Override + protected void renderOutputBufferV21(MediaCodecAdapter codec, int index, long presentationTimeUs, long releaseTimeNs) { + if(getState() == STATE_STARTED) { + if(!this.renderedFirstFrameAfterResetAfterReady) { + this.renderedFirstFrameAfterResetAfterReady = true; + this.shouldNotifyRenderedFirstFrameAfterStarted = true; + } + } + super.renderOutputBufferV21(codec, index, presentationTimeUs, releaseTimeNs); + } + + @Override + protected int supportsFormat(MediaCodecSelector mediaCodecSelector, Format format) throws MediaCodecUtil.DecoderQueryException { + return mediaCodecSupportFormatHelper.supportsFormat(mediaCodecSelector, format); + } +} diff --git a/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/video/KVideoRendererFirstFrameWhenStartedEventListener.java b/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/video/KVideoRendererFirstFrameWhenStartedEventListener.java new file mode 100644 index 000000000..8613c3977 --- /dev/null +++ b/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/video/KVideoRendererFirstFrameWhenStartedEventListener.java @@ -0,0 +1,6 @@ +package com.kaltura.androidx.media3.exoplayer.video; + +public interface KVideoRendererFirstFrameWhenStartedEventListener { + default void onRenderedFirstFrameWhenStarted() { + } +} diff --git a/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/video/MediaCodecSupportFormatHelper.java b/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/video/MediaCodecSupportFormatHelper.java new file mode 100644 index 000000000..36e5c3d67 --- /dev/null +++ b/playkit/src/main/java/com/kaltura/androidx/media3/exoplayer/video/MediaCodecSupportFormatHelper.java @@ -0,0 +1,196 @@ +package com.kaltura.androidx.media3.exoplayer.video; + +import static android.view.Display.DEFAULT_DISPLAY; + +import static com.kaltura.androidx.media3.exoplayer.RendererCapabilities.ADAPTIVE_NOT_SEAMLESS; +import static com.kaltura.androidx.media3.exoplayer.RendererCapabilities.ADAPTIVE_SEAMLESS; +import static com.kaltura.androidx.media3.exoplayer.RendererCapabilities.DECODER_SUPPORT_FALLBACK; +import static com.kaltura.androidx.media3.exoplayer.RendererCapabilities.DECODER_SUPPORT_FALLBACK_MIMETYPE; +import static com.kaltura.androidx.media3.exoplayer.RendererCapabilities.DECODER_SUPPORT_PRIMARY; +import static com.kaltura.androidx.media3.exoplayer.RendererCapabilities.HARDWARE_ACCELERATION_NOT_SUPPORTED; +import static com.kaltura.androidx.media3.exoplayer.RendererCapabilities.HARDWARE_ACCELERATION_SUPPORTED; +import static com.kaltura.androidx.media3.exoplayer.mediacodec.MediaCodecUtil.getAlternativeCodecMimeType; + +import android.content.Context; +import android.hardware.display.DisplayManager; +import android.view.Display; + +import androidx.annotation.DoNotInline; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +import com.google.common.collect.ImmutableList; +import com.kaltura.androidx.media3.common.C; +import com.kaltura.androidx.media3.common.DrmInitData; +import com.kaltura.androidx.media3.common.Format; +import com.kaltura.androidx.media3.common.MimeTypes; +import com.kaltura.androidx.media3.common.util.UnstableApi; +import com.kaltura.androidx.media3.common.util.Util; +import com.kaltura.androidx.media3.exoplayer.RendererCapabilities; +import com.kaltura.androidx.media3.exoplayer.mediacodec.MediaCodecInfo; +import com.kaltura.androidx.media3.exoplayer.mediacodec.MediaCodecSelector; +import com.kaltura.androidx.media3.exoplayer.mediacodec.MediaCodecUtil; +import com.kaltura.playkit.PKLog; + +import java.util.List; + +@UnstableApi +public class MediaCodecSupportFormatHelper { + + private static final PKLog log = PKLog.get("MediaCodecSupport"); + private final Context context; + + public MediaCodecSupportFormatHelper(Context context) { + this.context = context; + } + + public int supportsFormat(MediaCodecSelector mediaCodecSelector, Format format) throws MediaCodecUtil.DecoderQueryException { + String mimeType = format.sampleMimeType; + if (!MimeTypes.isVideo(mimeType)) { + return RendererCapabilities.create(C.FORMAT_UNSUPPORTED_TYPE); + } + @Nullable DrmInitData drmInitData = format.drmInitData; + // Assume encrypted content requires secure decoders. + boolean requiresSecureDecryption = drmInitData != null; + List decoderInfos = getDecoderInfos(context, mediaCodecSelector, format, requiresSecureDecryption, + /* requiresTunnelingDecoder= */ false); + if (requiresSecureDecryption && decoderInfos.isEmpty()) { + // No secure decoders are available. Fall back to non-secure decoders. + decoderInfos = getDecoderInfos(context, mediaCodecSelector, format, + /* requiresSecureDecoder= */ false, + /* requiresTunnelingDecoder= */ false); + } + if (decoderInfos.isEmpty()) { + return RendererCapabilities.create(C.FORMAT_UNSUPPORTED_SUBTYPE); + } + if (!supportsFormatDrm(format)) { + return RendererCapabilities.create(C.FORMAT_UNSUPPORTED_DRM); + } + // Check whether the first decoder supports the format. This is the preferred decoder for the + // format's MIME type, according to the MediaCodecSelector. + MediaCodecInfo decoderInfo = decoderInfos.get(0); + boolean isFormatSupported = decoderInfo.isFormatSupported(format); + log.d("Preferred decoder: " + decoderInfo.name + ", isFormatSupported=" + isFormatSupported); + + // ===== + // KUX-489 + // https://github.com/google/ExoPlayer/issues/10898 + // Check whether the first decoder supports the format's resolution and frame rate. + if (!isFormatSupported && Util.SDK_INT >= 29) { + if (decoderInfo.capabilities != null) { + log.d("Check resolution and frame rate support for " + decoderInfo.name); + log.d("Format: " + format); + isFormatSupported = decoderInfo.capabilities.getVideoCapabilities() + .areSizeAndRateSupported(format.width, format.height, format.frameRate); + log.d("VideoCapabilities areSizeAndRateSupported=" + isFormatSupported); + } + } + // ===== + + boolean isPreferredDecoder = true; + if (!isFormatSupported) { + // Check whether any of the other decoders support the format. + for (int i = 1; i < decoderInfos.size(); i++) { + MediaCodecInfo otherDecoderInfo = decoderInfos.get(i); + if (otherDecoderInfo.isFormatSupported(format)) { + decoderInfo = otherDecoderInfo; + isFormatSupported = true; + isPreferredDecoder = false; + break; + } + } + } + @C.FormatSupport int formatSupport = isFormatSupported ? C.FORMAT_HANDLED : C.FORMAT_EXCEEDS_CAPABILITIES; + @RendererCapabilities.AdaptiveSupport int adaptiveSupport = decoderInfo.isSeamlessAdaptationSupported(format) ? ADAPTIVE_SEAMLESS : ADAPTIVE_NOT_SEAMLESS; + @RendererCapabilities.HardwareAccelerationSupport int hardwareAccelerationSupport = decoderInfo.hardwareAccelerated ? HARDWARE_ACCELERATION_SUPPORTED : HARDWARE_ACCELERATION_NOT_SUPPORTED; + @RendererCapabilities.DecoderSupport int decoderSupport = isPreferredDecoder ? DECODER_SUPPORT_PRIMARY : DECODER_SUPPORT_FALLBACK; + + if (Util.SDK_INT >= 26 && MimeTypes.VIDEO_DOLBY_VISION.equals(format.sampleMimeType) && !Api26.doesDisplaySupportDolbyVision(context)) { + decoderSupport = DECODER_SUPPORT_FALLBACK_MIMETYPE; + } + + @RendererCapabilities.TunnelingSupport int tunnelingSupport = RendererCapabilities.TUNNELING_NOT_SUPPORTED; + if (isFormatSupported) { + List tunnelingDecoderInfos = getDecoderInfos(context, mediaCodecSelector, format, requiresSecureDecryption, + /* requiresTunnelingDecoder= */ true); + if (!tunnelingDecoderInfos.isEmpty()) { + MediaCodecInfo tunnelingDecoderInfo = MediaCodecUtil.getDecoderInfosSortedByFormatSupport(tunnelingDecoderInfos, format).get(0); + if (tunnelingDecoderInfo.isFormatSupported(format) && tunnelingDecoderInfo.isSeamlessAdaptationSupported(format)) { + tunnelingSupport = RendererCapabilities.TUNNELING_SUPPORTED; + } + } + } + + return RendererCapabilities.create(formatSupport, adaptiveSupport, tunnelingSupport, hardwareAccelerationSupport, decoderSupport); + } + + /** + * Returns whether this renderer supports the given {@link Format Format's} DRM scheme. + */ + private static boolean supportsFormatDrm(Format format) { + return format.cryptoType == C.CRYPTO_TYPE_NONE || format.cryptoType == C.CRYPTO_TYPE_FRAMEWORK; + } + + /** + * Returns a list of decoders that can decode media in the specified format, in the priority order + * specified by the {@link MediaCodecSelector}. Note that since the {@link MediaCodecSelector} + * only has access to {@link Format#sampleMimeType}, the list is not ordered to account for + * whether each decoder supports the details of the format (e.g., taking into account the format's + * profile, level, resolution and so on). {@link + * MediaCodecUtil#getDecoderInfosSortedByFormatSupport} can be used to further sort the list into + * an order where decoders that fully support the format come first. + * + * @param mediaCodecSelector The decoder selector. + * @param format The {@link Format} for which a decoder is required. + * @param requiresSecureDecoder Whether a secure decoder is required. + * @param requiresTunnelingDecoder Whether a tunneling decoder is required. + * @return A list of {@link MediaCodecInfo}s corresponding to decoders. May be empty. + * @throws MediaCodecUtil.DecoderQueryException Thrown if there was an error querying decoders. + */ + private static List getDecoderInfos(Context context, MediaCodecSelector mediaCodecSelector, Format format, boolean requiresSecureDecoder, boolean requiresTunnelingDecoder) throws MediaCodecUtil.DecoderQueryException { + if (format.sampleMimeType == null) { + return ImmutableList.of(); + } + if (Util.SDK_INT >= 26 && MimeTypes.VIDEO_DOLBY_VISION.equals(format.sampleMimeType) && !Api26.doesDisplaySupportDolbyVision(context)) { + List alternativeDecoderInfos = getAlternativeDecoderInfos(mediaCodecSelector, format, requiresSecureDecoder, requiresTunnelingDecoder); + if (!alternativeDecoderInfos.isEmpty()) { + return alternativeDecoderInfos; + } + } + return getDecoderInfosSoftMatch(mediaCodecSelector, format, requiresSecureDecoder, requiresTunnelingDecoder); + } + + public static List getAlternativeDecoderInfos(MediaCodecSelector mediaCodecSelector, Format format, boolean requiresSecureDecoder, boolean requiresTunnelingDecoder) throws MediaCodecUtil.DecoderQueryException { + @Nullable String alternativeMimeType = getAlternativeCodecMimeType(format); + if (alternativeMimeType == null) { + return ImmutableList.of(); + } + return mediaCodecSelector.getDecoderInfos(alternativeMimeType, requiresSecureDecoder, requiresTunnelingDecoder); + } + + public static List getDecoderInfosSoftMatch(MediaCodecSelector mediaCodecSelector, Format format, boolean requiresSecureDecoder, boolean requiresTunnelingDecoder) throws MediaCodecUtil.DecoderQueryException { + List decoderInfos = mediaCodecSelector.getDecoderInfos(format.sampleMimeType, requiresSecureDecoder, requiresTunnelingDecoder); + List alternativeDecoderInfos = getAlternativeDecoderInfos(mediaCodecSelector, format, requiresSecureDecoder, requiresTunnelingDecoder); + return ImmutableList.builder().addAll(decoderInfos).addAll(alternativeDecoderInfos).build(); + } + + @RequiresApi(26) + private static final class Api26 { + @DoNotInline + public static boolean doesDisplaySupportDolbyVision(Context context) { + boolean supportsDolbyVision = false; + DisplayManager displayManager = (DisplayManager) context.getSystemService(Context.DISPLAY_SERVICE); + Display display = (displayManager != null) ? displayManager.getDisplay(DEFAULT_DISPLAY) : null; + if (display != null && display.isHdr()) { + int[] supportedHdrTypes = display.getHdrCapabilities().getSupportedHdrTypes(); + for (int hdrType : supportedHdrTypes) { + if (hdrType == Display.HdrCapabilities.HDR_TYPE_DOLBY_VISION) { + supportsDolbyVision = true; + break; + } + } + } + return supportsDolbyVision; + } + } +} diff --git a/playkit/src/main/java/com/kaltura/playkit/InterceptorEvent.kt b/playkit/src/main/java/com/kaltura/playkit/InterceptorEvent.kt new file mode 100644 index 000000000..f684edbf0 --- /dev/null +++ b/playkit/src/main/java/com/kaltura/playkit/InterceptorEvent.kt @@ -0,0 +1,29 @@ +package com.kaltura.playkit + +open class InterceptorEvent(type: Type?): PKEvent { + var type: Type? = null + + init { + this.type = type + } + + enum class Type { + CDN_SWITCHED, + SOURCE_URL_SWITCHED + } + + class CdnSwitchedEvent(eventType: Type?, val cdnCode: String?) : InterceptorEvent(eventType) + + class SourceUrlSwitched(eventType: Type?, val originalUrl: String?, val updatedUrl: String?) : InterceptorEvent(eventType) + + override fun eventType(): Enum<*>? { + return type + } + + companion object { + @JvmField + val cdnSwitched = CdnSwitchedEvent::class.java + @JvmField + val sourceUrlSwitched = SourceUrlSwitched::class.java + } +} diff --git a/playkit/src/main/java/com/kaltura/playkit/KDefaultRenderersFactory.java b/playkit/src/main/java/com/kaltura/playkit/KDefaultRenderersFactory.java new file mode 100644 index 000000000..4acc8f5c4 --- /dev/null +++ b/playkit/src/main/java/com/kaltura/playkit/KDefaultRenderersFactory.java @@ -0,0 +1,129 @@ +package com.kaltura.playkit; + +import android.content.Context; +import android.os.Handler; + +import androidx.annotation.NonNull; + +import com.kaltura.androidx.media3.common.Format; +import com.kaltura.androidx.media3.common.PlaybackException; +import com.kaltura.androidx.media3.exoplayer.DecoderReuseEvaluation; +import com.kaltura.androidx.media3.exoplayer.DefaultRenderersFactory; +import com.kaltura.androidx.media3.exoplayer.ExoPlaybackException; +import com.kaltura.androidx.media3.exoplayer.Renderer; +import com.kaltura.androidx.media3.exoplayer.mediacodec.MediaCodecInfo; +import com.kaltura.androidx.media3.exoplayer.mediacodec.MediaCodecSelector; +import com.kaltura.androidx.media3.exoplayer.mediacodec.MediaCodecUtil; +import com.kaltura.androidx.media3.exoplayer.video.MediaCodecSupportFormatHelper; +import com.kaltura.androidx.media3.exoplayer.video.MediaCodecVideoRenderer; +import com.kaltura.androidx.media3.exoplayer.video.VideoRendererEventListener; +import com.kaltura.playkit.player.PlayerSettings; + +import java.util.ArrayList; + +public class KDefaultRenderersFactory { + private static final PKLog log = PKLog.get("KDefaultRenderersFactory"); + + public static DefaultRenderersFactory createDecoderInitErrorRetryFactory( + Context context, + PlayerSettings playerSettings, + boolean skipFirstCodecReusage + ) { + final MediaCodecSupportFormatHelper mediaCodecSupportFormatHelper = new MediaCodecSupportFormatHelper(context); + return new DefaultRenderersFactory(context) { + @Override + protected void buildVideoRenderers(@NonNull Context context, + int extensionRendererMode, + @NonNull MediaCodecSelector mediaCodecSelector, + boolean enableDecoderFallback, + @NonNull Handler eventHandler, + @NonNull VideoRendererEventListener eventListener, + long allowedVideoJoiningTimeMs, + @NonNull ArrayList out) { + ArrayList renderersArrayList = new ArrayList<>(); + super.buildVideoRenderers(context, + extensionRendererMode, + mediaCodecSelector, + enableDecoderFallback, + eventHandler, + eventListener, + allowedVideoJoiningTimeMs, + renderersArrayList); + for (Renderer renderer : renderersArrayList) { + if (renderer instanceof MediaCodecVideoRenderer) { + out.add(new MediaCodecVideoRenderer( + context, + this.getCodecAdapterFactory(), + mediaCodecSelector, + allowedVideoJoiningTimeMs, + enableDecoderFallback, + eventHandler, + eventListener, + MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY) { + + private boolean firstCodecReusageSkipped = false; + + @Override + public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException { + try { + super.render(positionUs, elapsedRealtimeUs); + } catch (ExoPlaybackException e) { + if (e.errorCode == PlaybackException.ERROR_CODE_DECODER_INIT_FAILED + && playerSettings.getCodecFailureRetryCount() > 0 && playerSettings.getCodecFailureRetryTimeout() > 0) { + for(int i = 0; i < playerSettings.getCodecFailureRetryCount(); i++) { + log.d("Retrying on coded init failure (" + i + ")"); + super.onReset(); + try { + Thread.sleep(playerSettings.getCodecFailureRetryTimeout()); + } catch (Exception e1) { + log.d("Interrupted while sleeping: " + e1.getMessage()); + e1.printStackTrace(); + } + try { + super.render(positionUs, elapsedRealtimeUs); + log.d("Retrying on coded init failure successful"); + // Stop retrying if no exception was thrown + break; + } catch (ExoPlaybackException e2) { + if (e2.errorCode != PlaybackException.ERROR_CODE_DECODER_INIT_FAILED + || i == playerSettings.getCodecFailureRetryCount() - 1) { + // Some other error happened or last retry. Throw exception to the caller + log.d("Codec init retry failed: " + e2.getMessage()); + throw e2; + } + } + } + } + } + } + + @Override + protected int supportsFormat(MediaCodecSelector mediaCodecSelector, Format format) throws MediaCodecUtil.DecoderQueryException { + return mediaCodecSupportFormatHelper.supportsFormat(mediaCodecSelector, format); + } + + @NonNull + @Override + protected DecoderReuseEvaluation canReuseCodec(@NonNull MediaCodecInfo codecInfo, @NonNull Format oldFormat, @NonNull Format newFormat) { + if (playerSettings.canReuseCodec() && (!skipFirstCodecReusage || firstCodecReusageSkipped)) { + return super.canReuseCodec(codecInfo, oldFormat, newFormat); + } else { + if (skipFirstCodecReusage) { + firstCodecReusageSkipped = true; + } + return new DecoderReuseEvaluation(codecInfo.name, + oldFormat, newFormat, + DecoderReuseEvaluation.REUSE_RESULT_NO, + DecoderReuseEvaluation.DISCARD_REASON_APP_OVERRIDE); + } + } + } + ); + } else { + out.add(renderer); + } + } + } + }; + } +} diff --git a/playkit/src/main/java/com/kaltura/playkit/LocalAssetsManager.java b/playkit/src/main/java/com/kaltura/playkit/LocalAssetsManager.java index dd09e864e..715ee5034 100644 --- a/playkit/src/main/java/com/kaltura/playkit/LocalAssetsManager.java +++ b/playkit/src/main/java/com/kaltura/playkit/LocalAssetsManager.java @@ -15,6 +15,7 @@ import android.content.Context; import android.content.SharedPreferences; import android.os.AsyncTask; +import android.os.Build; import android.text.TextUtils; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -27,6 +28,8 @@ import java.io.IOException; import java.util.Collections; import java.util.List; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; /** * Responsible for managing the local(offline) assets. When offline playback of the @@ -37,6 +40,7 @@ public class LocalAssetsManager { private static final PKLog log = PKLog.get("LocalAssetsManager"); private final LocalAssetsManagerHelper helper; + private boolean forceWidevineL3Playback = false; public LocalAssetsManager(Context context, LocalDataStore localDataStore) { helper = new LocalAssetsManagerHelper(context, localDataStore); @@ -50,6 +54,23 @@ public void setLicenseRequestAdapter(PKRequestParams.Adapter licenseRequestAdapt helper.setLicenseRequestAdapter(licenseRequestAdapter); } + /** + * If the device codec is known to fail if security level L1 is used + * then set flag to true, it will force the player to use Widevine L3 + * Will work only SDK level 18 or above + * + * @param forceWidevineL3Playback - force the L3 Playback. Default is false + */ + public void forceWidevineL3Playback(boolean forceWidevineL3Playback) { + this.forceWidevineL3Playback = forceWidevineL3Playback; + if (forceWidevineL3Playback) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { + Executor executor = Executors.newSingleThreadExecutor(); + executor.execute(MediaSupport::provisionWidevineL3); + } + } + } + /** * Will check if passed parameters are valid. * @@ -167,7 +188,7 @@ public void registerAsset(@NonNull final PKMediaSource mediaSource, @NonNull fin } if (drmParams != null) { - registerDrmAsset(localAssetPath, assetId, mediaFormat, drmParams, listener); + registerDrmAsset(localAssetPath, assetId, mediaFormat, drmParams, forceWidevineL3Playback, listener); } else { registerClearAsset(localAssetPath, assetId, mediaFormat, listener); } @@ -176,19 +197,20 @@ public void registerAsset(@NonNull final PKMediaSource mediaSource, @NonNull fin /** * Will register the drm asset and store the keyset id and {@link PKMediaFormat} in local storage. * - * @param localAssetPath - the local asset path of the asset. - * @param assetId - the asset id. - * @param mediaFormat - the media format converted to byte[]. - * @param drmParams - drm params of the media. - * @param listener - notify about the success/fail after the completion of the registration process. + * @param localAssetPath - the local asset path of the asset. + * @param assetId - the asset id. + * @param mediaFormat - the media format converted to byte[]. + * @param drmParams - drm params of the media. + * @param forceWidevineL3Playback - if the device codec is known to fail if security level L1 is used then set flag to true, it will force the player to use Widevine L3 + * @param listener - notify about the success/fail after the completion of the registration process. */ - private void registerDrmAsset(final String localAssetPath, final String assetId, final PKMediaFormat mediaFormat, final PKDrmParams drmParams, final AssetRegistrationListener listener) { + private void registerDrmAsset(final String localAssetPath, final String assetId, final PKMediaFormat mediaFormat, final PKDrmParams drmParams, boolean forceWidevineL3Playback, final AssetRegistrationListener listener) { doInBackground(() -> { try { DrmAdapter drmAdapter = DrmAdapter.getDrmAdapter(drmParams.getScheme(), helper.context, helper.localDataStore); String licenseUri = drmParams.getLicenseUri(); - boolean isRegistered = drmAdapter.registerAsset(localAssetPath, assetId, licenseUri, helper.licenseRequestParamAdapter, listener); + boolean isRegistered = drmAdapter.registerAsset(localAssetPath, assetId, licenseUri, helper.licenseRequestParamAdapter, forceWidevineL3Playback, listener); if (isRegistered) { helper.saveMediaFormat(assetId, mediaFormat, drmParams.getScheme()); } @@ -216,8 +238,6 @@ private void registerClearAsset(String localAssetPath, String assetId, PKMediaFo public void unregisterAsset(@NonNull final String localAssetPath, @NonNull final String assetId, final AssetRemovalListener listener) { - - PKDrmParams.Scheme scheme = helper.getLocalAssetScheme(assetId); if (scheme == null) { @@ -228,7 +248,7 @@ public void unregisterAsset(@NonNull final String localAssetPath, final DrmAdapter drmAdapter = DrmAdapter.getDrmAdapter(scheme, helper.context, helper.localDataStore); doInBackground(() -> { - drmAdapter.unregisterAsset(localAssetPath, assetId, localAssetPath1 -> { + drmAdapter.unregisterAsset(localAssetPath, assetId, forceWidevineL3Playback, localAssetPath1 -> { helper.mainHandler.post(() -> { removeAsset(localAssetPath1, assetId, listener); }); @@ -262,7 +282,7 @@ private void checkDrmAssetStatus(final String localAssetPath, final String asset final DrmAdapter drmAdapter = DrmAdapter.getDrmAdapter(scheme, helper.context, helper.localDataStore); - doInBackground(() -> drmAdapter.checkAssetStatus(localAssetPath, assetId, (localAssetPath1, expiryTimeSeconds, availableTimeSeconds, isRegistered) -> { + doInBackground(() -> drmAdapter.checkAssetStatus(localAssetPath, assetId, forceWidevineL3Playback, (localAssetPath1, expiryTimeSeconds, availableTimeSeconds, isRegistered) -> { if (listener != null) { helper.mainHandler.post(() -> { listener.onStatus(localAssetPath1, expiryTimeSeconds, availableTimeSeconds, isRegistered); diff --git a/playkit/src/main/java/com/kaltura/playkit/LocalAssetsManagerExo.java b/playkit/src/main/java/com/kaltura/playkit/LocalAssetsManagerExo.java index f2b30cf86..108947a5a 100644 --- a/playkit/src/main/java/com/kaltura/playkit/LocalAssetsManagerExo.java +++ b/playkit/src/main/java/com/kaltura/playkit/LocalAssetsManagerExo.java @@ -4,8 +4,8 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.kaltura.android.exoplayer2.MediaItem; -import com.kaltura.android.exoplayer2.source.MediaSource; +import com.kaltura.androidx.media3.common.MediaItem; +import com.kaltura.androidx.media3.exoplayer.source.MediaSource; import com.kaltura.playkit.drm.WidevineModularAdapter; import java.util.Map; @@ -48,10 +48,10 @@ private static LocalAssetsManager.AssetStatus assetStatusFromWidevineMap(Map map = adapter.checkAssetStatus(drmInitData); + Map map = adapter.checkAssetStatus(drmInitData, forceWidevineL3Playback); return assetStatusFromWidevineMap(map); } catch (LocalAssetsManager.RegisterException e) { return LocalAssetsManager.AssetStatus.invalid; @@ -104,6 +104,7 @@ public LocalAssetsManager.AssetStatus getDrmStatus(String assetId, byte[] drmIni public static class LocalExoMediaItem extends LocalAssetsManager.LocalMediaSource { private MediaItem exoMediaItem; + PKDrmParams.Scheme scheme; /** * @param localDataStore - the storage from where drm keySetId is stored. @@ -114,15 +115,22 @@ public static class LocalExoMediaItem extends LocalAssetsManager.LocalMediaSourc super(localDataStore, null, assetId, scheme); this.exoMediaItem = exoMediaItem; + this.scheme = scheme; } public MediaItem getExoMediaItem() { return exoMediaItem; } + + public PKDrmParams.Scheme getScheme() { + return scheme; + } } + public static class LocalExoMediaSource extends LocalAssetsManager.LocalMediaSource { private MediaSource exoMediaSource; + PKDrmParams.Scheme scheme; /** * @param localDataStore - the storage from where drm keySetId is stored. @@ -133,10 +141,15 @@ public static class LocalExoMediaSource extends LocalAssetsManager.LocalMediaSou super(localDataStore, null, assetId, scheme); this.exoMediaSource = exoMediaSource; + this.scheme = scheme; } public MediaSource getExoMediaSource() { return exoMediaSource; } + + public PKDrmParams.Scheme getScheme() { + return scheme; + } } } diff --git a/playkit/src/main/java/com/kaltura/playkit/LocalAssetsManagerHelper.java b/playkit/src/main/java/com/kaltura/playkit/LocalAssetsManagerHelper.java index 85395f08e..852e092bd 100644 --- a/playkit/src/main/java/com/kaltura/playkit/LocalAssetsManagerHelper.java +++ b/playkit/src/main/java/com/kaltura/playkit/LocalAssetsManagerHelper.java @@ -80,7 +80,6 @@ void saveMediaFormat(String assetId, PKMediaFormat mediaFormat, PKDrmParams.Sche localDataStore.save(buildAssetKey(assetId), buildMediaFormatValueAsByteArray(mediaFormat, scheme)); } - void removeAssetKey(String assetId) { localDataStore.remove(buildAssetKey(assetId)); } diff --git a/playkit/src/main/java/com/kaltura/playkit/MessageBus.java b/playkit/src/main/java/com/kaltura/playkit/MessageBus.java index 8027a9bdb..19bbdd5c4 100644 --- a/playkit/src/main/java/com/kaltura/playkit/MessageBus.java +++ b/playkit/src/main/java/com/kaltura/playkit/MessageBus.java @@ -17,6 +17,7 @@ import androidx.annotation.Nullable; import java.util.Collections; +import java.util.ConcurrentModificationException; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; @@ -42,10 +43,18 @@ public void post(final PKEvent event) { // Listeners that are listening for this event final Set postListeners = new HashSet<>(); - // By event type (PlayerEvent.DURATION_CHANGED etc) - postListeners.addAll(safeSet(this.listeners.get(event.eventType()))); - // By event class (PlayerEvent.DurationChanged.class etc) - postListeners.addAll(safeSet(this.listeners.get(event.getClass()))); + try { + // By event type (PlayerEvent.DURATION_CHANGED etc) + postListeners.addAll(safeSet(this.listeners.get(event.eventType()))); + } catch (ConcurrentModificationException ex) { + postListeners.addAll(safeSet(this.listeners.get(event.eventType()))); + } + try { + // By event class (PlayerEvent.DurationChanged.class etc) + postListeners.addAll(safeSet(this.listeners.get(event.getClass()))); + } catch (ConcurrentModificationException ex) { + postListeners.addAll(safeSet(this.listeners.get(event.getClass()))); + } if (!postListeners.isEmpty()) { postHandler.post(() -> { diff --git a/playkit/src/main/java/com/kaltura/playkit/PKAbrFilter.java b/playkit/src/main/java/com/kaltura/playkit/PKAbrFilter.java new file mode 100644 index 000000000..a5f93c163 --- /dev/null +++ b/playkit/src/main/java/com/kaltura/playkit/PKAbrFilter.java @@ -0,0 +1,9 @@ +package com.kaltura.playkit; + +public enum PKAbrFilter { + NONE, + BITRATE, + HEIGHT, + WIDTH, + PIXEL +} diff --git a/playkit/src/main/java/com/kaltura/playkit/PKDeviceCapabilities.java b/playkit/src/main/java/com/kaltura/playkit/PKDeviceCapabilities.java index aca0aafc1..ca2a3b7f9 100644 --- a/playkit/src/main/java/com/kaltura/playkit/PKDeviceCapabilities.java +++ b/playkit/src/main/java/com/kaltura/playkit/PKDeviceCapabilities.java @@ -172,7 +172,7 @@ private JSONObject collect() { return root; } - private boolean isKalturaPlayerAvailable() { + public static boolean isKalturaPlayerAvailable() { try { Class.forName( "com.kaltura.tvplayer.KalturaPlayer" ); return true; diff --git a/playkit/src/main/java/com/kaltura/playkit/PKDrmParams.java b/playkit/src/main/java/com/kaltura/playkit/PKDrmParams.java index 0e5d78d0d..05ed2fd20 100644 --- a/playkit/src/main/java/com/kaltura/playkit/PKDrmParams.java +++ b/playkit/src/main/java/com/kaltura/playkit/PKDrmParams.java @@ -39,7 +39,7 @@ public boolean isSupported() { supported = MediaSupport.widevineModular(); break; case PlayReadyCENC: - supported = MediaSupport.playReady(); + supported = MediaSupport.playready(); break; case WidevineClassic: supported = MediaSupport.widevineClassic(); @@ -47,8 +47,6 @@ public boolean isSupported() { case PlayReadyClassic: case FairPlay: case Unknown: - supported = false; - break; default: supported = false; break; diff --git a/playkit/src/main/java/com/kaltura/playkit/PKMediaEntry.java b/playkit/src/main/java/com/kaltura/playkit/PKMediaEntry.java index fea41fe33..10dccc48a 100644 --- a/playkit/src/main/java/com/kaltura/playkit/PKMediaEntry.java +++ b/playkit/src/main/java/com/kaltura/playkit/PKMediaEntry.java @@ -34,11 +34,11 @@ public class PKMediaEntry implements Parcelable { private boolean isVRMediaType; private Map metadata; private List externalSubtitleList; + private String externalVttThumbnailUrl; public PKMediaEntry() { } - public PKMediaEntry setId(String id) { this.id = id; return this; @@ -59,6 +59,33 @@ public PKMediaEntry setMetadata(Map metadata) { return this; } + public PKMediaEntry setExternalSubtitleList(List externalSubtitleList) { + this.externalSubtitleList = externalSubtitleList; + if (externalSubtitleList != null) { + ListIterator externalSubtitleListIterator = externalSubtitleList.listIterator(); + + while (externalSubtitleListIterator.hasNext()) { + PKExternalSubtitle pkExternalSubtitle = externalSubtitleListIterator.next(); + PKSubtitleFormat urlFormat = PKSubtitleFormat.valueOfUrl(pkExternalSubtitle.getUrl()); + + if (urlFormat != null && pkExternalSubtitle.getMimeType() == null) { + pkExternalSubtitle.setMimeType(urlFormat); + } + + if (TextUtils.isEmpty(pkExternalSubtitle.getUrl()) || (urlFormat != null && !urlFormat.mimeType.equals(pkExternalSubtitle.getMimeType()))) { + externalSubtitleListIterator.remove(); + } + } + } + + return this; + } + + public PKMediaEntry setExternalVttThumbnailUrl(String externalVttThumbnailUrl) { + this.externalVttThumbnailUrl = externalVttThumbnailUrl; + return this; + } + public String getId() { return id; } @@ -110,26 +137,8 @@ public List getExternalSubtitleList() { return externalSubtitleList; } - public PKMediaEntry setExternalSubtitleList(List externalSubtitleList) { - this.externalSubtitleList = externalSubtitleList; - if (externalSubtitleList != null) { - ListIterator externalSubtitleListIterator = externalSubtitleList.listIterator(); - - while (externalSubtitleListIterator.hasNext()) { - PKExternalSubtitle pkExternalSubtitle = externalSubtitleListIterator.next(); - PKSubtitleFormat urlFormat = PKSubtitleFormat.valueOfUrl(pkExternalSubtitle.getUrl()); - - if (urlFormat != null && pkExternalSubtitle.getMimeType() == null) { - pkExternalSubtitle.setMimeType(urlFormat); - } - - if (TextUtils.isEmpty(pkExternalSubtitle.getUrl()) || (urlFormat != null && !urlFormat.mimeType.equals(pkExternalSubtitle.getMimeType()))) { - externalSubtitleListIterator.remove(); - } - } - } - - return this; + public String getExternalVttThumbnailUrl() { + return externalVttThumbnailUrl; } public enum MediaEntryType { @@ -166,6 +175,7 @@ public void writeToParcel(Parcel dest, int flags) { dest.writeInt(-1); } dest.writeTypedList(this.externalSubtitleList); + dest.writeString(this.externalVttThumbnailUrl); } protected PKMediaEntry(Parcel in) { @@ -190,6 +200,7 @@ protected PKMediaEntry(Parcel in) { } } this.externalSubtitleList = in.createTypedArrayList(PKExternalSubtitle.CREATOR); + this.externalVttThumbnailUrl = in.readString(); } public static final Creator CREATOR = new Creator() { diff --git a/playkit/src/main/java/com/kaltura/playkit/PKMediaFormat.java b/playkit/src/main/java/com/kaltura/playkit/PKMediaFormat.java index 296bbad17..4a94f5a56 100644 --- a/playkit/src/main/java/com/kaltura/playkit/PKMediaFormat.java +++ b/playkit/src/main/java/com/kaltura/playkit/PKMediaFormat.java @@ -15,6 +15,7 @@ import android.net.Uri; import java.util.HashMap; +import java.util.Locale; import java.util.Map; public enum PKMediaFormat { @@ -52,7 +53,7 @@ public static PKMediaFormat valueOfExt(String ext) { public static PKMediaFormat valueOfUrl(String sourceURL) { PKMediaFormat mediaFormat = null; if (sourceURL != null) { - if (sourceURL.toLowerCase().startsWith("udp://")) { + if (sourceURL.toLowerCase(Locale.ENGLISH).startsWith("udp://")) { return PKMediaFormat.udp; } String path = Uri.parse(sourceURL).getPath(); diff --git a/playkit/src/main/java/com/kaltura/playkit/PKMediaSource.java b/playkit/src/main/java/com/kaltura/playkit/PKMediaSource.java index a8e2fe980..d4eccf61d 100644 --- a/playkit/src/main/java/com/kaltura/playkit/PKMediaSource.java +++ b/playkit/src/main/java/com/kaltura/playkit/PKMediaSource.java @@ -14,6 +14,7 @@ import android.os.Parcel; import android.os.Parcelable; +import android.text.TextUtils; import java.util.Collections; import java.util.List; @@ -62,7 +63,7 @@ public PKMediaSource setDrmData(List drmData) { } public PKMediaFormat getMediaFormat() { - if (mediaFormat == null && url != null) { + if (mediaFormat == null && !TextUtils.isEmpty(url)) { this.mediaFormat = PKMediaFormat.valueOfUrl(url); } return mediaFormat; diff --git a/playkit/src/main/java/com/kaltura/playkit/PKPlaybackException.kt b/playkit/src/main/java/com/kaltura/playkit/PKPlaybackException.kt new file mode 100644 index 000000000..6d0091c50 --- /dev/null +++ b/playkit/src/main/java/com/kaltura/playkit/PKPlaybackException.kt @@ -0,0 +1,158 @@ +package com.kaltura.playkit + +import android.media.MediaCodec.CryptoException +import android.util.Pair +import com.kaltura.androidx.media3.exoplayer.ExoPlaybackException +import com.kaltura.androidx.media3.exoplayer.ExoTimeoutException +import com.kaltura.androidx.media3.common.PlaybackException +import com.kaltura.androidx.media3.exoplayer.mediacodec.MediaCodecRenderer.DecoderInitializationException +import com.kaltura.androidx.media3.exoplayer.mediacodec.MediaCodecUtil +import com.kaltura.playkit.player.PKPlayerErrorType + +class PKPlaybackException { + + companion object { + + private val log = PKLog.get("PKPlaybackException") + + // Miscellaneous errors (1xxx). + private const val MISC_ERROR_CODE = 1000 + // Input/Output errors (2xxx). + private const val IO_ERROR_CODE = 2000 + // Content parsing errors (3xxx). + private const val CONTENT_PARSING_ERROR_CODE = 3000 + // Decoding errors (4xxx). + private const val DECODING_ERROR_CODE = 4000 + // AudioTrack errors (5xxx). + private const val AUDIO_TRACK_ERROR_CODE = 5000 + // DRM errors (6xxx). + private const val DRM_ERROR_CODE = 6000 + private const val CUSTOM_ERROR_CODE = 1000000 + + @JvmStatic + fun getPlaybackExceptionType(playbackException: PlaybackException): Pair { + var errorStr = playbackException.errorCodeName + + val playerErrorType = when (playbackException.errorCode) { + PlaybackException.ERROR_CODE_TIMEOUT -> { + PKPlayerErrorType.TIMEOUT + } + in (MISC_ERROR_CODE + 1) until IO_ERROR_CODE -> { + PKPlayerErrorType.MISCELLANEOUS + } + in (IO_ERROR_CODE + 1) until CONTENT_PARSING_ERROR_CODE -> { + PKPlayerErrorType.IO_ERROR + } + in (CONTENT_PARSING_ERROR_CODE + 1) until DECODING_ERROR_CODE -> { + PKPlayerErrorType.SOURCE_ERROR + } + in (DECODING_ERROR_CODE + 1) until AUDIO_TRACK_ERROR_CODE, + in (AUDIO_TRACK_ERROR_CODE + 1) until DRM_ERROR_CODE -> { + PKPlayerErrorType.RENDERER_ERROR + } + in (DRM_ERROR_CODE + 1) until CUSTOM_ERROR_CODE -> { + PKPlayerErrorType.DRM_ERROR + } + else -> { + PKPlayerErrorType.UNEXPECTED + } + } + + getExoPlaybackException(playbackException, playerErrorType)?.let { errorMessage -> + errorStr = "$errorMessage-$errorStr" + } + + return Pair(playerErrorType, errorStr) + } + + /** + * If Playback exception is ExoPlaybackException then + * fire the error details in the traditional way + * + * @param playbackException Exception on player error + */ + private fun getExoPlaybackException(playbackException: PlaybackException, playerErrorType: PKPlayerErrorType): String? { + if (playbackException is ExoPlaybackException) { + var errorMessage = playbackException.message + when (playbackException.type) { + ExoPlaybackException.TYPE_SOURCE -> { + errorMessage = getSourceErrorMessage(playbackException, errorMessage) + } + + ExoPlaybackException.TYPE_RENDERER -> { + errorMessage = getRendererExceptionDetails(playbackException, errorMessage) + } + + ExoPlaybackException.TYPE_UNEXPECTED -> { + errorMessage = getUnexpectedErrorMessage(playbackException, errorMessage) + } + } + val errorStr = errorMessage ?: "Player error: " + playerErrorType.name + log.e(errorStr) + return errorStr + } + + return null + } + + private fun getRendererExceptionDetails( + error: ExoPlaybackException, + errorMessage: String? + ): String? { + var message: String? = errorMessage + when (val cause = error.rendererException) { + is DecoderInitializationException -> { + // Special case for decoder initialization failures. + message = if (cause.codecInfo == null) { + when { + cause.cause is MediaCodecUtil.DecoderQueryException -> { + "Unable to query device decoders" + } + cause.secureDecoderRequired -> { + "This device does not provide a secure decoder for " + cause.mimeType + } + else -> { + "This device does not provide a decoder for " + cause.mimeType + } + } + } else { + "Unable to instantiate decoder" + cause.codecInfo?.name + } + } + is CryptoException -> { + message = cause.message ?: "MediaCodec.CryptoException occurred" + message = "DRM_ERROR:$message" + } + is ExoTimeoutException -> { + message = cause.message ?: "Exo timeout exception" + message = "EXO_TIMEOUT_EXCEPTION:$message" + } + } + return message + } + + private fun getUnexpectedErrorMessage( + error: ExoPlaybackException, + errorMessage: String? + ): String? { + var message: String? = errorMessage + val cause: Exception = error.unexpectedException + cause.cause?.let { + message = it.message + } + return message + } + + private fun getSourceErrorMessage( + error: ExoPlaybackException, + errorMessage: String? + ): String? { + var message: String? = errorMessage + val cause: Exception = error.sourceException + cause.cause?.let { + message = it.message + } + return message + } + } +} diff --git a/playkit/src/main/java/com/kaltura/playkit/PKRequestConfig.kt b/playkit/src/main/java/com/kaltura/playkit/PKRequestConfig.kt new file mode 100644 index 000000000..f869a1d7b --- /dev/null +++ b/playkit/src/main/java/com/kaltura/playkit/PKRequestConfig.kt @@ -0,0 +1,46 @@ +package com.kaltura.playkit + +import androidx.annotation.NonNull +import com.kaltura.androidx.media3.datasource.DefaultHttpDataSource +import com.kaltura.playkit.player.CustomLoadErrorHandlingPolicy + +/** + * Request Configuration for [com.kaltura.playkit.player.ExoPlayerWrapper.getHttpDataSourceFactory] + */ +data class PKRequestConfig @JvmOverloads constructor(@NonNull var crossProtocolRedirectEnabled: Boolean = false, + @NonNull var readTimeoutMs: Int = DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS, + @NonNull var connectTimeoutMs: Int = DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS, + /* Maximum number of times to retry a load in the case of a load error, before propagating the error.*/ + @NonNull var maxRetries: Int = CustomLoadErrorHandlingPolicy.LOADABLE_RETRY_COUNT_UNSET) { + class Builder { + + private var crossProtocolRedirectEnabled: Boolean = false + private var readTimeoutMs: Int = DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS + private var connectTimeoutMs: Int = DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS + private var maxRetries = CustomLoadErrorHandlingPolicy.LOADABLE_RETRY_COUNT_UNSET + + fun setCrossProtocolRedirectEnabled(crossProtocolRedirectEnabled: Boolean): Builder { + this.crossProtocolRedirectEnabled = crossProtocolRedirectEnabled + return this + } + + fun setReadTimeoutMs(readTimeoutMs: Int): Builder { + this.readTimeoutMs = readTimeoutMs + return this + } + + fun setConnectTimeoutMs(connectTimeoutMs: Int): Builder { + this.connectTimeoutMs = connectTimeoutMs + return this + } + + fun setMaxRetries(maxRetries: Int): Builder { + this.maxRetries = maxRetries + return this + } + + fun build(): PKRequestConfig { + return PKRequestConfig(crossProtocolRedirectEnabled, readTimeoutMs, connectTimeoutMs, maxRetries) + } + } +} diff --git a/playkit/src/main/java/com/kaltura/playkit/PKRequestParams.java b/playkit/src/main/java/com/kaltura/playkit/PKRequestParams.java index b83ac70c1..be6aee9cb 100644 --- a/playkit/src/main/java/com/kaltura/playkit/PKRequestParams.java +++ b/playkit/src/main/java/com/kaltura/playkit/PKRequestParams.java @@ -14,6 +14,9 @@ import android.net.Uri; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONObject; import java.util.HashMap; import java.util.Map; @@ -22,12 +25,17 @@ public class PKRequestParams { public final Uri url; @NonNull public final Map headers; + @Nullable public Map postBody; public PKRequestParams(Uri url, Map headers) { this.url = url; this.headers = headers != null ? headers : new HashMap<>(); } + public void setPostBody(@Nullable Map postBody) { + this.postBody = postBody; + } + /** * PKRequestParams.Adapter allows adapting (changing) the request parameters before sending the * request to the server. @@ -46,5 +54,15 @@ public interface Adapter { void updateParams(Player player); String getApplicationName(); + + /** + * Return a potentially modified DrmData. + * The implementation can return the JSONObject by adding the post params. + * + * @param drmData The opaque key request data + * @return Modified JSONObject + */ + + @Nullable default JSONObject buildDrmPostParams(@NonNull byte[] drmData) { return null;} } } diff --git a/playkit/src/main/java/com/kaltura/playkit/PKSubtitleFormat.java b/playkit/src/main/java/com/kaltura/playkit/PKSubtitleFormat.java index c11e09ed5..f46d0d8de 100644 --- a/playkit/src/main/java/com/kaltura/playkit/PKSubtitleFormat.java +++ b/playkit/src/main/java/com/kaltura/playkit/PKSubtitleFormat.java @@ -14,14 +14,15 @@ import android.net.Uri; -import com.kaltura.android.exoplayer2.util.MimeTypes; +import com.kaltura.androidx.media3.common.MimeTypes; import java.util.HashMap; import java.util.Map; public enum PKSubtitleFormat { vtt(MimeTypes.TEXT_VTT, "vtt"), - srt(MimeTypes.APPLICATION_SUBRIP, "srt"); + srt(MimeTypes.APPLICATION_SUBRIP, "srt"), + ttml(MimeTypes.APPLICATION_TTML, "ttml"); public final String mimeType; public final String pathExt; diff --git a/playkit/src/main/java/com/kaltura/playkit/PKTracksAvailableStatus.java b/playkit/src/main/java/com/kaltura/playkit/PKTracksAvailableStatus.java new file mode 100644 index 000000000..f76416c14 --- /dev/null +++ b/playkit/src/main/java/com/kaltura/playkit/PKTracksAvailableStatus.java @@ -0,0 +1,7 @@ +package com.kaltura.playkit; + +public enum PKTracksAvailableStatus { + NEW, + UPDATED, + RESET +} diff --git a/playkit/src/main/java/com/kaltura/playkit/Player.java b/playkit/src/main/java/com/kaltura/playkit/Player.java index d0fd88be4..33827ae93 100644 --- a/playkit/src/main/java/com/kaltura/playkit/Player.java +++ b/playkit/src/main/java/com/kaltura/playkit/Player.java @@ -15,17 +15,26 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.kaltura.androidx.media3.datasource.cache.Cache; +import com.kaltura.playkit.ads.AdvertisingConfig; +import com.kaltura.playkit.ads.PKAdvertisingController; import com.kaltura.playkit.player.ABRSettings; +import com.kaltura.playkit.player.DRMSettings; import com.kaltura.playkit.player.LoadControlBuffers; +import com.kaltura.playkit.player.MulticastSettings; import com.kaltura.playkit.player.PKAspectRatioResizeMode; +import com.kaltura.playkit.player.PKLowLatencyConfig; import com.kaltura.playkit.player.PKMaxVideoSize; import com.kaltura.playkit.player.PlayerView; import com.kaltura.playkit.player.SubtitleStyleSettings; import com.kaltura.playkit.player.VideoCodecSettings; import com.kaltura.playkit.player.AudioCodecSettings; +import com.kaltura.playkit.player.thumbnail.ThumbnailInfo; import com.kaltura.playkit.player.vr.VRSettings; import com.kaltura.playkit.utils.Consts; +import java.util.List; + @SuppressWarnings("unused") public interface Player { @@ -92,7 +101,9 @@ interface Settings { * * @param crossProtocolRedirectEnabled - true if should do cross protocol redirect. * @return - Player Settings. + * @deprecated Please use {@link com.kaltura.playkit.PKRequestConfig} to set crossProtocolRedirect */ + @Deprecated Settings setAllowCrossProtocolRedirect(boolean crossProtocolRedirectEnabled); /** @@ -100,7 +111,9 @@ interface Settings { * * @param allowClearLead - should enable/disable clear lead playback default true (enabled) * @return - Player Settings. + * @deprecated Please use {@link #setDRMSettings(DRMSettings)} to set allowClearLead. */ + @Deprecated Settings allowClearLead(boolean allowClearLead); /** @@ -110,6 +123,7 @@ interface Settings { * @return - Player Settings. */ Settings enableDecoderFallback(boolean enableDecoderFallback); + /** * Decide if player should use secure rendering on the surface. * Known limitation - when useTextureView set to true and isSurfaceSecured set to true - @@ -177,7 +191,7 @@ interface Settings { Settings setSubtitleStyle(SubtitleStyleSettings subtitleStyleSettings); /** - * Set the Player's ABR settings + * Set the Player's ABR settings * * @param abrSettings ABR settings * @return - Player Settings @@ -185,17 +199,17 @@ interface Settings { Settings setABRSettings(ABRSettings abrSettings); /** - * Set the Player's AspectRatio resize Mode + * Set the Player's AspectRatio resize Mode * * @param resizeMode Resize mode * @return - Player Settings */ - Settings setSurfaceAspectRatioResizeMode(PKAspectRatioResizeMode resizeMode); + Settings setSurfaceAspectRatioResizeMode(@NonNull PKAspectRatioResizeMode resizeMode); /** * Do not prepare the content player when the Ad starts(if exists); instead content player will be prepared * when content_resume_requested is called. - * + *

* Default value is set to 'false'. * * @param forceSinglePlayerEngine Do not prepare the content player while Ad is playing @@ -203,8 +217,48 @@ interface Settings { */ Settings forceSinglePlayerEngine(boolean forceSinglePlayerEngine); + /** + * This flag is only for the HLS Streams. Default is `true`. + *
+ * Player will only use the information in the multivariant playlist to prepare the stream, + * which works if the `#EXT-X-STREAM-INF` tags contain the `CODECS` attribute. + *
+ * You may need to disable this feature if your media segments contain muxed + * closed-caption tracks that are not declared in the multivariant playlist with a + * `#EXT-X-MEDIA:TYPE=CLOSED-CAPTIONS` tag. Otherwise, these closed-caption tracks + * won't be detected and played. + *
+ *
+ *
+ * You can disable chunkless preparation by setting this flag to `false`. + * Note that this + * will increase start up time as Player needs to download a media segment to + * discover these additional tracks and it is preferable to declare the + * closed-caption tracks in the multivariant playlist instead. + *
+ * @param allowChunklessPreparation chunkless preparation is allowed or not + * @return - Player Settings + */ + Settings allowChunklessPreparation(boolean allowChunklessPreparation); + + /** + * When enabled, the `DefaultTrackSelector` will prefer audio tracks whose + * channel count does not exceed the device output capabilities. + * + * On handheld devices, the DefaultTrackSelector will prefer stereo/mono over + * multichannel audio formats, unless the multichannel format can be Spatialized (Android 12L+) + * or is a Dolby surround sound format. In addition, on devices that support audio spatialization, + * the DefaultTrackSelector will monitor for changes in the Spatializer properties and + * trigger `tracksAvailable` event upon these. + * + * @param enabled Default is disabled. + * @return - Player Settings + */ + Settings constrainAudioChannelCountToDeviceCapabilities(boolean enabled); + /** * Set the flag which handles the video view + * * @param hide video surface visibility * @return - Player Settings */ @@ -212,6 +266,7 @@ interface Settings { /** * Set VR Settings on the player + * * @param vrSettings vr configuration * @return - Player Settings */ @@ -219,18 +274,21 @@ interface Settings { /** * Set Preferred codec for video track + * * @param videoCodecSettings Use {@link VideoCodecSettings} */ Settings setPreferredVideoCodecSettings(VideoCodecSettings videoCodecSettings); /** * Set Preferred codec for audio track + * * @param audioCodecSettings Use {@link AudioCodecSettings} */ Settings setPreferredAudioCodecSettings(AudioCodecSettings audioCodecSettings); /** * Set custom load control strategy + * * @param loadControlStrategy object implementing LoadControlStrategy interface * @return - Player Settings */ @@ -238,6 +296,7 @@ interface Settings { /** * Set Tunneled Audio Playback + * * @param isTunneledAudioPlayback audio tunnelling enabled * @return - Player Settings */ @@ -278,13 +337,53 @@ interface Settings { */ Settings setHandleAudioFocus(boolean handleAudioFocus); + /** + * Set shutterStaysOnRenderedFirstFrame - Whether shutter view being hide on first frame rendered + * + * @param shutterStaysOnRenderedFirstFrame + * @return - Player Settings + */ + Settings setShutterStaysOnRenderedFirstFrame(boolean shutterStaysOnRenderedFirstFrame); + + /** + * Set muteWhenShutterVisible - Whether player being muted when shutter view is visible + * + * @param muteWhenShutterVisible + * @return - Player Settings + */ + Settings setMuteWhenShutterVisible(boolean muteWhenShutterVisible); + + /** + * Set canReuseCodec - Whether player will not use default codec re-usage logic on each media change + * + * @param canReuseCodec + * @return - Player Settings + */ + Settings setCanReuseCodec(boolean canReuseCodec); + + /** + * Set codecFailureRetryCount - count of the codec initialization failure retries + * + * @param codecFailureRetryCount + * @return - Player Settings + */ + Settings setCodecFailureRetryCount(int codecFailureRetryCount); + + /** + * Set codecFailureRetryTimeout - timeout to wait between retries of codec re-initialization on failure + * + * @param codecFailureRetryTimeout + * @return - Player Settings + */ + Settings setCodecFailureRetryTimeout(int codecFailureRetryTimeout); + /** * Set preference to choose internal subtitles over external subtitles (Only in the case if the same language is present * in both Internal and External subtitles) - Default is true (Internal is preferred) * * @param subtitlePreference PKSubtitlePreference.INTERNAL, Internal will be present and External subtitle will be discarded - * PKSubtitlePreference.EXTERNAL, External will be present and Internal subtitle will be discarded - * PKSubtitlePreference.OFF, Both internal and external subtitles will be there + * PKSubtitlePreference.EXTERNAL, External will be present and Internal subtitle will be discarded + * PKSubtitlePreference.OFF, Both internal and external subtitles will be there * @return - Player Settings */ Settings setSubtitlePreference(PKSubtitlePreference subtitlePreference); @@ -298,16 +397,20 @@ interface Settings { * * @param maxVideoSize - Max allowed video width and height * @return - Player Settings + * @deprecated Please use {@link #setABRSettings(ABRSettings)} to set max video size. */ + @Deprecated Settings setMaxVideoSize(@NonNull PKMaxVideoSize maxVideoSize); /** + * * Sets the maximum allowed video bitrate. * * @param maxVideoBitrate Maximum allowed video bitrate in bits per second. * @return - Player Settings + @deprecated Please use {@link #setABRSettings(ABRSettings)} to set max video bitrate. */ - + @Deprecated Settings setMaxVideoBitrate(@NonNull Integer maxVideoBitrate); /** @@ -325,6 +428,50 @@ interface Settings { * @return - Player Settings */ Settings setMaxAudioChannelCount(int maxAudioChannelCount); + + /** + * Sets the multicastSettings for udp streams. + * + * @param multicastSettings - maxPacketSize default = 3000 & socketTimeoutMillis default = 10000 + * @return - Player Settings + */ + Settings setMulticastSettings(MulticastSettings multicastSettings); + + /** + * If the device codec is known to fail if security level L1 is used + * then set flag to true, it will force the player to use Widevine L3 + * Will work only SDK level 18 or above + * + * @param forceWidevineL3Playback - force the L3 Playback. Default is false + * @return - Player Settings + * @deprecated Please use {@link #setDRMSettings(DRMSettings)} to forceWidevineL3Playback. + */ + @Deprecated + Settings forceWidevineL3Playback(boolean forceWidevineL3Playback); + + /** + * Creates a DRM playback configuration. + * + * @param drmSettings - Configuration for DRM playback Widevine/Playready default is Widevine + * @return - Player Settings + */ + Settings setDRMSettings(DRMSettings drmSettings); + + /** + * Creates a Low Latency Live playback configuration. + * + * @param pkLowLatencyConfig - Configuration for Low Latency + * @return - Player Settings + */ + Settings setPKLowLatencyConfig(PKLowLatencyConfig pkLowLatencyConfig); + + /** + * Creates a request configuration for HttpDataSourceFactory {@link com.kaltura.playkit.player.ExoPlayerWrapper}. + * + * @param pkRequestConfig - Configuration for PKRequestConfig + * @return - Player Settings + */ + Settings setPKRequestConfig(PKRequestConfig pkRequestConfig); } /** @@ -341,7 +488,22 @@ interface Settings { */ void prepare(@NonNull PKMediaConfig playerConfig); + /** + * Used by Kaltura-Player SDK internally for AdvertisingConfiguration. + * @param pkAdvertisingController Controller, it resides in Kaltura-Player + * @param advertisingConfig AdvertisingConfig + */ + void setAdvertising(@NonNull PKAdvertisingController pkAdvertisingController, @Nullable AdvertisingConfig advertisingConfig); + void updatePluginConfig(@NonNull String pluginName, @Nullable Object pluginConfig); + + /** + * Used by Kaltura-Player SDK internally for ExoOffline provider. + * This feature is blocked for being used directly by Playkit SDK. + * + * @param downloadCache internally build the CacheDataSource + */ + void setDownloadCache(Cache downloadCache); /** * Player lifecycle method. Should be used when the application went to onPause(); @@ -407,6 +569,7 @@ interface Settings { /** * The current program time in milliseconds since the epoch, or {@link Consts#TIME_UNSET} if not set. * This value is derived from the attribute availabilityStartTime in DASH or the tag EXT-X-PROGRAM-DATE-TIME in HLS. + * * @return The current program time in milliseconds since the epoch, or {@link Consts#TIME_UNSET} if not set. */ long getCurrentProgramTime(); @@ -423,11 +586,17 @@ interface Settings { */ long getBufferedPosition(); + /** + * @return - The Current Live Offset of the media, + * or {@link Consts#TIME_UNSET} if the offset is unknown or player engine is null. + */ + long getCurrentLiveOffset(); + /** * Change the volume of the current audio track. - * Accept values between 0 and 1. Where 0 is mute and 1 is maximum volume. - * If the volume parameter is higher then 1, it will be converted to 1. - * If the volume parameter is lower then 0, it be converted to 0. + * Accept values between 0.0 and 1.0. Where 0.0 is mute and 1.0 is maximum volume. + * If the volume parameter is higher then 1.0, it will be converted to 1.0. + * If the volume parameter is lower then 0.0, it be converted to 0.0. * * @param volume - volume to set. */ @@ -455,6 +624,12 @@ interface Settings { */ void seekTo(long position); + /** + * Seek player to Live Default Position. + * + */ + void seekToLiveDefaultPosition(); + /** * Get the Player's SessionId. The SessionId is generated each time new media is set. * @@ -464,6 +639,7 @@ interface Settings { /** * Checks if the stream is live or not + * * @return flag for live */ boolean isLive(); @@ -474,6 +650,12 @@ interface Settings { */ PKMediaFormat getMediaFormat(); + /** + * @return - Getter for the current mediaSource + * or {@link null} if the media source is not set yet + */ + PKMediaSource getMediaSource(); + /** * Change player speed (pitch = 1.0f by default) * @@ -486,6 +668,14 @@ interface Settings { */ float getPlaybackRate(); + /** + * get the Information for a thumbnailImage by position + * if positionMS is not passed current position will be used + * + * @param positionMS - relevant image for given player position. (optional) + */ + ThumbnailInfo getThumbnailInfo(long ... positionMS); + /** * Generic getters for playkit controllers. * @@ -503,35 +693,93 @@ interface Settings { /** * Update video size */ - void updateSurfaceAspectRatioResizeMode(PKAspectRatioResizeMode resizeMode); + void updateSurfaceAspectRatioResizeMode(@NonNull PKAspectRatioResizeMode resizeMode); + + /** + * Update Low Latency configuration + */ + void updatePKLowLatencyConfig(PKLowLatencyConfig pkLowLatencyConfig); + + /** Update ABRSettings + *
+ * Updating {@link ABRSettings#setInitialBitrateEstimate(Long)} is unaffected because + * initial bitrate is only meant at the start of the playback + *
+ * @param abrSettings new ABR Settings + */ + void updateABRSettings(ABRSettings abrSettings); + + /** + * Reset existing ABRSettings + */ + void resetABRSettings(); + + /** Update LoadControlBuffers + *
+ * Updating LoadControlBuffers in between one media playback to another if required like in case of Vod -> multicast + *
+ * @param loadControlBuffers new LoadControlBuffers + */ + void updateLoadControlBuffers(LoadControlBuffers loadControlBuffers); + + /** Disable/Enable VideoTracks from being fetched from the network after player is loaded with the media + * @param isDisabled if video tracks should be enabled or disabled + */ + void disableVideoTracks(boolean isDisabled); + + /** Disalbe/Enable AudioTracks from being fetched from the network after player is loaded with the media + * @param isDisabled if audio tracks should be enabled or disabled + */ + void disableAudioTracks(boolean isDisabled); + + /** Disalbe/Enable TextTracks from being fetched from the network after player is loaded with the media + * @param isDisabled if text tracks should be enabled or disabled + */ + void disableTextTracks(boolean isDisabled); + + /** + * Returns the media manifest of the window + * Manifest depends on the type of media being prepared. + * Must be called once the media preparation is complete (Means tracks are loaded) + * + * @return Manifest object depends on the media type + * ({@link com.kaltura.androidx.media3.exoplayer.source.hls.HlsManifest} + * or {@link com.kaltura.androidx.media3.exoplayer.source.dash.manifest.DashManifest}) + *
+ * OR `null` + */ + @Nullable + Object getCurrentMediaManifest(); /** * Add listener by event type as Class object. This generics-based method allows the caller to * avoid the otherwise required cast. - * + *

* Sample usage: *

      *   player.addListener(this, PlayerEvent.stateChanged,
      *      event -> Log.d(TAG, "Player state change: " + event.oldState + " => " + event.newState));
      * 
- * @param groupId listener group id for calling {@link #removeListeners(Object)} - * @param type A typed {@link Class} object. The class type must extend PKEvent. + * + * @param groupId listener group id for calling {@link #removeListeners(Object)} + * @param type A typed {@link Class} object. The class type must extend PKEvent. * @param listener a typed {@link PKEvent.Listener}. Must match the type given as the first parameter. - * @param Event type. + * @param Event type. */ void addListener(Object groupId, Class type, PKEvent.Listener listener); /** * Add listener by event type as enum, for use with events that don't have payloads. - * + *

* Sample usage: *

      *   player.addListener(this, PlayerEvent.canPlay, event -> {
      *       Log.d(TAG, "Player can play");
      *   });
      * 
- * @param groupId listener group id for calling {@link #removeListeners(Object)} - * @param type event type + * + * @param groupId listener group id for calling {@link #removeListeners(Object)} + * @param type event type * @param listener listener */ void addListener(Object groupId, Enum type, PKEvent.Listener listener); @@ -545,11 +793,18 @@ interface Settings { /** * Remove event listener, regardless of event type. - + * * @param listener - event listener */ void removeListener(@NonNull PKEvent.Listener listener); + /** + * Get loaded plugins of type. + * + * @param pluginClass - PluginType class. + */ + @NonNull List getLoadedPluginsByType(Class pluginClass); + /** * Add event listener to the player. * @@ -574,7 +829,7 @@ interface Settings { * Add state changed listener to the player. * * @param listener - state changed listener - * @deprecated Use {@link #addListener(Object, Class, PKEvent.Listener)} with {@link PlayerEvent#stateChanged} + * @deprecated Please use {@link #addListener(Object, Class, PKEvent.Listener)} with {@link PlayerEvent#stateChanged} * and remove with {@link #removeListeners(Object)}. */ @Deprecated @@ -589,4 +844,3 @@ interface Settings { @Deprecated void removeStateChangeListener(@NonNull PKEvent.Listener listener); } - diff --git a/playkit/src/main/java/com/kaltura/playkit/PlayerDecoratorBase.java b/playkit/src/main/java/com/kaltura/playkit/PlayerDecoratorBase.java index 5394256a6..ee637aa27 100644 --- a/playkit/src/main/java/com/kaltura/playkit/PlayerDecoratorBase.java +++ b/playkit/src/main/java/com/kaltura/playkit/PlayerDecoratorBase.java @@ -15,9 +15,18 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.kaltura.androidx.media3.datasource.cache.Cache; +import com.kaltura.playkit.ads.AdvertisingConfig; +import com.kaltura.playkit.ads.PKAdvertisingController; +import com.kaltura.playkit.player.ABRSettings; +import com.kaltura.playkit.player.LoadControlBuffers; import com.kaltura.playkit.player.PKAspectRatioResizeMode; +import com.kaltura.playkit.player.PKLowLatencyConfig; import com.kaltura.playkit.player.PlayerView; import com.kaltura.playkit.player.SubtitleStyleSettings; +import com.kaltura.playkit.player.thumbnail.ThumbnailInfo; + +import java.util.List; public class PlayerDecoratorBase implements Player { @@ -31,6 +40,11 @@ public void prepare(@NonNull PKMediaConfig mediaConfig) { player.prepare(mediaConfig); } + @Override + public void setAdvertising(@NonNull PKAdvertisingController pkAdvertisingController, @Nullable AdvertisingConfig advertisingConfig) { + player.setAdvertising(pkAdvertisingController, advertisingConfig); + } + @Override public long getDuration() { return player.getDuration(); @@ -56,6 +70,11 @@ public void seekTo(long position) { player.seekTo(position); } + @Override + public void seekToLiveDefaultPosition() { + player.seekToLiveDefaultPosition(); + } + @Override public T getController(Class type) { return player.getController(type); @@ -76,6 +95,11 @@ public PKMediaFormat getMediaFormat() { return player.getMediaFormat(); } + @Override + public PKMediaSource getMediaSource() { + return player.getMediaSource(); + } + @Override public void setPlaybackRate(float rate) { player.setPlaybackRate(rate); @@ -86,6 +110,11 @@ public float getPlaybackRate() { return player.getPlaybackRate(); } + @Override + public ThumbnailInfo getThumbnailInfo(long ... positionMS) { + return player.getThumbnailInfo(positionMS); + } + @Override public void play() { player.play(); @@ -116,6 +145,11 @@ public long getBufferedPosition() { return player.getBufferedPosition(); } + @Override + public long getCurrentLiveOffset() { + return player.getCurrentLiveOffset(); + } + @Override public void destroy() { player.destroy(); @@ -160,6 +194,12 @@ public void removeListener(@NonNull PKEvent.Listener listener) { player.removeListeners(listener); } + @NonNull + @Override + public List getLoadedPluginsByType(Class pluginClass) { + return player.getLoadedPluginsByType(pluginClass); + } + void setPlayer(Player player) { this.player = player; } @@ -195,16 +235,62 @@ public void updatePluginConfig(@NonNull String pluginName, @Nullable Object plug player.updatePluginConfig(pluginName, pluginConfig); } + @Override + public void setDownloadCache(Cache downloadCache) { + player.setDownloadCache(downloadCache); + } + @Override public void updateSubtitleStyle(SubtitleStyleSettings subtitleStyleSettings) { player.updateSubtitleStyle(subtitleStyleSettings); } @Override - public void updateSurfaceAspectRatioResizeMode(PKAspectRatioResizeMode resizeMode) { + public void updateSurfaceAspectRatioResizeMode(@NonNull PKAspectRatioResizeMode resizeMode) { player.updateSurfaceAspectRatioResizeMode(resizeMode); } + @Override + public void updatePKLowLatencyConfig(PKLowLatencyConfig pkLowLatencyConfig) { + player.updatePKLowLatencyConfig(pkLowLatencyConfig); + } + + @Override + public void updateABRSettings(ABRSettings abrSettings) { + player.updateABRSettings(abrSettings); + } + + @Override + public void resetABRSettings() { + player.resetABRSettings(); + } + + @Override + public void updateLoadControlBuffers(LoadControlBuffers loadControlBuffers) { + player.updateLoadControlBuffers(loadControlBuffers); + } + + @Override + public void disableVideoTracks(boolean isDisabled) { + player.disableVideoTracks(isDisabled); + } + + @Override + public void disableAudioTracks(boolean isDisabled) { + player.disableAudioTracks(isDisabled); + } + + @Override + public void disableTextTracks(boolean isDisabled) { + player.disableTextTracks(isDisabled); + } + + @Nullable + @Override + public Object getCurrentMediaManifest() { + return player.getCurrentMediaManifest(); + } + @Override public void addListener(Object groupId, Class type, PKEvent.Listener listener) { player.addListener(groupId, type, listener); @@ -220,3 +306,4 @@ public void removeListeners(@NonNull Object groupId) { player.removeListeners(groupId); } } + diff --git a/playkit/src/main/java/com/kaltura/playkit/PlayerEngineWrapper.java b/playkit/src/main/java/com/kaltura/playkit/PlayerEngineWrapper.java index f4fe1eb55..f806d1423 100644 --- a/playkit/src/main/java/com/kaltura/playkit/PlayerEngineWrapper.java +++ b/playkit/src/main/java/com/kaltura/playkit/PlayerEngineWrapper.java @@ -1,7 +1,14 @@ package com.kaltura.playkit; +import androidx.annotation.Nullable; + +import com.kaltura.androidx.media3.datasource.cache.Cache; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.EventStream; +import com.kaltura.playkit.player.ABRSettings; import com.kaltura.playkit.player.BaseTrack; +import com.kaltura.playkit.player.LoadControlBuffers; import com.kaltura.playkit.player.PKAspectRatioResizeMode; +import com.kaltura.playkit.player.PKLowLatencyConfig; import com.kaltura.playkit.player.PKMediaSourceConfig; import com.kaltura.playkit.player.PKTracks; import com.kaltura.playkit.player.PlayerEngine; @@ -9,6 +16,7 @@ import com.kaltura.playkit.player.Profiler; import com.kaltura.playkit.player.SubtitleStyleSettings; import com.kaltura.playkit.player.metadata.PKMetadata; +import com.kaltura.playkit.player.thumbnail.ThumbnailInfo; import java.util.List; @@ -66,6 +74,11 @@ public long getBufferedPosition() { return playerEngine.getBufferedPosition(); } + @Override + public long getCurrentLiveOffset() { + return playerEngine.getCurrentLiveOffset(); + } + @Override public float getVolume() { return playerEngine.getVolume(); @@ -81,14 +94,29 @@ public void changeTrack(String uniqueId) { playerEngine.changeTrack(uniqueId); } + @Override + public void updateLoadControlBuffers(LoadControlBuffers loadControlBuffers) { + playerEngine.updateLoadControlBuffers(loadControlBuffers); + } + + @Override + public void disableVideoTracks(boolean isDisabled) { + playerEngine.disableVideoTracks(isDisabled); + } + + @Override + public void disableAudioTracks(boolean isDisabled) { + playerEngine.disableAudioTracks(isDisabled); + } + @Override public void overrideMediaVideoCodec() { playerEngine.overrideMediaVideoCodec(); } @Override - public void overrideMediaDefaultABR(long minVideoBitrate, long maxVideoBitrate) { - playerEngine.overrideMediaDefaultABR(minVideoBitrate, maxVideoBitrate); + public void overrideMediaDefaultABR(long minVideoBitrate, long maxVideoBitrate, PKAbrFilter pkAbrFilter) { + playerEngine.overrideMediaDefaultABR(minVideoBitrate, maxVideoBitrate, pkAbrFilter); } @Override @@ -96,6 +124,11 @@ public void seekTo(long position) { playerEngine.seekTo(position); } + @Override + public void seekToDefaultPosition() { + playerEngine.seekToDefaultPosition(); + } + @Override public void startFrom(long position) { playerEngine.startFrom(position); @@ -126,6 +159,16 @@ public void setAnalyticsListener(AnalyticsListener analyticsListener) { playerEngine.setAnalyticsListener(analyticsListener); } + @Override + public void setInputFormatChangedListener(Boolean enableListener) { + playerEngine.setInputFormatChangedListener(enableListener); + } + + @Override + public void setRedirectedManifestURL(String playbackRedirectedManifestUrl) { + playerEngine.setRedirectedManifestURL(playbackRedirectedManifestUrl); + } + @Override public void release() { playerEngine.release(); @@ -166,6 +209,11 @@ public BaseTrack getLastSelectedTrack(int renderType) { return playerEngine.getLastSelectedTrack(renderType); } + @Override + public List getEventStreams() { + return playerEngine.getEventStreams(); + } + @Override public boolean isLive() { return playerEngine.isLive(); @@ -181,6 +229,16 @@ public float getPlaybackRate() { return playerEngine.getPlaybackRate(); } + @Override + public void setDownloadCache(Cache downloadCache) { + playerEngine.setDownloadCache(downloadCache); + } + + @Override + public ThumbnailInfo getThumbnailInfo(long positionMS) { + return playerEngine.getThumbnailInfo(positionMS); + } + @Override public void setProfiler(Profiler profiler) { this.playerEngine.setProfiler(profiler); @@ -191,11 +249,32 @@ public void updateSubtitleStyle(SubtitleStyleSettings subtitleStyleSettings) { playerEngine.updateSubtitleStyle(subtitleStyleSettings); } + @Override + public void updateABRSettings(ABRSettings abrSettings) { + playerEngine.updateABRSettings(abrSettings); + } + + @Override + public void resetABRSettings() { + playerEngine.resetABRSettings(); + } + + @Nullable + @Override + public Object getCurrentMediaManifest() { + return playerEngine.getCurrentMediaManifest(); + } + @Override public void updateSurfaceAspectRatioResizeMode(PKAspectRatioResizeMode resizeMode) { playerEngine.updateSurfaceAspectRatioResizeMode(resizeMode); } + @Override + public void updatePKLowLatencyConfig(PKLowLatencyConfig pkLowLatencyConfig) { + playerEngine.updatePKLowLatencyConfig(pkLowLatencyConfig); + } + @Override public T getController(Class type) { return playerEngine.getController(type); diff --git a/playkit/src/main/java/com/kaltura/playkit/PlayerEvent.java b/playkit/src/main/java/com/kaltura/playkit/PlayerEvent.java index 0026a4e0e..c42366561 100644 --- a/playkit/src/main/java/com/kaltura/playkit/PlayerEvent.java +++ b/playkit/src/main/java/com/kaltura/playkit/PlayerEvent.java @@ -14,7 +14,9 @@ import androidx.annotation.NonNull; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.EventStream; import com.kaltura.playkit.player.AudioTrack; +import com.kaltura.playkit.player.ImageTrack; import com.kaltura.playkit.player.PKAspectRatioResizeMode; import com.kaltura.playkit.player.PKTracks; import com.kaltura.playkit.player.TextTrack; @@ -40,6 +42,10 @@ public class PlayerEvent implements PKEvent { public static final Class videoTrackChanged = VideoTrackChanged.class; public static final Class audioTrackChanged = AudioTrackChanged.class; public static final Class textTrackChanged = TextTrackChanged.class; + public static final Class eventStreamChanged = EventStreamChanged.class; + public static final Class imageTrackChanged = ImageTrackChanged.class; + public static final Class sourceRedirected = ManifestRedirected.class; + public static final Class playbackRateChanged = PlaybackRateChanged.class; public static final Class subtitlesStyleChanged = SubtitlesStyleChanged.class; public static final Class videoFramesDropped = VideoFramesDropped.class; @@ -57,6 +63,10 @@ public class PlayerEvent implements PKEvent { public static final PlayerEvent.Type seeked = Type.SEEKED; public static final PlayerEvent.Type replay = Type.REPLAY; public static final PlayerEvent.Type stopped = Type.STOPPED; + public static final PlayerEvent.Type videoTracksEnabled = Type.VIDEO_TRACKS_ENABLED; + public static final PlayerEvent.Type videoTracksDisabled = Type.VIDEO_TRACKS_DISABLED; + + public final Type type; @@ -70,6 +80,14 @@ public Generic(Type type) { } } + public static class Stopped extends PlayerEvent { + public final String mediaSourceUrl; + public Stopped(Type type, String mediaSourceUrl) { + super(type); + this.mediaSourceUrl = mediaSourceUrl; + } + } + public static class StateChanged extends PlayerEvent { public final PlayerState newState; public final PlayerState oldState; @@ -94,10 +112,12 @@ public DurationChanged(long duration) { public static class TracksAvailable extends PlayerEvent { public final PKTracks tracksInfo; + public final PKTracksAvailableStatus pkTracksAvailableStatus; - public TracksAvailable(PKTracks tracksInfo) { + public TracksAvailable(PKTracks tracksInfo, PKTracksAvailableStatus pkTracksAvailableStatus) { super(Type.TRACKS_AVAILABLE); this.tracksInfo = tracksInfo; + this.pkTracksAvailableStatus = pkTracksAvailableStatus; } } @@ -207,6 +227,24 @@ public TextTrackChanged(TextTrack newTrack) { } } + public static class EventStreamChanged extends PlayerEvent { + public final List eventStreamList; + public EventStreamChanged(List eventStreams) { + super(Type.EVENT_STREAM_CHANGED); + this.eventStreamList = eventStreams; + } + } + + public static class ImageTrackChanged extends PlayerEvent { + + public final ImageTrack newTrack; + + public ImageTrackChanged(ImageTrack newTrack) { + super(Type.IMAGE_TRACK_CHANGED); + this.newTrack = newTrack; + } + } + public static class PlaybackRateChanged extends PlayerEvent { public final float rate; @@ -281,6 +319,16 @@ public OutputBufferCountUpdate(int skippedOutputBufferCount, int renderedOutputB } } + public static class ManifestRedirected extends PlayerEvent { + + public final String redirectedUrl; + + public ManifestRedirected(String redirectedUrl) { + super(Type.SOURCE_REDIRECTED); + this.redirectedUrl = redirectedUrl; + } + } + public static class BytesLoaded extends PlayerEvent { /* @@ -339,7 +387,7 @@ public enum Type { LOADED_METADATA, // The media's metadata has finished loading; all attributes now contain as much useful information as they're going to. PAUSE, // Sent when playback is paused. PLAY, // Sent when playback of the media starts after having been paused; that is, when playback is resumed after a prior pause event. - RETRY, // Sent when retry api is called by app + RETRY, // Sent when retry api is called by app. PLAYING, // Sent when the media begins to play (either for the first time, after having been paused, or after ending and then restarting). SEEKED, // Sent when a seek operation completes. SEEKING, // Sent when a seek operation begins. @@ -347,20 +395,25 @@ public enum Type { REPLAY, //Sent when replay happened. PLAYBACK_INFO_UPDATED, // Sent event that notify about changes in the playback parameters. When bitrate of the video or audio track changes or new media loaded. Holds the PlaybackInfo.java object with relevant data. VOLUME_CHANGED, // Sent when volume is changed. - STOPPED, // sent when stop player api is called + STOPPED, // sent when stop player api is called. METADATA_AVAILABLE, // Sent when there is metadata available for this entry. SOURCE_SELECTED, // Sent when the source was selected. - PLAYHEAD_UPDATED, //Send player position every 100 Milisec + SOURCE_REDIRECTED, // Sent when there is manifest redirection. + PLAYHEAD_UPDATED, //Send player position every 100 Milisec. VIDEO_TRACK_CHANGED, AUDIO_TRACK_CHANGED, TEXT_TRACK_CHANGED, + IMAGE_TRACK_CHANGED, PLAYBACK_RATE_CHANGED, CONNECTION_ACQUIRED, - VIDEO_FRAMES_DROPPED, // Video frames were dropped, see PlayerEvent.VideoFramesDropped + VIDEO_FRAMES_DROPPED, // Video frames were dropped, see PlayerEvent.VideoFramesDropped. OUTPUT_BUFFER_COUNT_UPDATE, - BYTES_LOADED, // Bytes were downloaded from the network + VIDEO_TRACKS_ENABLED, + VIDEO_TRACKS_DISABLED, + BYTES_LOADED, // Bytes were downloaded from the network. SUBTITLE_STYLE_CHANGED, // Subtitle style is changed. - ASPECT_RATIO_RESIZE_MODE_CHANGED //Send when updating the Surface Vide Aspect Ratio size mode. + ASPECT_RATIO_RESIZE_MODE_CHANGED, //Send when updating the Surface Vide Aspect Ratio size mode. + EVENT_STREAM_CHANGED //Send event streams received from manifest. } @Override diff --git a/playkit/src/main/java/com/kaltura/playkit/PlayerLoader.java b/playkit/src/main/java/com/kaltura/playkit/PlayerLoader.java index 80635b0d4..4f55c24f3 100644 --- a/playkit/src/main/java/com/kaltura/playkit/PlayerLoader.java +++ b/playkit/src/main/java/com/kaltura/playkit/PlayerLoader.java @@ -14,13 +14,19 @@ import android.content.Context; import android.net.Uri; +import android.text.TextUtils; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.util.Pair; +import com.kaltura.playkit.ads.AdvertisingConfig; +import com.kaltura.playkit.ads.AdvertisingController; +import com.kaltura.playkit.ads.PKAdvertisingController; import com.kaltura.playkit.player.PlayerController; import com.kaltura.playkit.plugins.playback.KalturaPlaybackRequestAdapter; import com.kaltura.playkit.plugins.playback.KalturaUDRMLicenseRequestAdapter; +import com.kaltura.playkit.utils.NetworkUtils; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -48,6 +54,10 @@ class PlayerLoader extends PlayerDecoratorBase { private Map loadedPlugins = new LinkedHashMap<>(); private PlayerController playerController; + private @Nullable AdvertisingConfig advertisingConfig; + private PKAdvertisingController pkAdvertisingController; + private final String kavaPluginKey = "kava"; + private boolean isKavaImpressionFired; PlayerLoader(Context context, MessageBus messageBus) { this.context = context; @@ -121,6 +131,7 @@ public void updatePluginConfig(@NonNull final String pluginName, @Nullable final @Override public void destroy() { + isKavaImpressionFired = false; stop(); releasePlugins(); releasePlayer(); @@ -158,9 +169,22 @@ public void prepare(@NonNull final PKMediaConfig mediaConfig) { super.prepare(mediaConfig); + if (pkAdvertisingController != null && playerController.getController(AdvertisingController.class) != null) { + pkAdvertisingController.setAdController(playerController.getController(AdvertisingController.class)); + pkAdvertisingController.setAdvertising(advertisingConfig); + } + for (Map.Entry loadedPluginEntry : loadedPlugins.entrySet()) { loadedPluginEntry.getValue().plugin.onUpdateMedia(mediaConfig); } + + if (!PKDeviceCapabilities.isKalturaPlayerAvailable() && + loadedPlugins != null && !loadedPlugins.containsKey(kavaPluginKey)) { + Player player = getPlayer(); + String sessionId = (player != null && player.getSessionId() != null) ? player.getSessionId() : ""; + doKavaAnalyticsCall(mediaConfig, sessionId); + } + // messageBus.post(new Runnable() { // @Override // public void run() { @@ -171,6 +195,17 @@ public void prepare(@NonNull final PKMediaConfig mediaConfig) { // }); } + @Override + public void setAdvertising(@NonNull PKAdvertisingController pkAdvertisingController, @Nullable AdvertisingConfig advertisingConfig) { + if (!PKDeviceCapabilities.isKalturaPlayerAvailable()) { + log.e("Advertising is being used to configure custom AdLayout. This feature is not available in Playkit SDK. " + + "It is only being used by Kaltura Player SDK."); + return; + } + this.pkAdvertisingController = pkAdvertisingController; + this.advertisingConfig = advertisingConfig; + } + private void releasePlugins() { // Unload in the reversed order they were loaded, peeling off the decorators. List> plugins = new ArrayList<>(loadedPlugins.entrySet()); @@ -198,6 +233,54 @@ private void releasePlugins() { setPlayer(currentLayer); } + /** + * Do KavaAnalytics call + * @param pkMediaConfig mediaConfig + */ + private void doKavaAnalyticsCall(PKMediaConfig pkMediaConfig, String sessionId) { + Pair metaData = getRequiredAnalyticsInfo(pkMediaConfig); + + int partnerId = metaData.first == null ? 0 : metaData.first; + String entryId = TextUtils.isEmpty(metaData.second) ? "" : metaData.second; + + if (partnerId <= 0) { + partnerId = NetworkUtils.DEFAULT_KAVA_PARTNER_ID; + entryId = NetworkUtils.DEFAULT_KAVA_ENTRY_ID; + } + + if (!isKavaImpressionFired) { + NetworkUtils.sendKavaAnalytics(context, partnerId, entryId, NetworkUtils.KAVA_EVENT_IMPRESSION, sessionId); + isKavaImpressionFired = true; + } + + NetworkUtils.sendKavaAnalytics(context, partnerId, entryId, NetworkUtils.KAVA_EVENT_PLAY_REQUEST, sessionId); + } + + /** + * Get EntryId and PartnerId from Metadata + * @param pkMediaConfig mediaConfig + * @return Pair of Entry and partner Ids + */ + private Pair getRequiredAnalyticsInfo(PKMediaConfig pkMediaConfig) { + final String kavaPartnerIdKey = "kavaPartnerId"; + final String kavaEntryIdKey = "entryId"; + int kavaPartnerId = 0; + String kavaEntryId = null; + + if (pkMediaConfig.getMediaEntry() != null && pkMediaConfig.getMediaEntry().getMetadata() != null) { + if (pkMediaConfig.getMediaEntry().getMetadata().containsKey(kavaPartnerIdKey)) { + String partnerId = pkMediaConfig.getMediaEntry().getMetadata().get(kavaPartnerIdKey); + kavaPartnerId = Integer.parseInt(partnerId != null && TextUtils.isDigitsOnly(partnerId) && !TextUtils.isEmpty(partnerId) ? partnerId : "0"); + } + + if (pkMediaConfig.getMediaEntry().getMetadata().containsKey(kavaEntryIdKey)) { + kavaEntryId = pkMediaConfig.getMediaEntry().getMetadata().get(kavaEntryIdKey); + } + } + + return Pair.create(kavaPartnerId, kavaEntryId); + } + private PKPlugin loadPlugin(String name, Player player, Object config, MessageBus messageBus, Context context) { PKPlugin plugin = PlayKitManager.createPlugin(name); if (plugin != null) { @@ -249,4 +332,18 @@ public void addListener(Object groupId, Enum type, PKEvent.Listener listener) { public void removeListeners(@NonNull Object groupId) { messageBus.removeListeners(groupId); } + + @NonNull + @Override + public List getLoadedPluginsByType(Class pluginClass) { + List filteredPlugins = new ArrayList<>(); + for (LoadedPlugin loadedPlugin : loadedPlugins.values()) { + if (pluginClass.isAssignableFrom(loadedPlugin.plugin.getClass())) { + @SuppressWarnings({"unchecked", "isAssignableFrom checks both superclass and superinterface"}) + PluginType pluginType = (PluginType) loadedPlugin.plugin; + filteredPlugins.add(pluginType); + } + } + return filteredPlugins; + } } diff --git a/playkit/src/main/java/com/kaltura/playkit/SpeedAdjustedRenderersFactory.java b/playkit/src/main/java/com/kaltura/playkit/SpeedAdjustedRenderersFactory.java new file mode 100644 index 000000000..c3b817b34 --- /dev/null +++ b/playkit/src/main/java/com/kaltura/playkit/SpeedAdjustedRenderersFactory.java @@ -0,0 +1,139 @@ +/* + * ============================================================================ + * Copyright (C) 2023 Kaltura Inc. + * + * Licensed under the AGPLv3 license, unless a different license for a + * particular library is specified in the applicable library path. + * + * You may obtain a copy of the License at + * https://www.gnu.org/licenses/agpl-3.0.html + * ============================================================================ + */ + +package com.kaltura.playkit; + +import android.content.Context; +import android.os.Handler; + +import androidx.annotation.NonNull; + +import com.kaltura.androidx.media3.exoplayer.DefaultRenderersFactory; +import com.kaltura.androidx.media3.exoplayer.Renderer; +import com.kaltura.androidx.media3.exoplayer.audio.AudioRendererEventListener; +import com.kaltura.androidx.media3.exoplayer.audio.AudioSink; +import com.kaltura.androidx.media3.exoplayer.audio.KMediaCodecAudioRenderer; +import com.kaltura.androidx.media3.exoplayer.audio.MediaCodecAudioRenderer; +import com.kaltura.androidx.media3.exoplayer.mediacodec.MediaCodecSelector; +import com.kaltura.androidx.media3.exoplayer.video.KMediaCodecVideoRenderer; +import com.kaltura.androidx.media3.exoplayer.video.KVideoRendererFirstFrameWhenStartedEventListener; +import com.kaltura.androidx.media3.exoplayer.video.MediaCodecVideoRenderer; +import com.kaltura.androidx.media3.exoplayer.video.VideoRendererEventListener; +import com.kaltura.playkit.player.PlayerSettings; + +import java.util.ArrayList; + +/** + * Utility class, providing a mechanism for creating renderers factory, which in its turn + * creates Audio/Video renderers, which are capable to adjust playback speed, in case when + * there's a big gap between Audio and Video streams buffers position at playback startup. + * Also, Video renderer takes a callback interface for providing notification once playback + * actually begins (i.e. in addition to onFirstFrameRendered, when no playback is actually + * happening yet). + * Speed adjustment behavioral values may be provided inside the {@link com.kaltura.playkit.player.PlayerSettings} + * instance passed into factory method + * Currently this mechanism is used only for multicast streams + */ +public class SpeedAdjustedRenderersFactory { + public static DefaultRenderersFactory createSpeedAdjustedRenderersFactory( + Context context, + PlayerSettings playerSettings, + KVideoRendererFirstFrameWhenStartedEventListener rendererFirstFrameWhenStartedEventListener + ) { + return new DefaultRenderersFactory(context) { + @Override + protected void buildAudioRenderers(@NonNull Context context, + int extensionRendererMode, + @NonNull MediaCodecSelector mediaCodecSelector, + boolean enableDecoderFallback, + @NonNull AudioSink audioSink, + @NonNull Handler eventHandler, + @NonNull AudioRendererEventListener eventListener, + @NonNull ArrayList out) { + ArrayList renderersArrayList = new ArrayList<>(); + super.buildAudioRenderers(context, + extensionRendererMode, + mediaCodecSelector, + enableDecoderFallback, + audioSink, + eventHandler, + eventListener, + renderersArrayList); + for (Renderer renderer : renderersArrayList) { + if (renderer instanceof MediaCodecAudioRenderer) { + if (playerSettings.getMulticastSettings() != null) { + out.add(new KMediaCodecAudioRenderer( + context, + getCodecAdapterFactory(), + mediaCodecSelector, + enableDecoderFallback, + eventHandler, + eventListener, + audioSink, + playerSettings.getMulticastSettings().getExperimentalMaxSpeedFactor(), + playerSettings.getMulticastSettings().getExperimentalSpeedStep(), + playerSettings.getMulticastSettings().getExperimentalAVGapForSpeedAdjustment(), + playerSettings.getMulticastSettings().getExperimentalContinuousSpeedAdjustment())); + } else { + out.add(new KMediaCodecAudioRenderer( + context, + getCodecAdapterFactory(), + mediaCodecSelector, + enableDecoderFallback, + eventHandler, + eventListener, + audioSink)); + } + } else { + out.add(renderer); + } + } + } + + @Override + protected void buildVideoRenderers(@NonNull Context context, + int extensionRendererMode, + @NonNull MediaCodecSelector mediaCodecSelector, + boolean enableDecoderFallback, + @NonNull Handler eventHandler, + @NonNull VideoRendererEventListener eventListener, + long allowedVideoJoiningTimeMs, + @NonNull ArrayList out) { + ArrayList renderersArrayList = new ArrayList<>(); + super.buildVideoRenderers(context, + extensionRendererMode, + mediaCodecSelector, + enableDecoderFallback, + eventHandler, + eventListener, + allowedVideoJoiningTimeMs, + renderersArrayList); + for (Renderer renderer : renderersArrayList) { + if (renderer instanceof MediaCodecVideoRenderer) { + out.add(new KMediaCodecVideoRenderer( + context, + this.getCodecAdapterFactory(), + mediaCodecSelector, + allowedVideoJoiningTimeMs, + enableDecoderFallback, + eventHandler, + eventListener, + MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY, + rendererFirstFrameWhenStartedEventListener)); + } else { + out.add(renderer); + } + } + } + }; + } +} diff --git a/playkit/src/main/java/com/kaltura/playkit/Utils.java b/playkit/src/main/java/com/kaltura/playkit/Utils.java index a08e53e05..d7d9f447b 100644 --- a/playkit/src/main/java/com/kaltura/playkit/Utils.java +++ b/playkit/src/main/java/com/kaltura/playkit/Utils.java @@ -293,4 +293,8 @@ public static String getDeviceType(Context context) { } return deviceType; } + + public static boolean isMulticastMedia(PKMediaFormat mediaFormat) { + return PKMediaFormat.udp.equals(mediaFormat); + } } diff --git a/playkit/src/main/java/com/kaltura/playkit/ads/AdController.java b/playkit/src/main/java/com/kaltura/playkit/ads/AdController.java index 4817a9319..0417fafc2 100644 --- a/playkit/src/main/java/com/kaltura/playkit/ads/AdController.java +++ b/playkit/src/main/java/com/kaltura/playkit/ads/AdController.java @@ -27,6 +27,8 @@ public interface AdController extends PKController { void seekTo(long position); + default void setVolume(float volume) {} + long getAdCurrentPosition(); long getAdDuration(); diff --git a/playkit/src/main/java/com/kaltura/playkit/ads/AdEnabledPlayerController.java b/playkit/src/main/java/com/kaltura/playkit/ads/AdEnabledPlayerController.java index 81fad0855..2238f7f26 100644 --- a/playkit/src/main/java/com/kaltura/playkit/ads/AdEnabledPlayerController.java +++ b/playkit/src/main/java/com/kaltura/playkit/ads/AdEnabledPlayerController.java @@ -23,7 +23,6 @@ import com.kaltura.playkit.utils.Consts; - public class AdEnabledPlayerController extends PlayerDecorator implements AdController, PKAdProviderListener, PKController { private static final PKLog log = PKLog.get("AdEnablController"); @@ -83,6 +82,25 @@ public void seekTo(long position) { super.seekTo(position); } + @Override + public void seekToLiveDefaultPosition() { + if (adsProvider.isAdDisplayed()) { + log.d("seekToLiveDefaultPosition is not enabled during AD playback"); + return; + } + super.seekToLiveDefaultPosition(); + } + + @Override + public void setVolume(float volume) { + if (adsProvider.isAdDisplayed()) { + adsProvider.setVolume(volume); + return; + } + + super.setVolume(volume); + } + @Override public void play() { log.d("PLAY IMA decorator"); diff --git a/playkit/src/main/java/com/kaltura/playkit/ads/AdsPlayerEngineWrapper.java b/playkit/src/main/java/com/kaltura/playkit/ads/AdsPlayerEngineWrapper.java index 628b4b28d..2c46715bf 100644 --- a/playkit/src/main/java/com/kaltura/playkit/ads/AdsPlayerEngineWrapper.java +++ b/playkit/src/main/java/com/kaltura/playkit/ads/AdsPlayerEngineWrapper.java @@ -16,7 +16,9 @@ import com.kaltura.playkit.PKController; import com.kaltura.playkit.PKLog; +import com.kaltura.playkit.PKMediaEntry; import com.kaltura.playkit.PlayerEngineWrapper; +import com.kaltura.playkit.player.PKAspectRatioResizeMode; import com.kaltura.playkit.player.PKMediaSourceConfig; import com.kaltura.playkit.plugins.ads.AdsProvider; @@ -32,11 +34,13 @@ public class AdsPlayerEngineWrapper extends PlayerEngineWrapper implements PKAdP private AdsProvider adsProvider; private PKMediaSourceConfig mediaSourceConfig; private DefaultAdControllerImpl defaultAdController; + private DefaultAdvertisingControllerImpl defaultAdvertisingController; public AdsPlayerEngineWrapper(final Context context, AdsProvider adsProvider) { this.context = context; this.adsProvider = adsProvider; this.defaultAdController = new DefaultAdControllerImpl(adsProvider); + this.defaultAdvertisingController = new DefaultAdvertisingControllerImpl(adsProvider); } @Override @@ -53,7 +57,7 @@ public void load(PKMediaSourceConfig mediaSourceConfig) { adsProvider.setAdRequested(true); // need to prepare immediately } - if (preparePlayerForPlayback()) { + if (preparePlayerForPlayback() || preparePlayerForPlaybackIfLiveMedia()) { log.d("AdWrapper calling super.prepare"); super.load(mediaSourceConfig); } else { @@ -65,13 +69,25 @@ public void load(PKMediaSourceConfig mediaSourceConfig) { private boolean preparePlayerForPlayback() { - return (adsProvider.isAdRequested() && adsProvider.isForceSinglePlayerRequired()) || + return (adsProvider.isAdRequested() && adsProvider.isForceSinglePlayerRequired()) || (adsProvider.isAdRequested() && (adsProvider.getCuePoints() == null || adsProvider.getAdInfo() == null)) || adsProvider.isAllAdsCompleted() || adsProvider.isAdError() || adsProvider.isAdDisplayed() || adsProvider.isAdRequested() && adsProvider.getCuePoints() != null && (!adsProvider.getCuePoints().hasPreRoll() || getCurrentPosition() > 0) || adsProvider.getPlaybackStartPosition() != null && adsProvider.getPlaybackStartPosition() > 0 && !adsProvider.isAlwaysStartWithPreroll(); } + /** + * This check is only for Live Medias + * Because for live media, player always seeks to live edge + * when app comes from background which results the getCurrentPosition to 0 + * + * @return boolean player preparation required or not + */ + private boolean preparePlayerForPlaybackIfLiveMedia() { + return isLiveMediaWithoutDvr() && getCurrentPosition() == 0 && + adsProvider.isAdvertisingConfigured() && adsProvider.isAdRequested() && !adsProvider.isAdDisplayed(); + } + @Override public void play() { log.d("AdWrapper PLAY"); @@ -115,11 +131,32 @@ public void pause() { } } + @Override + public void updateSurfaceAspectRatioResizeMode(PKAspectRatioResizeMode resizeMode) { + if (resizeMode == null) { + log.e("Resize mode is invalid"); + return; + } + + if (adsProvider != null) { + adsProvider.updateSurfaceAspectRatioResizeMode(resizeMode); + } + + super.updateSurfaceAspectRatioResizeMode(resizeMode); + } + @Override public long getCurrentPosition() { return super.getCurrentPosition(); } + private boolean isLiveMediaWithoutDvr() { + if (mediaSourceConfig != null) { + return mediaSourceConfig.getMediaEntryType() == PKMediaEntry.MediaEntryType.Live; + } + return false; + } + @Override public long getProgramStartTime() { return super.getProgramStartTime(); @@ -136,6 +173,12 @@ public void seekTo(long position) { super.seekTo(position); } + @Override + public void seekToDefaultPosition() { + log.d("AdWrapper seekToDefaultPosition"); + super.seekToDefaultPosition(); + } + @Override public boolean isPlaying() { log.d("AdWrapper isPlaying"); @@ -147,6 +190,16 @@ public void setAnalyticsListener(AnalyticsListener analyticsListener) { super.setAnalyticsListener(analyticsListener); } + @Override + public void setInputFormatChangedListener(Boolean enableListener) { + super.setInputFormatChangedListener(enableListener); + } + + @Override + public void setRedirectedManifestURL(String playbackRedirectedManifestUrl) { + super.setRedirectedManifestURL(playbackRedirectedManifestUrl); + } + @Override public void stop() { log.d("AdWrapper stop"); @@ -162,6 +215,11 @@ public T getController(Class type) { if (type == AdController.class && defaultAdController != null) { return (T) this.defaultAdController; } + + if (type == AdvertisingController.class && defaultAdvertisingController != null) { + return (T) this.defaultAdvertisingController; + } + return super.getController(type); } diff --git a/playkit/src/main/java/com/kaltura/playkit/ads/AdvertisingConfig.kt b/playkit/src/main/java/com/kaltura/playkit/ads/AdvertisingConfig.kt new file mode 100644 index 000000000..730aed977 --- /dev/null +++ b/playkit/src/main/java/com/kaltura/playkit/ads/AdvertisingConfig.kt @@ -0,0 +1,109 @@ +package com.kaltura.playkit.ads + +import androidx.annotation.NonNull + +// Collection of classes and enums required for Advertising + +/** + * Advertising configuration + * Pass the list of AdBreaks + * + * @param advertising: List of AdBreaks + * @param adTimeUnit: AdBreak position in Seconds or Milliseconds + * @param adType: If it is AdUrl or VAST response + * @param playAdsAfterTime: Play ads only from a specific time + * @param returnToLive: Default is false - Only for Live Medias (If true then after ad playback, + * player will go to the live edge else it will try to resume from the same position, if possible) + */ +data class AdvertisingConfig(val advertising: List?, + @NonNull val adTimeUnit: AdTimeUnit = AdTimeUnit.SECONDS, + @NonNull val adType: AdType = AdType.AD_URL, + @NonNull val playAdsAfterTime: Long = Long.MIN_VALUE, + @NonNull val returnToLive: Boolean = false) + +/** + * AdBreak: Pre, Mid, Post + * Each AdBreak may contain List of AdPod + * Each AdPod may contain a list of Ads (List of Ads is being used to do waterfalling) + * + * For PlayAdNow API, if app is passing AdBreak then position is irrelevant. + */ +data class AdBreak(@NonNull var adBreakPositionType: AdBreakPositionType = AdBreakPositionType.POSITION, + @NonNull var position: Long, + @NonNull val ads: List>) + +// Ad Break Config +data class AdBreakConfig(val adBreakPositionType: AdBreakPositionType, + var adPosition: Long, + var adBreakState: AdState, + val adPodList: List?) + +// Ad list contains waterfalling ads as well. +data class AdPodConfig(var adPodState: AdState, + val adList: List?, + val hasWaterFalling: Boolean = false) + +// Single Ad +data class Ad(var adState: AdState, + val ad: String) + +/** + * AdInfo for the Advertising Controller + */ +data class PKAdvertisingAdInfo(var adPodTimeOffset: Long, + var podIndex: Int, + var podCount: Int) + +/** + * For Preroll and Postroll, always configure POSITION or PERCENTAGE (0% = Preroll, 100% = Postroll) + * For Midroll, POSITION, PERCENTAGE or EVERY can be configured. + * + * PERCENTAGE and EVERY can not be mixed in one configuration (Only one can be configured at a time) + * For EVERY: Only one Midroll ad should be configured (Because the adbreak will be played every X seconds) + */ +enum class AdBreakPositionType { + POSITION, // Play AdBreak at this specific second + PERCENTAGE, // Play AdBreak at nth percentage (Position percentage of the media length) + EVERY // Play AdBreak at every n seconds (60 means on every 1 min ad will be played) +} + +/** + * AdBreak time value can be passed as SECONDS (10 means 10 seconds) OR MILISECONDS (10000 means 10 seconds) + */ +enum class AdTimeUnit { + SECONDS, + MILISECONDS +} + +/** + * Passing Ad type + * It could be Ad's VAST Url or Ad's VAST XML Response + */ +enum class AdType { + AD_URL, + AD_RESPONSE +} + +/** + * Ad's State + */ +enum class AdState { + READY, + PLAYING, + PLAYED, + ERROR +} + +/** + * Adroll type + * + * AdLayout may contain different AdBreaks + * Each AdBreak will contain at least 1 or more than 1 AdPod + * Ead AdPod will contain ad least 1 Ad + */ + +internal enum class AdRollType { + ADBREAK, + ADPOD, + AD +} diff --git a/playkit/src/main/java/com/kaltura/playkit/ads/AdvertisingContainer.kt b/playkit/src/main/java/com/kaltura/playkit/ads/AdvertisingContainer.kt new file mode 100644 index 000000000..78f8935b5 --- /dev/null +++ b/playkit/src/main/java/com/kaltura/playkit/ads/AdvertisingContainer.kt @@ -0,0 +1,380 @@ +package com.kaltura.playkit.ads + +import androidx.annotation.Nullable +import com.google.gson.Gson +import com.kaltura.playkit.PKLog +import com.kaltura.playkit.utils.Consts +import java.util.* +import kotlin.collections.ArrayList +import kotlin.collections.HashMap + +/** + * Class to map the Advertising object to our internal helper DataStructure + */ +internal class AdvertisingContainer { + + private val log = PKLog.get(AdvertisingContainer::class.java.simpleName) + private var adsConfigMap: MutableMap? = null + private var cuePointsList: LinkedList? = null + private var midrollAdPositionType: AdBreakPositionType = AdBreakPositionType.POSITION + private var midrollFrequency = Long.MIN_VALUE + private var playAdsAfterTime = Long.MIN_VALUE + private var adType: AdType = AdType.AD_URL + private var returnToLive: Boolean = false + + internal fun setAdvertisingConfig(advertisingConfig: AdvertisingConfig?) { + advertisingConfig?.let { + parseAdTypes(it) + } + } + + /** + * Parse the Ads from the external Ads' data structure + */ + private fun parseAdTypes(advertisingConfig: AdvertisingConfig?) { + log.d("parseAdTypes") + + advertisingConfig?.advertising?.let { adBreaks -> + val adBreaksList = ArrayList() + cuePointsList = LinkedList() + + playAdsAfterTime = if (advertisingConfig.adTimeUnit == AdTimeUnit.SECONDS && (advertisingConfig.playAdsAfterTime != -1L || advertisingConfig.playAdsAfterTime > 0)) { + advertisingConfig.playAdsAfterTime * Consts.MILLISECONDS_MULTIPLIER + } else { + advertisingConfig.playAdsAfterTime + } + + advertisingConfig.adType.let { + adType = it + } + + returnToLive = advertisingConfig.returnToLive + + for (adBreak: AdBreak? in adBreaks) { + + adBreak?.let adBreakLoop@{ singleAdBreak -> + + // Only one ad can be configured for Every AdBreakPositionType + + if ((singleAdBreak.position == 0L || singleAdBreak.position == -1L) && + (singleAdBreak.adBreakPositionType == AdBreakPositionType.EVERY)) { + log.w("Preroll or Postroll ad should not be configured with AdBreakPositionType.EVERY\n" + + "Dropping such AdBreak") + return@adBreakLoop + } + + if (midrollAdPositionType == AdBreakPositionType.POSITION && adBreak.adBreakPositionType == AdBreakPositionType.EVERY) { + midrollAdPositionType = AdBreakPositionType.EVERY + midrollFrequency = if (advertisingConfig.adTimeUnit == AdTimeUnit.SECONDS) singleAdBreak.position * Consts.MILLISECONDS_MULTIPLIER else singleAdBreak.position + } else if (midrollAdPositionType == AdBreakPositionType.EVERY && adBreak.adBreakPositionType == AdBreakPositionType.EVERY) { + log.w("There should not be multiple Midrolls for AdBreakPositionType EVERY.\n" + + "Keep One MidRoll ad which will play at the given second.") + midrollAdPositionType = AdBreakPositionType.POSITION + midrollFrequency = 0L + return@adBreakLoop + } + + if (adBreak.adBreakPositionType == AdBreakPositionType.PERCENTAGE) { + if (midrollAdPositionType == AdBreakPositionType.EVERY) { + log.w("There should not be a combination of PERCENTAGE and EVERY.") + return@adBreakLoop + } + if (singleAdBreak.position < 0 || singleAdBreak.position > 100) { + log.w("AdBreak having PERCENTAGE type \n " + + "should neither give percentage values less than 0 nor \n " + + "greater than 100.") + return@adBreakLoop + } + + midrollAdPositionType = AdBreakPositionType.PERCENTAGE + } + + var singleAdBreakPosition = singleAdBreak.position + + if (advertisingConfig.adTimeUnit == AdTimeUnit.SECONDS && + (singleAdBreak.adBreakPositionType == AdBreakPositionType.POSITION || singleAdBreak.adBreakPositionType == AdBreakPositionType.EVERY)) { + // Convert to milliseconds + singleAdBreakPosition = if (singleAdBreak.position > 0) (singleAdBreak.position * Consts.MILLISECONDS_MULTIPLIER) else singleAdBreak.position + } + + var singleAdBreakPositionType = singleAdBreak.adBreakPositionType + + // Special case when Pre/Post AdBreak is configured with PERCENTAGE + // Changing 100 percentage to -1 and setting type as Position + // so that further percentage to position logic will not get hampered + if (singleAdBreak.adBreakPositionType == AdBreakPositionType.PERCENTAGE) { + if (singleAdBreak.position == 100L) { + singleAdBreakPosition = -1L + } + + if (singleAdBreak.position == 0L || singleAdBreak.position == 100L) { + singleAdBreakPositionType = AdBreakPositionType.POSITION + } + } + + val adPodConfigList = parseAdPodConfig(singleAdBreak) + // Create ad break list and mark them ready + val adBreakConfig = AdBreakConfig(singleAdBreakPositionType, singleAdBreakPosition, AdState.READY, adPodConfigList) + adBreaksList.add(adBreakConfig) + } + } + sortAdsByPosition(adBreaksList) + } + } + + /** + * Parse Each AdBreak. AdBreak may contain list of ad pods + * Mark all the ad pods Ready. + */ + internal fun parseAdPodConfig(singleAdBreak: AdBreak): List { + log.d("parseAdPodConfig") + val adPodConfigList = mutableListOf() + for (adPod: List? in singleAdBreak.ads) { + val adsList = parseEachAdUrl(adPod) + val hasWaterFalling = adsList.size > 1 + val adPodConfig = AdPodConfig(AdState.READY, adsList, hasWaterFalling) + adPodConfigList.add(adPodConfig) + } + return adPodConfigList + } + + /** + * PlayAdNow JSON parsing + */ + internal fun parseAdBreakGSON(singleAdBreak: String): AdBreak? { + log.d("parseAdBreakGSON") + try { + val adBreak = Gson().fromJson(singleAdBreak, AdBreak::class.java) + if (adBreak != null) { + return adBreak + } else { + log.e("AdBreak Json is invalid") + } + } catch (e: Exception) { + log.e("AdBreak Json Exception: ${e.message}") + } + return null + } + + /** + * AdPod may contain the list of Ads (including waterfalling ads) + * Mark all the ads Ready. + */ + private fun parseEachAdUrl(ads: List?): List { + log.d("parseEachAdUrl") + val adUrls = mutableListOf() + if (ads != null) { + for (url: String in ads) { + adUrls.add(Ad(AdState.READY, url)) + } + } + return adUrls + } + + /** + * Sorting ads by position: App can pass the AdBreaks in any sequence + * Here we are arranging the ads in Pre(0)/Mid(n)/Post(-1) adroll order + * Here Mid(n) n denotes the time/percentage + */ + private fun sortAdsByPosition(adBreaksList: ArrayList) { + log.d("sortAdsByPosition") + if (adBreaksList.isNotEmpty()) { + adBreaksList.sortWith(compareBy { it.adPosition }) + prepareAdsMapAndList(adBreaksList) + movePostRollAdToLastInList() + } + } + + /** + * After the Ads sorting, create a map with position and the relevant AdBreakConfig + * Prepare a CuePoints List. List is being monitored on the controller level + * to understand the current and upcoming cuepoint + */ + private fun prepareAdsMapAndList(adBreakConfigList: ArrayList) { + log.d("prepareAdsMapAndList") + if (adBreakConfigList.isNotEmpty()) { + adsConfigMap = hashMapOf() + for (adBreakConfig: AdBreakConfig in adBreakConfigList) { + adsConfigMap?.put(adBreakConfig.adPosition, adBreakConfig) + cuePointsList?.add(adBreakConfig.adPosition) + } + } + } + + /** + * After the sorting -1 will be on the top, + * so remove it and put it at the last (Postroll) + */ + private fun movePostRollAdToLastInList() { + log.d("movePostRollAdToLastInList") + cuePointsList?.let { + if (it.first == -1L) { + it.remove(-1) + it.addLast(-1) + } + } + } + + /** + * Used only if AdBreakPositionType is PERCENTAGE + * Remove and Add the Map's Adbreak Position as per the player duration (Replace not allowed for key in Map) + * Replace the List's Adbreak Position as per the player duration + */ + fun updatePercentageBasedPosition(playerDuration: Long?) { + log.d("updatePercentageBasedPosition PlayerDuration is $playerDuration") + playerDuration?.let { + if (it <= 0) { + return + } + } + + adsConfigMap?.let { adsMap -> + val iterator = adsMap.keys.iterator() + var tempMapForUpdatedConfigs: HashMap? = HashMap(adsMap.size) + while (iterator.hasNext()) { + val entry = iterator.next() + val config = adsMap[entry] + + if (config?.adBreakPositionType == AdBreakPositionType.PERCENTAGE) { + playerDuration?.let { + // Copy of adconfig object + val newAdBreakConfig = config.copy() + // Remove the actual adconfig from map + iterator.remove() + + // Update the copied object with updated position for percentage + val oldAdPosition = newAdBreakConfig.adPosition + val updatedPosition = playerDuration.times(oldAdPosition).div(100) // Ex: 23456 + val updatedRoundedOfPositionMs = (updatedPosition.div(Consts.MILLISECONDS_MULTIPLIER)) * Consts.MILLISECONDS_MULTIPLIER // It will be changed to 23000 + newAdBreakConfig.adPosition = updatedRoundedOfPositionMs + + // Put back again the object in the temp map (Because Iterator doesn't have put method + // hence to avoidConcurrentModificationException, need to use temp map and putall the values after the iteration + // Don't use ConcurrentHashmap as it can be overkilling + tempMapForUpdatedConfigs?.put(newAdBreakConfig.adPosition, newAdBreakConfig) + cuePointsList?.forEachIndexed { index, adPosition -> + if (adPosition == oldAdPosition) { + cuePointsList?.set(index, updatedRoundedOfPositionMs) + } + } + } + } + } + tempMapForUpdatedConfigs?.let { + if (it.isNotEmpty()) { + adsMap.putAll(it) + } + it.clear() + } + tempMapForUpdatedConfigs = null + } + sortCuePointsList() + } + + /** + * Sort the cue points list again + * Move the postroll to the last + */ + private fun sortCuePointsList() { + log.d("sortCuePointsList") + cuePointsList?.sort() + movePostRollAdToLastInList() + } + + fun getEveryBasedCuePointsList(playerDuration: Long?, frequency: Long): List? { + log.d("getEveryBasedCuePointsList PlayerDuration is $playerDuration") + playerDuration?.let { + if (it <= 0) { + return null + } + } + + if (frequency > Long.MIN_VALUE) { + val updatedCuePointsList = mutableListOf() + + playerDuration?.let { duration -> + val updatedRoundedOfDurationMs = + (duration.div(Consts.MILLISECONDS_MULTIPLIER)) * Consts.MILLISECONDS_MULTIPLIER + val factor = updatedRoundedOfDurationMs / frequency + for (factorValue in 1..factor) { + updatedCuePointsList.add(frequency * factorValue) + } + } + + log.d("getEveryBasedCuePointsList $updatedCuePointsList") + + if (updatedCuePointsList.isNotEmpty()) { + if (cuePointsList?.first == 0L) { + updatedCuePointsList.add(0, 0) + } + + if (cuePointsList?.last == -1L) { + updatedCuePointsList.add(-1) + } + } + + log.d(" final updatedCuePointsList = $updatedCuePointsList") + return updatedCuePointsList + } + + return null + } + + /** + * Get Midroll ad break position type (POSITION, PERCENTAGE, EVERY) + */ + fun getMidrollAdBreakPositionType(): AdBreakPositionType { + log.d("getMidrollAdBreakPositionType") + return midrollAdPositionType + } + + /** + * If Midroll ad break position type is EVERY + * Then at what frequency ad will be played + */ + fun getMidrollFrequency(): Long { + log.d("getMidrollFrequency") + return midrollFrequency + } + + /** + * Getter for CuePoints list + */ + @Nullable + fun getCuePointsList(): LinkedList? { + log.d("getCuePointsList") + return cuePointsList + } + + /** + * Get MidRoll ads if there is any + */ + @Nullable + fun getAdsConfigMap(): MutableMap? { + log.d("getAdsConfigMap") + return adsConfigMap + } + + /** + * Get PlayAdsAfterTime value from AdvertisingConfig + */ + fun getPlayAdsAfterTime(): Long { + return playAdsAfterTime + } + + /** + * Get adType (VAST URL or VAST response) + */ + fun getAdType(): AdType { + return adType + } + + /** + * Only for LIVE Media + * After the AdPlayback, player will seek to the live edge or not + */ + fun isReturnToLive(): Boolean { + return returnToLive + } +} + diff --git a/playkit/src/main/java/com/kaltura/playkit/ads/AdvertisingController.kt b/playkit/src/main/java/com/kaltura/playkit/ads/AdvertisingController.kt new file mode 100644 index 000000000..773e1c6f6 --- /dev/null +++ b/playkit/src/main/java/com/kaltura/playkit/ads/AdvertisingController.kt @@ -0,0 +1,34 @@ +package com.kaltura.playkit.ads + +import com.kaltura.playkit.PKController + +/** + * Interface helping PKAdvertisingController to interact with AdsProvider(IMAPlugin) + */ +interface AdvertisingController: PKController { + + /** + * Set if Advertising is configured + */ + fun setAdvertisingConfig(isConfigured: Boolean, adType: AdType, imaEventsListener: IMAEventsListener?) + + /** + * Send ad for the playback to AdsProvider(IMAPlugin) + */ + fun advertisingPlayAdNow(adTag: String?) + + /** + * Set/Update CuePoints + */ + fun advertisingSetCuePoints(cuePoints: List?) + + /** + * Set AdInfo on the AdsProvider(IMAPlugin) + */ + fun advertisingSetAdInfo(pkAdvertisingAdInfo: PKAdvertisingAdInfo?) + + /** + * Prepare the Content Player + */ + fun advertisingPreparePlayer() +} diff --git a/playkit/src/main/java/com/kaltura/playkit/ads/DefaultAdControllerImpl.java b/playkit/src/main/java/com/kaltura/playkit/ads/DefaultAdControllerImpl.java index c82313de7..eac05b5b0 100644 --- a/playkit/src/main/java/com/kaltura/playkit/ads/DefaultAdControllerImpl.java +++ b/playkit/src/main/java/com/kaltura/playkit/ads/DefaultAdControllerImpl.java @@ -43,7 +43,15 @@ public void pause() { @SuppressWarnings("StatementWithEmptyBody") @Override public void seekTo(long position) { - //seeking operation during ad is blocked + //seeking operation during ad is blocked + } + + @Override + public void setVolume(float volume) { + //control playback volume [0..1.0] + if (adsProvider.isAdDisplayed()) { + adsProvider.setVolume(volume); + } } @Override diff --git a/playkit/src/main/java/com/kaltura/playkit/ads/DefaultAdvertisingControllerImpl.kt b/playkit/src/main/java/com/kaltura/playkit/ads/DefaultAdvertisingControllerImpl.kt new file mode 100644 index 000000000..06d6029ec --- /dev/null +++ b/playkit/src/main/java/com/kaltura/playkit/ads/DefaultAdvertisingControllerImpl.kt @@ -0,0 +1,30 @@ +package com.kaltura.playkit.ads + +import com.kaltura.playkit.plugins.ads.AdsProvider + +/** + * Implementation helping to interact with underlying adsProvider + * Currently, IMAPlugin is being used + */ +class DefaultAdvertisingControllerImpl(private val adsProvider: AdsProvider): AdvertisingController { + + override fun setAdvertisingConfig(isConfigured: Boolean, adType: AdType, imaEventsListener: IMAEventsListener?) { + adsProvider.setAdvertisingConfig(isConfigured, adType, imaEventsListener) + } + + override fun advertisingPlayAdNow(adTag: String?) { + adsProvider.advertisingPlayAdNow(adTag) + } + + override fun advertisingSetCuePoints(cuePoints: List?) { + adsProvider.advertisingSetCuePoints(cuePoints) + } + + override fun advertisingSetAdInfo(pkAdvertisingAdInfo: PKAdvertisingAdInfo?) { + adsProvider.advertisingSetAdInfo(pkAdvertisingAdInfo) + } + + override fun advertisingPreparePlayer() { + adsProvider.advertisingPreparePlayer() + } +} diff --git a/playkit/src/main/java/com/kaltura/playkit/ads/DefaultDAIAdControllerImpl.java b/playkit/src/main/java/com/kaltura/playkit/ads/DefaultDAIAdControllerImpl.java index 2101ac342..06d8b855f 100644 --- a/playkit/src/main/java/com/kaltura/playkit/ads/DefaultDAIAdControllerImpl.java +++ b/playkit/src/main/java/com/kaltura/playkit/ads/DefaultDAIAdControllerImpl.java @@ -43,7 +43,7 @@ public void pause() { @SuppressWarnings("StatementWithEmptyBody") @Override public void seekTo(long position) { - //seeking operation during ad is blocked + //seeking operation during ad is blocked } @Override diff --git a/playkit/src/main/java/com/kaltura/playkit/ads/IMAEventsListener.kt b/playkit/src/main/java/com/kaltura/playkit/ads/IMAEventsListener.kt new file mode 100644 index 000000000..6435636c3 --- /dev/null +++ b/playkit/src/main/java/com/kaltura/playkit/ads/IMAEventsListener.kt @@ -0,0 +1,33 @@ +package com.kaltura.playkit.ads + +import com.kaltura.playkit.plugins.ads.AdEvent + +/** + * Interface to listen to the callbacks on PKAdvertisingController from + * AdsProvider(IMAPlugin) + */ +interface IMAEventsListener { + + /** + * Listen to all ads completed event + * In Advertising case, it will be called after each Ad playback + * If Advertising is set then this event will be fired from PKAdvertisingController + */ + fun allAdsCompleted() + + /** + * Listen to content pause requested event from AdsProvider(IMAPlugin) + */ + fun contentPauseRequested() + + /** + * Listen to content resume requested event from AdsProvider(IMAPlugin) + */ + fun contentResumeRequested() + + /** + * Listen to AdError event from AdsProvider(IMAPlugin) + * It's implementation holds the logic + */ + fun adError(error: AdEvent.Error) +} diff --git a/playkit/src/main/java/com/kaltura/playkit/ads/PKAdvertising.kt b/playkit/src/main/java/com/kaltura/playkit/ads/PKAdvertising.kt new file mode 100644 index 000000000..5f8acf975 --- /dev/null +++ b/playkit/src/main/java/com/kaltura/playkit/ads/PKAdvertising.kt @@ -0,0 +1,18 @@ +package com.kaltura.playkit.ads + +import androidx.annotation.NonNull + +interface PKAdvertising { + + /** + * App may call it whenever it wants to play an AdBreak + * + * *WARNING* For Live Media, if this API is used then after + * every adPlayback, player will go back to the live edge + * + * @param adBreak AdBreak to be played + */ + fun playAdNow(adBreak: Any?, + @NonNull adType: AdType = AdType.AD_URL) +} + diff --git a/playkit/src/main/java/com/kaltura/playkit/ads/PKAdvertisingController.kt b/playkit/src/main/java/com/kaltura/playkit/ads/PKAdvertisingController.kt new file mode 100644 index 000000000..55eea1662 --- /dev/null +++ b/playkit/src/main/java/com/kaltura/playkit/ads/PKAdvertisingController.kt @@ -0,0 +1,1557 @@ +package com.kaltura.playkit.ads + +import android.text.TextUtils +import androidx.annotation.NonNull +import androidx.annotation.Nullable +import com.kaltura.playkit.* +import com.kaltura.playkit.plugins.ads.AdEvent +import com.kaltura.playkit.utils.Consts +import com.kaltura.playkit.utils.ResumableCountDownTimer +import java.util.* + +/** + * Controller to handle the Custom Ad playback + */ +class PKAdvertisingController: PKAdvertising, IMAEventsListener { + + private val log = PKLog.get(PKAdvertisingController::class.java.simpleName) + private var player: Player? = null + private var messageBus: MessageBus? = null + private var mediaConfig: PKMediaConfig? = null + private var adController: AdvertisingController? = null + private var advertisingConfig: AdvertisingConfig? = null + private var advertisingContainer: AdvertisingContainer? = null + + // List containing the Ads' position (Sorted list, Preroll is moved to 0th position and Postoll, is moved to the last) + private var cuePointsList: LinkedList? = null + // Map containing the actual ads with position as key in the Map (Insertion order of app maintained) + private var adsConfigMap: MutableMap? = null + + // LiveMedia handler + private var liveMediaCountdownTimer: ResumableCountDownTimer? = null + private val liveMediaCountdownTimerInterval: Long = 100 + private var isLiveMediaCountdownStarted: Boolean = false + private var isLiveMediaMidrollAdPlayback: Boolean = false + private var isReturnToLiveEdgeAfterAdPlayback: Boolean = false + private var firstPlayHeadUpdatedTimeForLive: Long = Long.MIN_VALUE + + private var playerStartPosition: Long = 0L + private var DEFAULT_AD_INDEX: Int = Int.MIN_VALUE + private var PREROLL_AD_INDEX: Int = 0 + private var POSTROLL_AD_INDEX: Int = 0 + + private var currentAdBreakIndexPosition: Int = DEFAULT_AD_INDEX // For each ad Break 0, 15, 30, -1 + private var nextAdBreakIndexForMonitoring: Int = DEFAULT_AD_INDEX // For Next ad Break 0, 15, 30, -1 + private var adPlaybackTriggered: Boolean = false + private var isPlayerSeeking: Boolean = false + private var isPostrollLeftForPlaying: Boolean = false + private var isAllAdsCompleted: Boolean = false + private var isAllAdsCompletedFired: Boolean = false + + private var midrollAdBreakPositionType: AdBreakPositionType = AdBreakPositionType.POSITION + private var midrollFrequency = Long.MIN_VALUE + + // PlayAdNow Setup + private var isPlayAdNowTriggered = false + private var playAdNowAdBreak: AdBreakConfig? = null + + // PlayAdsAfterTime Setup + private var isPlayAdsAfterTimeConfigured = false + private var playAdsAfterTime: Long = Long.MIN_VALUE + + // AdWaterfalling indication + private var hasWaterFallingAds = false + + // AdType + private var adType: AdType = AdType.AD_URL + + /** + * Set the AdController from PlayerLoader level + * Need to inform IMAPlugin that Advertising is configured + * before onUpdateMedia call + */ + fun setAdController(adController: AdvertisingController) { + log.d("setAdController") + this.adController = adController + } + + /** + * Set the actual advertising config object + * and map it with our internal Advertising tree + */ + fun setAdvertising(@Nullable advertisingConfig: AdvertisingConfig?) { + log.d("setAdvertising") + advertisingContainer = null + resetAdvertisingConfig() + advertisingContainer = AdvertisingContainer() + + if (advertisingConfig != null) { + initAdvertising(advertisingConfig) + } else { + log.d("setAdvertising: AdvertisingConfig is null") + } + } + + /** + * Player configuration from KalturaPlayer + */ + fun setPlayer(player: Player?, messageBus: MessageBus?, mediaConfig: PKMediaConfig) { + if (player == null || messageBus == null) { + log.d("setPlayer: Player or MessageBus is null hence cleaning up the underlying controller resources.") + release() + return + } + log.d("setPlayer") + this.player = player + this.messageBus = messageBus + this.mediaConfig = mediaConfig + subscribeToPlayerEvents() + } + + /** + * Initialize Advertising Config + */ + private fun initAdvertising(advertisingConfig: AdvertisingConfig) { + log.d("initAdvertising") + + this.advertisingConfig = advertisingConfig + advertisingContainer?.setAdvertisingConfig(this.advertisingConfig) + adType = advertisingContainer?.getAdType() ?: AdType.AD_URL + adController?.setAdvertisingConfig(true, adType, this) + adsConfigMap = advertisingContainer?.getAdsConfigMap() + isReturnToLiveEdgeAfterAdPlayback = advertisingContainer?.isReturnToLive() ?: false + checkTypeOfMidrollAdPresent(advertisingContainer?.getMidrollAdBreakPositionType(), 0L) + + cuePointsList = advertisingContainer?.getCuePointsList() + adController?.advertisingSetCuePoints(cuePointsList) + playAdsAfterTime = advertisingContainer?.getPlayAdsAfterTime() ?: Long.MIN_VALUE + + if (isAdsListEmpty()) { + log.d("All Ads are empty hence clearing the underlying resources") + resetAdvertisingConfig() + return + } + + log.d("cuePointsList $cuePointsList") + } + + /** + * After the Player prepare, starting point + * to play the Advertising + */ + fun loadAdvertising(@Nullable startPosition: Long?) { + log.d("loadAdvertising") + + if (isAdsListEmpty()) { + log.d("All Ads are empty hence clearing the underlying resources") + resetAdvertisingConfig() + return + } + + if (hasPostRoll()) { + cuePointsList?.let { + log.d("Config has Postroll") + POSTROLL_AD_INDEX = if (it.size > 1) it.size.minus(1) else 0 + nextAdBreakIndexForMonitoring = POSTROLL_AD_INDEX + } + } + + if (startPosition != null && startPosition > 0L) { + playerStartPosition = startPosition * Consts.MILLISECONDS_MULTIPLIER + } + + if (hasPreRoll()) { + log.d("Config has Preroll") + getPlayAdsAfterTimeConfiguration() + if (isPlayAdsAfterTimeConfigured) { + if (playAdsAfterTime == -1L) { + val preRollAdUrl = getAdFromAdConfigMap(PREROLL_AD_INDEX) + // Case when preroll is there then play preroll and then play immediate last ad as per player start position + if (preRollAdUrl != null) { + playAd(preRollAdUrl) + if (playerStartPosition > 0 && midrollAdBreakPositionType != AdBreakPositionType.EVERY) { + //Get the immediate last ad and play once preroll completes + nextAdBreakIndexForMonitoring = getImmediateLastAdPosition(playerStartPosition) + } + } else { + prepareContentPlayer() + } + } else { + if (midRollAdsCount() > 0 && playAdsAfterTime < playerStartPosition) { + // If playAdsAfterTime is less than playerStartPosition then play the immediate last ad from the map as per startposition + // It will skip the preroll + nextAdBreakIndexForMonitoring = getImmediateLastAdPosition(playerStartPosition) + if (nextAdBreakIndexForMonitoring > 0) { + val playAbleAdUrl = getAdFromAdConfigMap(nextAdBreakIndexForMonitoring) + if (playAbleAdUrl != null) { + playAd(playAbleAdUrl) + } + } else { + prepareContentPlayer() + } + } else { + // If playAdsAfterTime is greater than startPosition then prepare the content player + if (midRollAdsCount() > 0 && playAdsAfterTime > playerStartPosition) { + nextAdBreakIndexForMonitoring = if (midrollFrequency > Long.MIN_VALUE && midrollAdBreakPositionType == AdBreakPositionType.EVERY) { + if (hasPreRoll()) { 1 } else { 0 } + } else { + getImmediateNextAdPosition(playAdsAfterTime) + } + } + prepareContentPlayer() + } + } + } else { + if (playerStartPosition > 0L) { + nextAdBreakIndexForMonitoring = if (midRollAdsCount() > 0 && midrollFrequency > Long.MIN_VALUE && midrollAdBreakPositionType == AdBreakPositionType.EVERY) { + if (hasPreRoll()) { 1 } else { 0 } + } else { + getImmediateNextAdPosition(playerStartPosition) + } + prepareContentPlayer() + } else { + val preRollAdUrl = getAdFromAdConfigMap(PREROLL_AD_INDEX) + if (preRollAdUrl != null) { + playAd(preRollAdUrl) + } else { + prepareContentPlayer() + } + } + } + } else { + if (midRollAdsCount() > 0) { + log.d("Config has Midroll/s") + cuePointsList?.let { + if (it.isNotEmpty()) { + // Update next Ad index for monitoring + if (playerStartPosition > 0L) { + nextAdBreakIndexForMonitoring = getImmediateNextAdPosition(playerStartPosition) + } else { + nextAdBreakIndexForMonitoring = 0 + } + } + } + } + getPlayAdsAfterTimeConfiguration() + // In case if there is no Preroll ad + // prepare the content player + prepareContentPlayer() + } + } + + /** + * App may call it whenever it wants to play an AdBreak + */ + override fun playAdNow(adBreak: Any?, @NonNull adType: AdType) { + log.d("playAdNow AdBreak is $adBreak") + + var parsedAdBreak: AdBreak? = null + + adBreak?.let { + parsedAdBreak = if (it is String) { + advertisingContainer?.parseAdBreakGSON(it) + } else if (it is AdBreak) { + it + } else { + log.e("Malformed AdBreak Input. PlayAdNow API either support AdBreak Object or AdBreak JSON. Hence returning.") + return + } + } + + parsedAdBreak?.let parsedAdbreak@ { + if (advertisingContainer == null) { + log.d("AdvertisingContainer is null. Hence discarding the AdPlayback") + return@parsedAdbreak + } + + player?.let { plyr -> + if (plyr.currentPosition <= 0) { + log.e("PlayAdNow API can be used once the content playback starts.") + return@parsedAdbreak + } + } + + if (adPlaybackTriggered) { + log.e("Currently another Ad is either loading or being played, hence discarding PlayAdNow API request.") + return@parsedAdbreak + } + + if (it.adBreakPositionType == AdBreakPositionType.EVERY || it.adBreakPositionType == AdBreakPositionType.PERCENTAGE) { + log.e("For playAdNow, AdBreakPositionType can only be AdBreakPositionType.POSITION. Hence discarding the AdPlayback") + return@parsedAdbreak + } + + if (it.position > 0) { + isPlayAdNowTriggered = true + val adPodConfig = advertisingContainer?.parseAdPodConfig(it) + playAdNowAdBreak = AdBreakConfig(it.adBreakPositionType, 0L, AdState.READY, adPodConfig) + val adUrl = fetchPlayableAdFromAdsList(playAdNowAdBreak, false) + adUrl?.let { url -> + log.d("playAdNow") + adController?.setAdvertisingConfig(true, adType, this) + playAd(url) + } + } else { + log.d("PlayAdNow is not a replacement of Pre-roll or Postroll AdPlayback. Hence discarding. \n " + + "AdBreak Position should be greater than zero.") + } + } + } + + /** + * Player Events' subscription + */ + private fun subscribeToPlayerEvents() { + log.d("subscribeToPlayerEvents") + var triggerAdPlaybackCounter = 0 + + messageBus?.addListener(this, PlayerEvent.playheadUpdated) { event -> + + if (isAllAdsCompletedFired) { + log.d("All ads has completed its playback hence returning.") + return@addListener + } + + if (isLiveMedia() && firstPlayHeadUpdatedTimeForLive == Long.MIN_VALUE && isPlayAdsAfterTimeConfigured && playAdsAfterTime > Long.MIN_VALUE) { + firstPlayHeadUpdatedTimeForLive = System.currentTimeMillis() + } + + if (isLiveMedia()) { + if (midRollAdsCount() <= 0 || midrollAdBreakPositionType != AdBreakPositionType.EVERY || midrollFrequency <= Long.MIN_VALUE) { + log.v("For Live Media, either midrolls are not there or midrolls are not based on EVERY. Hence returning.") + return@addListener + } + + if (hasOnlyPreRoll() || + (!hasPreRoll() && midRollAdsCount() <= 0 && hasPostRoll()) || + (midRollAdsCount() > 0 && (midrollAdBreakPositionType != AdBreakPositionType.EVERY || midrollFrequency <= Long.MIN_VALUE))) { + return@addListener + } + + if (!isLiveMediaCountdownStarted && !adPlaybackTriggered) { + if (isPlayAdsAfterTimeConfigured && (firstPlayHeadUpdatedTimeForLive + playAdsAfterTime) >= System.currentTimeMillis()) { + log.v("For Live Media, playAdsAfterTime is less than the current time. Hence returning.") + return@addListener + } + + isLiveMediaCountdownStarted = true + createTimerForLiveMedias() + return@addListener + } else if (isLiveMediaCountdownStarted && !isLiveMediaMidrollAdPlayback && !adPlaybackTriggered) { + return@addListener + } + } + + cuePointsList?.let { list -> + + if (list.isEmpty() || nextAdBreakIndexForMonitoring == DEFAULT_AD_INDEX) { + log.v("playheadUpdated: Ads are empty, dropping ad playback.") + return@addListener + } + + if (adPlaybackTriggered) { + log.d("playheadUpdated: Ad is playing or being loaded, dropping ad playback.") + return@addListener + } + + if (hasOnlyPreRoll()) { + log.v("playheadUpdated: Only Preroll ad is available hence returning from here.") + return@addListener + } + + if (!isAllAdsCompleted) { + log.v("nextAdBreakIndexForMonitoring = $nextAdBreakIndexForMonitoring") + log.v("adPlaybackTriggered = $adPlaybackTriggered") + log.v("event.position = ${event.position}") + log.v("midrollFrequency = $midrollFrequency") + log.v("midrollAdBreakPositionType = $midrollAdBreakPositionType") + } + + if (!isPlayAdsAfterTimeConfigured && playerStartPosition > 0 && event.position < playerStartPosition) { + return@addListener + } + + if (!isLiveMedia() && isPlayAdsAfterTimeConfigured && playAdsAfterTime > Long.MIN_VALUE && event.position < playAdsAfterTime) { + log.v("playheadUpdated: current position = ${event.position} is less than the playAdsAfterTime = $playAdsAfterTime. Hence returning.") + return@addListener + } + + if (nextAdBreakIndexForMonitoring < 0 || nextAdBreakIndexForMonitoring >= list.size) { + log.d("playheadUpdated: Invalid nextAdBreakIndexForMonitoring") + return@addListener + } + + if (midrollAdBreakPositionType == AdBreakPositionType.EVERY) { + if (!isLiveMedia() && midrollFrequency > Long.MIN_VALUE && + event.position > Consts.MILLISECONDS_MULTIPLIER && + ((event.position % midrollFrequency) < Consts.MILLISECONDS_MULTIPLIER)) { + triggerAdPlaybackCounter++ + } else { + triggerAdPlaybackCounter = 0 + } + + if (isLiveMedia()) { + if (isLiveMediaMidrollAdPlayback && midrollFrequency > Long.MIN_VALUE) { + triggerAdPlaybackCounter++ + } else { + triggerAdPlaybackCounter = 0 + } + } + + } else { + if (!isLiveMedia() && + list[nextAdBreakIndexForMonitoring] > 0 && + event.position >= list[nextAdBreakIndexForMonitoring] && + (event.position > Consts.MILLISECONDS_MULTIPLIER && + (event.position % list[nextAdBreakIndexForMonitoring]) < Consts.MILLISECONDS_MULTIPLIER)) { + + triggerAdPlaybackCounter++ + } else { + triggerAdPlaybackCounter = 0 + } + } + + if (!isPlayerSeeking && + !adPlaybackTriggered && + triggerAdPlaybackCounter == 1) { + + if (hasPostRoll() && list[nextAdBreakIndexForMonitoring] == list.last) { + log.d("PlayheadUpdated: postroll position") + return@addListener + } + log.d("playheadUpdated ${event.position} & nextAdIndexForMonitoring is $nextAdBreakIndexForMonitoring & nextAdForMonitoring ad position is = ${list[nextAdBreakIndexForMonitoring]}") + log.d("nextAdForMonitoring ad position is = $list") + + getAdFromAdConfigMap(nextAdBreakIndexForMonitoring)?.let { adUrl -> + resetTimerForLiveMedias() + playAd(adUrl) + } + } + } + } + + messageBus?.addListener(this, PlayerEvent.pause) { + log.d("PlayerEvent pause") + if (isLiveMediaCountdownStarted) { + liveMediaCountdownTimer?.pause() + } + } + + messageBus?.addListener(this, PlayerEvent.play) { + log.d("PlayerEvent play") + if (isLiveMedia() && midRollAdsCount() > 0 && midrollFrequency > Long.MIN_VALUE) { + if (isReturnToLiveEdgeAfterAdPlayback) { + player?.seekToLiveDefaultPosition() + } + } + } + + messageBus?.addListener(this, PlayerEvent.playing) { + log.d("PlayerEvent playing") + if (isLiveMediaCountdownStarted) { + liveMediaCountdownTimer?.resume() + } + } + + messageBus?.addListener(this, PlayerEvent.seeking) { + log.d("Player seeking for player position = ${player?.currentPosition} - currentPosition ${it.currentPosition} - targetPosition ${it.targetPosition}" ) + isPlayerSeeking = true + if (isLiveMedia()) { + return@addListener + } + } + + messageBus?.addListener(this, PlayerEvent.loadedMetadata) { + log.d("loadedMetadata Player duration is = ${player?.duration}") + checkTypeOfMidrollAdPresent(advertisingContainer?.getMidrollAdBreakPositionType(), player?.duration) + } + + messageBus?.addListener(this, PlayerEvent.seeked) { + isPlayerSeeking = false + + if (isLiveMedia()) { + resetTimerForLiveMedias() + return@addListener + } + + if (isAllAdsCompletedFired) { + log.d("Player seeked to position = ${player?.currentPosition} but All ads has completed its playback hence returning.") + return@addListener + } + + val seekedPosition: Long = player?.currentPosition ?: return@addListener + + cuePointsList?.let { list -> + + if (list.isEmpty() || nextAdBreakIndexForMonitoring == DEFAULT_AD_INDEX) { + log.d("seeked: Ads are empty, dropping ad playback.") + return@addListener + } + + if (nextAdBreakIndexForMonitoring < 0 || nextAdBreakIndexForMonitoring >= list.size) { + log.d("seeked: Invalid nextAdBreakIndexForMonitoring") + return@addListener + } + + if (adPlaybackTriggered) { + log.d("seeked: Ad is playing or being loaded, dropping ad playback.") + return@addListener + } + + // In case, if playAdsAfterTime is not configured and user seeked before the start Position then don't play the Ads. + // On the other hand if playAdsAfterTime is configured then let the ad play in the normal fashion + if (!isPlayAdsAfterTimeConfigured && playerStartPosition > 0 && seekedPosition < playerStartPosition) { + return@addListener + } + + adPlaybackTriggered = false + log.d("Player seeked to position = $seekedPosition") + if (midrollAdBreakPositionType == AdBreakPositionType.EVERY && + midrollFrequency > Long.MIN_VALUE && + seekedPosition > midrollFrequency && + ((seekedPosition % midrollFrequency) == 0L || (seekedPosition % midrollFrequency) < midrollFrequency)) { + + getAdFromAdConfigMap(nextAdBreakIndexForMonitoring)?.let { adUrl -> + log.d("Midroll is EVERY(Frequency based) hence playing the last Ad.") + playAd(adUrl) + } + + return@addListener + } + + if (midRollAdsCount() > 0) { + val lastAdPosition = getImmediateLastAdPosition(seekedPosition) + if (lastAdPosition > 0 || (lastAdPosition == 0 && !hasPreRoll())) { + log.d("Ad found on the left side of ad list") + getAdFromAdConfigMap(lastAdPosition)?.let { adUrl -> + playAd(adUrl) + } + } else { + log.d("No Ad found on the left side of ad list, finding on right side") + // Trying to get the immediate Next ad from pod + val nextAdPosition = getImmediateNextAdPosition(seekedPosition) + if ((hasPreRoll() && nextAdPosition > 0) || (!hasPreRoll() && nextAdPosition >= 0)) { + log.d("Ad found on the right side of ad list, update the current and next ad Index") + nextAdBreakIndexForMonitoring = nextAdPosition + } else if (seekedPosition == 0L && nextAdPosition == -1) { + // Is seeked back till 0 then update the next index to monitor + if (hasPreRoll() && midRollAdsCount() > 0) { + nextAdBreakIndexForMonitoring = 1 + } else if (midRollAdsCount() > 0) { + nextAdBreakIndexForMonitoring = 0 + } + } + } + } + } + } + + messageBus?.addListener(this, PlayerEvent.ended) { + log.d("PlayerEvent.ended came = ${player?.currentPosition}" ) + if (hasPostRoll()) { + if (!adPlaybackTriggered) { + playPostrollAdBreak() + } else { + isPostrollLeftForPlaying = true + } + } else { + // Resetting the index position for the replay case after the player event ended + currentAdBreakIndexPosition = DEFAULT_AD_INDEX + nextAdBreakIndexForMonitoring = DEFAULT_AD_INDEX + adPlaybackTriggered = false + } + } + } + + /** + * Countdown timer only for Live medias + * This timer helps to calculate the time left for the next midroll + * Timer has a flag which becomes true when the timer finishes + * This flag is being used in `playHeadUpdated` event to trigger the midroll + */ + private fun createTimerForLiveMedias() { + log.d("createTimerForLiveMedias") + liveMediaCountdownTimer = object: ResumableCountDownTimer(midrollFrequency, liveMediaCountdownTimerInterval) { + override fun onTick(millisUntilFinished: Long) { + log.v("Live CountdownTimer millisUntilFinished $millisUntilFinished") + } + + override fun onFinish() { + log.v("CountdownTimer onFinish") + isLiveMediaMidrollAdPlayback = true + } + } + + liveMediaCountdownTimer?.start() + } + + /** + * Reset the countdown timer + */ + private fun resetTimerForLiveMedias() { + log.d("resetTimerForLiveMedias") + liveMediaCountdownTimer?.cancel() + isLiveMediaMidrollAdPlayback = false + isLiveMediaCountdownStarted = false + liveMediaCountdownTimer = null + } + + /** + * Check of the PlayAdsAfter time is configured by the app + */ + private fun getPlayAdsAfterTimeConfiguration() { + if (playAdsAfterTime == -1L || playAdsAfterTime > 0L) { + val nextAdPosition = getImmediateNextAdPosition(playAdsAfterTime) + if (nextAdPosition > 0) { + nextAdBreakIndexForMonitoring = nextAdPosition + } else if (midrollAdBreakPositionType == AdBreakPositionType.EVERY) { + if (hasPreRoll() && midRollAdsCount() > 0 && hasPostRoll()) { + nextAdBreakIndexForMonitoring = 1 + } else if (midRollAdsCount() > 0 && hasPostRoll()) { + nextAdBreakIndexForMonitoring = 0 + } + } + log.d("playAdsAfterTime = $playAdsAfterTime and nextAdPosition is $nextAdPosition") + isPlayAdsAfterTimeConfigured = true + return + } + isPlayAdsAfterTimeConfigured = false + log.d("isPlayAdsAfterTimeConfigured : $isPlayAdsAfterTimeConfigured") + } + + /** + * Mapping of ALL_ADS_COMPLETED event from IMAPlugin + */ + override fun allAdsCompleted() { + log.d("allAdsCompleted callback and currentAdBreakIndexPosition is $currentAdBreakIndexPosition") + adPlaybackTriggered = false + if (isPlayAdNowTriggered) { + handlePlayAdNowPlayback(AdEvent.allAdsCompleted, null) + return + } + changeAdState(AdState.PLAYED, AdRollType.AD) + val adUrl = getAdFromAdConfigMap(currentAdBreakIndexPosition) + if (adUrl != null) { + playAd(adUrl) + return + } else { + changeAdState(AdState.PLAYED, AdRollType.ADBREAK) + if (isPlayAdsAfterTimeConfigured && + playerStartPosition > 0L && + hasPreRoll() && + midRollAdsCount() > 0 && + currentAdBreakIndexPosition == 0 && + nextAdBreakIndexForMonitoring > DEFAULT_AD_INDEX) { + + // Special case, to mimic IMA behaviour so if playAdsAfterTime = -1 and startPosition > 0 + // Then after pre-roll playback, immediate last ad wrt startPosition will be played + val adUrlForAdsAfterTimeAfterPreroll = getAdFromAdConfigMap(nextAdBreakIndexForMonitoring) + if (adUrlForAdsAfterTimeAfterPreroll != null) { + playAd(adUrlForAdsAfterTimeAfterPreroll) + return + } + } + playContent() + } + + if (isPostrollLeftForPlaying) { + playPostrollAdBreak() + } + isPostrollLeftForPlaying = false + } + + override fun contentPauseRequested() { + adPlaybackTriggered = true + } + + override fun contentResumeRequested() { + adPlaybackTriggered = false + } + + /** + * Mapping of AD_ERROR event from IMAPlugin + */ + override fun adError(error: AdEvent.Error) { + log.w("AdEvent.error callback $error") + adPlaybackTriggered = false + if (isPlayAdNowTriggered && error.error.errorType != PKAdErrorType.VIDEO_PLAY_ERROR) { + handlePlayAdNowPlayback(AdEvent.adBreakFetchError, error) + return + } + if (error.error.errorType != PKAdErrorType.VIDEO_PLAY_ERROR) { + val ad = getAdFromAdConfigMap(currentAdBreakIndexPosition) + if (ad.isNullOrEmpty()) { + log.d("Ad is completely error $error") + handleErrorEvent(true, getCurrentAdBreakConfig(), error) + changeAdState(AdState.ERROR, AdRollType.ADBREAK) + playContent() + } else { + log.d("Playing next waterfalling ad") + handleErrorEvent(false, getCurrentAdBreakConfig(), error) + changeAdState(AdState.ERROR, AdRollType.ADPOD) + playAd(ad) + } + } else { + handleErrorEvent(null, getCurrentAdBreakConfig(), error) + log.d("PKAdErrorType.VIDEO_PLAY_ERROR currentAdIndexPosition = $currentAdBreakIndexPosition") + cuePointsList?.let { cueList -> + if (currentAdBreakIndexPosition != DEFAULT_AD_INDEX) { + val adPosition: Long = cueList[currentAdBreakIndexPosition] + if (currentAdBreakIndexPosition < cueList.size - 1 && adPosition != -1L) { + // Update next Ad index for monitoring + nextAdBreakIndexForMonitoring = currentAdBreakIndexPosition + 1 + log.d("nextAdIndexForMonitoring is $nextAdBreakIndexForMonitoring") + } + } + } + playContent() + } + } + + /** + * Handle Error event for AdvertisingConfig + * Send AD_WATERFALLING, AD_WATERFALLING_FAILED and ERROR event to the client apps. + */ + private fun handleErrorEvent(isAllAdsFailed: Boolean?, adBreakConfig: AdBreakConfig?, error: AdEvent.Error?) { + log.e("isAdWaterFallingOccurred $hasWaterFallingAds") + isAllAdsFailed?.let { + if (hasWaterFallingAds) { + if (it) { + messageBus?.post( + AdEvent.AdWaterFallingFailed(adBreakConfig) + ) + // When the all ads in the waterfalling list fail then with `AdWaterFallingFailed`, we need to fire + // AdEvent.error as well so that Analytics behaves as usual + error?.let { err -> + messageBus?.post(err) + } + log.e("Firing AdEvent.error after AdWaterFallingFailed event") + } else { + messageBus?.post( + AdEvent.AdWaterFalling(adBreakConfig) + ) + log.d("Firing AdWaterFalling event") + } + } else { + error?.let { err -> + messageBus?.post(err) + } + log.e("Firing AdError because there was no AdWaterFalling") + } + hasWaterFallingAds = false + return + } + // else Fire AdError + error?.let { err -> + log.d("Firing AdError $err") + messageBus?.post(err) + } + hasWaterFallingAds = false + } + + @Nullable + private fun getCurrentAdBreakConfig(): AdBreakConfig? { + log.d("getCurrentAdBreakConfig") + if (currentAdBreakIndexPosition > DEFAULT_AD_INDEX) { + cuePointsList?.let { cuePointsList -> + if (cuePointsList.isNotEmpty()) { + val adPosition: Long = cuePointsList[currentAdBreakIndexPosition] + adsConfigMap?.let { adsMap -> + return if (adsMap.isEmpty()) { + null + } else { + adsMap[adPosition] + } + } + } + } + } + return null + } + + /** + * Handle the PlayAdNow AdBreak/AdPod/Waterfalling playback + */ + private fun handlePlayAdNowPlayback(adEventType: AdEvent.Type, error: AdEvent.Error?) { + log.d("handlePlayAdNowPlayback ${adEventType.name}") + if (adEventType == AdEvent.allAdsCompleted) { + changeAdBreakState(playAdNowAdBreak, AdRollType.AD, AdState.PLAYED) + val adUrl = fetchPlayableAdFromAdsList(playAdNowAdBreak, false) + if (adUrl != null) { + playAd(adUrl) + } else { + changeAdBreakState(playAdNowAdBreak, AdRollType.ADBREAK, AdState.PLAYED) + isPlayAdNowTriggered = false + playAdNowAdBreak = null + playContent() + } + } else if (adEventType == AdEvent.adBreakFetchError) { + val adUrl = fetchPlayableAdFromAdsList(playAdNowAdBreak, false) + if (adUrl.isNullOrEmpty()) { + log.d("PlayAdNow Ad is completely errored") + handleErrorEvent(true, playAdNowAdBreak, error) + changeAdBreakState(playAdNowAdBreak , AdRollType.ADBREAK, AdState.ERROR) + isPlayAdNowTriggered = false + playAdNowAdBreak = null + playContent() + } else { + log.d("Playing next waterfalling ad") + handleErrorEvent(false, playAdNowAdBreak, error) + changeAdBreakState(playAdNowAdBreak, AdRollType.ADPOD, AdState.ERROR) + playAd(adUrl) + } + } else { + handleErrorEvent(null, playAdNowAdBreak, error) + isPlayAdNowTriggered = false + playAdNowAdBreak = null + playContent() + } + } + + /** + * Gets the next ad from AdsConfigMap using the cuePoints list + * Set the next ad break position to be monitored as well + */ + @Nullable + private fun getAdFromAdConfigMap(adIndex: Int): String? { + log.d("getAdFromAdConfigMap") + + if (isAllAdsCompletedFired) { + log.d("All ads have completed its playback. Hence returning null from here.") + return null + } + + var adUrl: String? = null + cuePointsList?.let { cuePointsList -> + + if (adIndex == cuePointsList.size || adIndex == DEFAULT_AD_INDEX) { + currentAdBreakIndexPosition = DEFAULT_AD_INDEX + nextAdBreakIndexForMonitoring = DEFAULT_AD_INDEX + return null + } + + if (cuePointsList.isNotEmpty()) { + val adPosition: Long = cuePointsList[adIndex] + adsConfigMap?.let { + getAdPodConfigMap(adPosition)?.let { + if (it.adBreakPositionType == AdBreakPositionType.EVERY) { + // For EVERY based midrolls always send 'isTriggeredFromPlayerPosition' true + adUrl = fetchPlayableAdOnFrequency(it, true) + adUrl?.let { + currentAdBreakIndexPosition = adIndex + } + return adUrl + } + + if ((it.adBreakState == AdState.PLAYING || it.adBreakState == AdState.READY) && it.adBreakPositionType != AdBreakPositionType.EVERY) { + adUrl = fetchPlayableAdFromAdsList(it, false) + adUrl?.let { + currentAdBreakIndexPosition = adIndex + log.d("currentAdIndexPosition is $currentAdBreakIndexPosition") + if (currentAdBreakIndexPosition < cuePointsList.size - 1 && adPosition != -1L) { + // Update next Ad index for monitoring + nextAdBreakIndexForMonitoring = currentAdBreakIndexPosition + 1 + log.d("nextAdIndexForMonitoring is $nextAdBreakIndexForMonitoring") + } + } + } + } + } + } + } + + return adUrl + } + + /** + * Get the specific AdBreakConfig by position + */ + @Nullable + private fun getAdPodConfigMap(position: Long?): AdBreakConfig? { + log.d("getAdPodConfigMap") + + var adBreakConfig: AdBreakConfig? = null + advertisingContainer?.let { _ -> + adsConfigMap?.let { adsMap -> + if (adsMap.contains(position)) { + adBreakConfig = adsMap[position] + } + } + } + + log.d("getAdPodConfigMap AdPodConfig is $adBreakConfig and podState is ${adBreakConfig?.adBreakState}") + return adBreakConfig + } + + /** + * In case if the AdBreakPositionType is EVERY + */ + @Nullable + private fun fetchPlayableAdOnFrequency(adBreakConfig: AdBreakConfig?, isTriggeredFromPlayerPosition: Boolean): String? { + log.d("fetchPlayableAdOnFrequency") + if (midRollAdsCount() > 0) { + return fetchPlayableAdFromAdsList(adBreakConfig, isTriggeredFromPlayerPosition) + } + return null + } + + /** + * Check the AdBreakConfig state and get the AdPod accordingly + */ + @Nullable + private fun fetchPlayableAdFromAdsList(adBreakConfig: AdBreakConfig?, isTriggeredFromPlayerPosition: Boolean): String? { + log.d("fetchPlayableAdFromAdsList AdBreakConfig is $adBreakConfig") + var adTagUrl: String? = null + hasWaterFallingAds = false + + when (adBreakConfig?.adBreakState) { + AdState.READY -> { + log.d("fetchPlayableAdFromAdsList -> I am in ready State and getting the first ad Tag.") + adBreakConfig.adPodList?.let { adPodList -> + adBreakConfig.adBreakState = AdState.PLAYING + if (adPodList.isNotEmpty()) { + val adsList = adPodList[0].adList + adsList?.let { ads -> + if (ads.isNotEmpty()) { + ads[0].adState = AdState.PLAYING + adPodList[0].adPodState = AdState.PLAYING + adTagUrl = ads[0].ad + } + } + } + } + } + + AdState.PLAYING -> { + log.d("fetchPlayableAdFromAdsList -> I am in Playing State and checking for the next ad Tag.") + adBreakConfig.adPodList?.let { adPodList -> + adTagUrl = getAdFromAdPod(adPodList, adBreakConfig.adBreakPositionType, isTriggeredFromPlayerPosition) + } + } + + AdState.PLAYED -> { + if (isTriggeredFromPlayerPosition && adBreakConfig.adBreakPositionType == AdBreakPositionType.EVERY) { + // We are not resetting the AdBreak, only the internal adPod or ad are being reset. + log.d("fetchPlayableAdFromAdsList -> I am in Played State only for adBreakPositionType EVERY \n " + + "and checking for the PLAYED ad Tag.") + adBreakConfig.adPodList?.let { adPodList -> + adTagUrl = getAdFromAdPod(adPodList, adBreakConfig.adBreakPositionType, isTriggeredFromPlayerPosition) + } + } + } + + else -> { + // Do Nothing + } + } + + return adTagUrl + } + + /** + * Check the AdPodConfig state and get the Ad accordingly + */ + + @Nullable + private fun getAdFromAdPod(adPodList: List, adBreakPositionType: AdBreakPositionType, isTriggeredFromPlayerPosition: Boolean): String? { + log.d("getAdFromAdPod") + + val adUrl: String? = null + for (adPodConfig: AdPodConfig in adPodList) { + hasWaterFallingAds = adPodConfig.hasWaterFalling + + when(adPodConfig.adPodState) { + + AdState.ERROR -> { + continue + } + + AdState.READY -> { + log.d("getAdFromAdPod -> I am in ready State and getting the first ad Tag.") + adPodConfig.adList?.let { + if(it.isNotEmpty()) { + it[0].adState = AdState.PLAYING + adPodConfig.adPodState = AdState.PLAYING + return it[0].ad + } + } + } + + AdState.PLAYING, AdState.PLAYED -> { + if (adPodConfig.adPodState == AdState.PLAYED) { + // Move AdState.PLAYED to outside on top level + continue + } + + log.d("getAdFromAdPod -> I am in Playing State and checking for the next ad Tag.") + adPodConfig.adList?.let { adsList -> + if(adsList.isNotEmpty()) { + for (specificAd: Ad in adsList) { + log.d("specificAd State ${specificAd.adState}") + log.d("specificAd ${specificAd.ad}") + when (specificAd.adState) { + AdState.ERROR -> continue + + AdState.PLAYED -> { + if (adBreakPositionType == AdBreakPositionType.EVERY && isTriggeredFromPlayerPosition) { + // ONLY in case of EVERY if there is an Ad with PLAYED state return this ad. + return specificAd.ad + } else { + continue + } + } + + AdState.READY -> { + adPodConfig.adPodState = AdState.PLAYING + specificAd.adState = AdState.PLAYING + return specificAd.ad + } + + AdState.PLAYING -> { + specificAd.adState = AdState.ERROR + } + } + } + } + } + } + } + } + return adUrl + } + + /** + * After each successful or error ad playback, + * Change the AdBreak, AdPod OR Ad state accordingly + */ + private fun changeAdState(adState: AdState, adRollType: AdRollType) { + log.d("changeAdPodState AdState is $adState and AdrollType is $adRollType") + advertisingContainer?.let advertisingContainer@ { + cuePointsList?.let { cuePointsList -> + if (cuePointsList.isNotEmpty()) { + adsConfigMap?.let { adsMap -> + if (currentAdBreakIndexPosition != DEFAULT_AD_INDEX) { + val adPosition: Long = cuePointsList[currentAdBreakIndexPosition] + val adBreakConfig: AdBreakConfig? = adsMap[adPosition] + changeAdBreakState(adBreakConfig, adRollType, adState) + if (!isAllAdsCompleted && adPosition == -1L && adState == AdState.PLAYED && adRollType == AdRollType.ADBREAK) { + log.d("It's PostRoll and it is played completely, firing allAdsCompleted from here.") + fireAllAdsCompleteEvent() + return@advertisingContainer + } else { + checkAllAdsArePlayed() + } + } + } + } + } + } + } + + /** + * Change the internal state of AdBreak, AdPod or Ad + * + * *NOTE*: For EVERY based midrolls AdPod State will be changed to READY + * This is important because for the next EVERY based midroll, we will pick those AdPods + * again and mark it Played/Errored accordingly [This will only be set when the all the AdPods + * of the AdBreak are done with playback] + * + */ + private fun changeAdBreakState(adBreakConfig: AdBreakConfig?, adRollType: AdRollType, adState: AdState) { + log.d("changeAdBreakState AdBreakConfig: $adBreakConfig") + adBreakConfig?.let { adBreak -> + log.d("AdState is changed for AdPod position ${adBreak.adPosition}") + if (adRollType == AdRollType.ADBREAK) { + adBreak.adBreakState = adState + } + + adBreak.adPodList?.forEach { + if (adRollType == AdRollType.ADBREAK && it.adPodState == AdState.PLAYING) { + it.adPodState = adState + } + + var isAdPodCompletelyErrored = 0 + it.adList?.forEach { ad -> + if (adRollType == AdRollType.AD && ad.adState == AdState.PLAYING) { + if (ad.adState != AdState.ERROR) { + it.adPodState = adState + } + ad.adState = adState + } + + if (adBreak.adBreakPositionType == AdBreakPositionType.EVERY && + adRollType == AdRollType.AD && + adState == AdState.PLAYED && + ad.adState == AdState.PLAYED && + it.adPodState == AdState.PLAYING) { + + // NOTE: Continuation of method Javadoc: + // Because there will be ads which are already in PLAYED state + // but even though those were played due to EVERY. + // Check it and mark the AdPod state to PLAYED + it.adPodState = AdState.PLAYED + } + + if (ad.adState != AdState.ERROR) { + isAdPodCompletelyErrored++ + } + } + + if (isAdPodCompletelyErrored == 0) { + it.adPodState = AdState.ERROR + } else if (adBreak.adBreakPositionType == AdBreakPositionType.EVERY && + adState == AdState.PLAYED && + adRollType == AdRollType.ADBREAK) { + + // NOTE: Continuation of method Javadoc: + // If All the ads of AdBreak completed then mark AdPod to READY + it.adPodState = AdState.READY + } + } + } + } + + /** + * Check te Midroll adbreak type + * Act only if it is EVERY or PERCENTAGE + * For Every case, get the midroll frequency + * For PERCENTAGE case, update the Advertising tree data structure + */ + private fun checkTypeOfMidrollAdPresent(adBreakPositionType: AdBreakPositionType?, playerDuration: Long?) { + log.d("checkTypeOfMidrollAdPresent") + + when(adBreakPositionType) { + AdBreakPositionType.EVERY -> { + midrollAdBreakPositionType = adBreakPositionType + midrollFrequency = advertisingContainer?.getMidrollFrequency() ?: Long.MIN_VALUE + if (isLiveMedia()) { + log.d("For Live medias no cue point update is required.") + return + } + val updatedCuePoints: List? = advertisingContainer?.getEveryBasedCuePointsList(playerDuration, midrollFrequency) + updatedCuePoints?.let { + adController?.advertisingSetCuePoints(it) + log.d("Updated cuePointsList for EVERY based Midrolls $it") + } + } + + AdBreakPositionType.PERCENTAGE -> { + playerDuration?.let { + if (it > 0) { + advertisingContainer?.updatePercentageBasedPosition(playerDuration) + adsConfigMap = advertisingContainer?.getAdsConfigMap() + cuePointsList = advertisingContainer?.getCuePointsList() + adController?.advertisingSetCuePoints(cuePointsList) + log.d("Updated cuePointsList for PERCENTAGE based Midrolls $cuePointsList") + } + } + } + else -> return + } + } + + /** + * Resets the Advertising Config + * */ + private fun resetAdvertisingConfig() { + log.d("resetAdvertisingConfig") + advertisingConfig = null + + adController?.setAdvertisingConfig(false, adType, null) + + cuePointsList = null + adsConfigMap = null + + DEFAULT_AD_INDEX = Int.MIN_VALUE + PREROLL_AD_INDEX = 0 + POSTROLL_AD_INDEX = 0 + currentAdBreakIndexPosition = DEFAULT_AD_INDEX + nextAdBreakIndexForMonitoring = DEFAULT_AD_INDEX + adPlaybackTriggered = false + isPlayerSeeking = false + isPostrollLeftForPlaying = false + isAllAdsCompleted = false + isAllAdsCompletedFired = false + + midrollAdBreakPositionType = AdBreakPositionType.POSITION + midrollFrequency = Long.MIN_VALUE + + isPlayAdNowTriggered = false + playAdNowAdBreak = null + + isPlayAdsAfterTimeConfigured = false + playAdsAfterTime = Long.MIN_VALUE + resetTimerForLiveMedias() + isReturnToLiveEdgeAfterAdPlayback = false + isReturnToLiveEdgeAfterAdPlayback = false + firstPlayHeadUpdatedTimeForLive = Long.MIN_VALUE + + hasWaterFallingAds = false + } + + /** + * Releasing the underlying resources + */ + fun release() { + advertisingContainer = null // Don't change the position of this + resetAdvertisingConfig() + destroyConfigResources() + adController = null // Don't change the position of this + log.d("Advertising Controller resources have been released completely") + } + + /** + * Destroy the Advertising Config in case if + * config is null + */ + private fun destroyConfigResources() { + log.d("destroyConfigResources") + this.player = null + this.messageBus?.removeListeners(this) + this.messageBus = null + this.mediaConfig = null + } + + /** + * Check if all the ads are completely played + */ + private fun checkAllAdsArePlayed(): Boolean { + if (isAllAdsCompleted) { + log.d("isAllAdsCompleted: $isAllAdsCompleted") + fireAllAdsCompleteEvent() + return true + } + + log.d("checkAllAdsArePlayed") + if (!hasPreRoll() && midRollAdsCount() <= 0 && !hasPostRoll()) { + isAllAdsCompleted = true + } + + adsConfigMap?.let map@ { adsMap -> + var unplayedAdCount = 0 + adsMap.forEach { (adBreakTime, adBreak) -> + adBreak?.let { + if (midrollAdBreakPositionType == AdBreakPositionType.EVERY && midrollFrequency > Long.MIN_VALUE) { + isAllAdsCompleted = false + return@map + } + + if ((adBreakTime >= 0L || adBreakTime == -1L) && + (it.adBreakState == AdState.READY || it.adBreakState == AdState.PLAYING) && + it.adBreakPositionType != AdBreakPositionType.EVERY) { + + unplayedAdCount++ + } + } + } + isAllAdsCompleted = (unplayedAdCount <= 0) + log.d("Unplayed AdCount is $unplayedAdCount") + } + + if (isAllAdsCompleted) { + fireAllAdsCompleteEvent() + } + + log.d("isAllAdsCompleted $isAllAdsCompleted") + return isAllAdsCompleted + } + + /** + * Firing AllAdsCompleted event + */ + private fun fireAllAdsCompleteEvent() { + if (isAllAdsCompletedFired) { + log.d("AllAdsCompleted event as already been fired.") + return + } + log.d("fireAllAdsCompleteEvent") + isAllAdsCompletedFired = true + messageBus?.post(AdEvent(AdEvent.Type.ALL_ADS_COMPLETED)) + playContent() + + resetAdvertisingConfig() + } + /** + * Trigger Postroll ad playback + */ + private fun playPostrollAdBreak() { + log.d("playPostrollAdBreak") + midrollAdBreakPositionType = AdBreakPositionType.POSITION + midrollFrequency = Long.MIN_VALUE + getAdFromAdConfigMap(POSTROLL_AD_INDEX)?.let { + playAd(it) + } + } + + /** + * Check if the Ads config is empty + */ + private fun isAdsListEmpty(): Boolean { + log.d("isAdsListEmpty") + if (adController == null || adsConfigMap == null) { + log.d("AdController or AdsConfigMap is null. hence discarding ad playback") + return true + } + + adsConfigMap?.let { + if (it.isEmpty()) { + return true + } + } + + cuePointsList?.let { + if (it.isEmpty()) { + return true + } + } + + return false + } + + /** + * Create AdInfo object for IMAPlugin + */ + private fun getAdInfo(): PKAdvertisingAdInfo? { + log.d("createAdInfoForAdvertisingConfig") + if (isPlayAdNowTriggered) { + log.d("PlayAdNow ad is there, hence no need of AdInfo object") + return null + } + + if (currentAdBreakIndexPosition == Int.MIN_VALUE) { + log.d("currentAdBreakIndexPosition is not valid") + return null + } + + var pkAdvertisingAdInfo: PKAdvertisingAdInfo? = null + + var adPodTimeOffset = 0L + var podIndex = 0 + var podCount = 0 + + adsConfigMap?.let { + cuePointsList?.let { cuePoints -> + adPodTimeOffset = cuePoints[currentAdBreakIndexPosition] + podIndex = currentAdBreakIndexPosition + 1 + podCount = cuePoints.size + } + + pkAdvertisingAdInfo = PKAdvertisingAdInfo(adPodTimeOffset, podIndex, podCount) + } + + return pkAdvertisingAdInfo + } + + /** + * Ad Playback + * Call the play Ad API on IMAPlugin + */ + private fun playAd(adTag: String) { + log.d("playAd AdUrl is $adTag") + adPlaybackTriggered = !TextUtils.isEmpty(adTag) + + // If Ad is empty, it means the content will be loaded using IMAPlugin + if (!TextUtils.isEmpty(adTag)) { + player?.let { + adController?.advertisingSetAdInfo(getAdInfo()) + if (it.isPlaying) { + it.pause() + } + } + } + adController?.advertisingPlayAdNow(adTag) + } + + /** + * Content Playback + */ + private fun playContent() { + log.d("playContent") + if (isLiveMedia()) { + resetTimerForLiveMedias() + } + + adPlaybackTriggered = false + player?.let { + adController?.advertisingPreparePlayer() + if (!it.isPlaying) { + it.play() + } + } + + } + + /** + * Prepare content player by passing empty ad tag + * Empty ad tag will trigger preparePlayer inside IMAPlugin + */ + private fun prepareContentPlayer() { + log.d("prepareContentPlayer") + playAd("") + } + + /** + * Checks if the media is Live + * @return isLive or not + */ + private fun isLiveMedia(): Boolean { + player?.let { + if (it.isLive) { + return true + } + getMediaEntry()?.let { pkMediaEntry -> + return pkMediaEntry.mediaType != PKMediaEntry.MediaEntryType.Vod + } + } + return false + } + + /** + * Get the PKMediaEntry from PKMediaConfig + */ + private fun getMediaEntry(): PKMediaEntry? { + return mediaConfig?.mediaEntry + } + + /** + * Check if preRoll ad is present + */ + private fun hasPreRoll(): Boolean { + if (cuePointsList?.first != null) { + return cuePointsList?.first == 0L + } + return false + } + + /** + * Check if postRoll ad is present + */ + private fun hasPostRoll(): Boolean { + if (cuePointsList?.last != null) { + return cuePointsList?.last == -1L + } + return false + } + + /** + * Get the number of midRolls, + * if no midRoll is present count will be zero. + */ + private fun midRollAdsCount(): Int { + var midrollAdsCount = 0 + cuePointsList?.let { + if (hasPreRoll() && hasPostRoll()) { + midrollAdsCount = it.size.minus(2) + } else if (hasPreRoll() || hasPostRoll()) { + midrollAdsCount = it.size.minus(1) + } else { + midrollAdsCount = it.size + } + } + log.v("MidRollAdsCount is $midrollAdsCount") + return midrollAdsCount + } + + /** + * Checks if it has only the pre roll ad + */ + private fun hasOnlyPreRoll(): Boolean { + if (hasPreRoll() && !hasPostRoll() && midRollAdsCount() <= 0) { + return true + } + return false + } + + /** + * Get the just previous ad position + * Used only if the user seeked the Ad cue point + * Mimicking the SNAPBACK feature + */ + private fun getImmediateLastAdPosition(position: Long?): Int { + log.d("getImmediateLastAdPosition") + + if (position == null || cuePointsList == null || cuePointsList.isNullOrEmpty()) { + log.d("Error in getImmediateLastAdPosition returning DEFAULT_AD_POSITION") + return DEFAULT_AD_INDEX + } + + var adPosition = -1 + + if (cuePointsList?.size == 1) { + adPosition = 0 + return adPosition + } + + cuePointsList?.let { + if (position > 0 && it.isNotEmpty() && it.size > 1) { + var lowerIndex: Int = if (it.first == 0L) 1 else 0 + var upperIndex: Int = if (it.last == -1L) it.size -2 else (it.size - 1) + + while (lowerIndex <= upperIndex) { + val midIndex = lowerIndex + (upperIndex - lowerIndex) / 2 + + if (it[midIndex] == position) { + adPosition = midIndex + break + } else if (it[midIndex] < position) { + adPosition = midIndex + lowerIndex = midIndex + 1 + } else if (it[midIndex] > position) { + upperIndex = midIndex - 1 + } + } + } else if (it.size == 1) { + adPosition = 0 + } + } + + log.d("Immediate Last Ad Position $adPosition") + return adPosition + } + + /** + * Get the just next ad position + * Used only if the user seeked the Ad cue point + * Mimicking the SNAPBACK feature + */ + private fun getImmediateNextAdPosition(position: Long?): Int { + log.d("getImmediateNextAdPosition") + + if (position == null || cuePointsList == null || cuePointsList.isNullOrEmpty()) { + log.d("Error in getImmediateNextAdPosition returning DEFAULT_AD_POSITION") + return DEFAULT_AD_INDEX + } + + var adPosition = -1 + + if (cuePointsList?.size == 1) { + adPosition = 0 + return adPosition + } + + cuePointsList?.let { + if (position > 0 && it.isNotEmpty() && it.size > 1) { + var lowerIndex: Int = if (it.first == 0L) 1 else 0 + var upperIndex: Int = if (it.last == -1L) it.size -2 else (it.size - 1) + + while (lowerIndex <= upperIndex) { + val midIndex = lowerIndex + (upperIndex - lowerIndex) / 2 + + if (it[midIndex] == position) { + adPosition = midIndex + break + } else if (it[midIndex] < position) { + lowerIndex = midIndex + 1 + } else if (it[midIndex] > position) { + adPosition = midIndex + upperIndex = midIndex - 1 + } + } + } + } + + log.d("Immediate Next Ad Position $adPosition") + return adPosition + } +} + diff --git a/playkit/src/main/java/com/kaltura/playkit/drm/DeferredDrmSessionManager.java b/playkit/src/main/java/com/kaltura/playkit/drm/DeferredDrmSessionManager.java index 704d41173..6ad2e70c2 100644 --- a/playkit/src/main/java/com/kaltura/playkit/drm/DeferredDrmSessionManager.java +++ b/playkit/src/main/java/com/kaltura/playkit/drm/DeferredDrmSessionManager.java @@ -16,20 +16,23 @@ import android.os.Handler; import android.os.Looper; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.kaltura.android.exoplayer2.Format; -import com.kaltura.android.exoplayer2.drm.DefaultDrmSessionManager; -import com.kaltura.android.exoplayer2.drm.DrmInitData; -import com.kaltura.android.exoplayer2.drm.DrmSession; -import com.kaltura.android.exoplayer2.drm.DrmSessionEventListener; -import com.kaltura.android.exoplayer2.drm.DrmSessionManager; -import com.kaltura.android.exoplayer2.drm.ExoMediaCrypto; -import com.kaltura.android.exoplayer2.drm.FrameworkMediaDrm; -import com.kaltura.android.exoplayer2.extractor.mp4.PsshAtomUtil; -import com.kaltura.android.exoplayer2.source.MediaSource; -import com.kaltura.android.exoplayer2.util.Util; +import com.kaltura.androidx.media3.common.C; +import com.kaltura.androidx.media3.common.Format; +import com.kaltura.androidx.media3.exoplayer.analytics.PlayerId; +import com.kaltura.androidx.media3.exoplayer.drm.DefaultDrmSessionManager; +import com.kaltura.androidx.media3.common.DrmInitData; +import com.kaltura.androidx.media3.exoplayer.drm.DrmSession; +import com.kaltura.androidx.media3.exoplayer.drm.DrmSessionEventListener; +import com.kaltura.androidx.media3.exoplayer.drm.DrmSessionManager; +import com.kaltura.androidx.media3.exoplayer.drm.FrameworkMediaDrm; +import com.kaltura.androidx.media3.exoplayer.drm.UnsupportedDrmException; +import com.kaltura.androidx.media3.extractor.mp4.PsshAtomUtil; +import com.kaltura.androidx.media3.exoplayer.source.MediaSource; +import com.kaltura.androidx.media3.common.util.Util; import com.kaltura.playkit.LocalAssetsManager; import com.kaltura.playkit.PKDrmParams; import com.kaltura.playkit.PKError; @@ -52,19 +55,25 @@ public class DeferredDrmSessionManager implements DrmSessionManager, DrmSessionE private static final PKLog log = PKLog.get("DeferredDrmSessionManager"); + private final String WIDEVINE_SECURITY_LEVEL_1 = "L1"; + private final String WIDEVINE_SECURITY_LEVEL_3 = "L3"; + private final String SECURITY_LEVEL_PROPERTY = "securityLevel"; + private Handler mainHandler; private final DrmCallback drmCallback; private DrmSessionListener drmSessionListener; private LocalAssetsManager.LocalMediaSource localMediaSource = null; private DrmSessionManager drmSessionManager; - private boolean allowClearLead; + private final boolean allowClearLead; + private final boolean forceWidevineL3Playback; - public DeferredDrmSessionManager(Handler mainHandler, DrmCallback drmCallback, DrmSessionListener drmSessionListener, boolean allowClearLead) { + public DeferredDrmSessionManager(Handler mainHandler, DrmCallback drmCallback, DrmSessionListener drmSessionListener, boolean allowClearLead, boolean forceWidevineL3Playback) { this.mainHandler = mainHandler; this.drmCallback = drmCallback; this.drmSessionListener = drmSessionListener; - drmSessionManager = DrmSessionManager.getDummyDrmSessionManager(); this.allowClearLead = allowClearLead; + this.forceWidevineL3Playback = forceWidevineL3Playback; + this.drmSessionManager = getDRMSessionManager(drmCallback); } public interface DrmSessionListener { @@ -81,12 +90,6 @@ public void setMediaSource(PKMediaSource mediaSource) { return; } - drmSessionManager = new DefaultDrmSessionManager.Builder() - .setUuidAndExoMediaDrmProvider(MediaSupport.WIDEVINE_UUID, FrameworkMediaDrm.DEFAULT_PROVIDER) - .setMultiSession(true) // key rotation - .setPlayClearSamplesWithoutKeys(allowClearLead) - .build(drmCallback); - if (mediaSource instanceof LocalAssetsManager.LocalMediaSource) { localMediaSource = (LocalAssetsManager.LocalMediaSource) mediaSource; } else { @@ -94,9 +97,29 @@ public void setMediaSource(PKMediaSource mediaSource) { } } + public void setLicenseUrl(String license) { + if (Util.SDK_INT < 18) { + drmSessionManager = null; + return; + } + + if (drmCallback != null) { + drmCallback.setLicenseUrl(license); + } else { + log.d("DrmCallback is null"); + } + } + + @Override + public void setPlayer(@NonNull Looper looper, @NonNull PlayerId playerId) { + if (drmSessionManager != null) { + drmSessionManager.setPlayer(looper, playerId); + } + } + @Nullable @Override - public DrmSession acquireSession(Looper playbackLooper, @Nullable EventDispatcher eventDispatcher, Format format) { + public DrmSession acquireSession(@Nullable EventDispatcher eventDispatcher, @NonNull Format format) { if (drmSessionManager == null) { return null; } @@ -119,17 +142,47 @@ public DrmSession acquireSession(Looper playbackLooper, @Nullable EventDispatche drmSessionListener.onError(error); } } + + return drmSessionManager.acquireSession(eventDispatcher, format); + } - return drmSessionManager.acquireSession(playbackLooper, eventDispatcher, format); + private DrmSessionManager getDRMSessionManager(DrmCallback drmCallback) { + log.d("getDRMSessionManager forceWidevineL3Playback = " + forceWidevineL3Playback); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { + DefaultDrmSessionManager.Builder drmSessionManagerBuilder = new DefaultDrmSessionManager.Builder(); + drmSessionManagerBuilder.setMultiSession(true) // key rotation + .setPlayClearSamplesWithoutKeys(allowClearLead); + + if (forceWidevineL3Playback) { + drmSessionManagerBuilder.setUuidAndExoMediaDrmProvider( + MediaSupport.WIDEVINE_UUID, + uuid -> { + try { + FrameworkMediaDrm frameworkMediaDrm = FrameworkMediaDrm.newInstance(MediaSupport.WIDEVINE_UUID); + frameworkMediaDrm.setPropertyString(SECURITY_LEVEL_PROPERTY, WIDEVINE_SECURITY_LEVEL_3); + return frameworkMediaDrm; + } catch (UnsupportedDrmException e) { + log.e("ForceWidevineL3Playback failed due to " + e.getMessage()); + return FrameworkMediaDrm.DEFAULT_PROVIDER.acquireExoMediaDrm(MediaSupport.WIDEVINE_UUID); + } + }); + } else { + drmSessionManagerBuilder.setUuidAndExoMediaDrmProvider(MediaSupport.WIDEVINE_UUID, FrameworkMediaDrm.DEFAULT_PROVIDER); + } + drmSessionManager = drmSessionManagerBuilder.build(drmCallback); + } else { + drmSessionManager = DrmSessionManager.DRM_UNSUPPORTED; + } + + return drmSessionManager; } - @Nullable @Override - public Class getExoMediaCryptoType(Format format) { + public int getCryptoType(@NonNull Format format) { if (drmSessionManager != null) { - return drmSessionManager.getExoMediaCryptoType(format); + return drmSessionManager.getCryptoType(format); } - return null; + return C.CRYPTO_TYPE_NONE; } @Override @@ -192,7 +245,7 @@ private String getLicenseUrl(PKMediaSource mediaSource) { } @Override - public void onDrmSessionAcquired(int windowIndex, @Nullable MediaSource.MediaPeriodId mediaPeriodId) { + public void onDrmSessionAcquired(int windowIndex, @Nullable MediaSource.MediaPeriodId mediaPeriodId, @DrmSession.State int state) { log.d("onDrmSessionAcquired"); } @@ -220,7 +273,7 @@ public void onDrmKeysRemoved(int windowIndex, @Nullable MediaSource.MediaPeriodI @Override public void onDrmSessionReleased(int windowIndex, @Nullable MediaSource.MediaPeriodId mediaPeriodId) { - log.d("onDrmSessionReleased"); + log.d("onDrmSessionReleased"); } } diff --git a/playkit/src/main/java/com/kaltura/playkit/drm/DrmAdapter.java b/playkit/src/main/java/com/kaltura/playkit/drm/DrmAdapter.java index b0bab4701..9a1eaa2c2 100644 --- a/playkit/src/main/java/com/kaltura/playkit/drm/DrmAdapter.java +++ b/playkit/src/main/java/com/kaltura/playkit/drm/DrmAdapter.java @@ -45,24 +45,26 @@ public static DrmAdapter getDrmAdapter(PKDrmParams.Scheme scheme, Context contex case WidevineClassic: return new WidevineClassicAdapter(context); case PlayReadyCENC: - log.d("Unsupported scheme PlayReady"); + log.d("PlayReadyCENC is supported using exoplayer default flow"); + //return new PlayreadyAdapter(context, localDataStore); + default: break; } return new NullDrmAdapter(); } - public abstract boolean registerAsset(final String localAssetPath, final String assetId, final String licenseUri, PKRequestParams.Adapter adapter, final LocalAssetsManager.AssetRegistrationListener listener) throws IOException; + public abstract boolean registerAsset(final String localAssetPath, final String assetId, final String licenseUri, PKRequestParams.Adapter adapter, boolean forceWidevineL3Playback, final LocalAssetsManager.AssetRegistrationListener listener) throws IOException; - public abstract boolean refreshAsset(final String localAssetPath, final String assetId, final String licenseUri, PKRequestParams.Adapter adapter, final LocalAssetsManager.AssetRegistrationListener listener); + public abstract boolean refreshAsset(final String localAssetPath, final String assetId, final String licenseUri, PKRequestParams.Adapter adapter, boolean forceWidevineL3Playback, final LocalAssetsManager.AssetRegistrationListener listener); - public abstract boolean unregisterAsset(final String localAssetPath, final String assetId, final LocalAssetsManager.AssetRemovalListener listener); + public abstract boolean unregisterAsset(final String localAssetPath, final String assetId, boolean forceWidevineL3Playback, final LocalAssetsManager.AssetRemovalListener listener); - public abstract boolean checkAssetStatus(final String localAssetPath, final String assetId, final LocalAssetsManager.AssetStatusListener listener); + public abstract boolean checkAssetStatus(final String localAssetPath, final String assetId, boolean forceWidevineL3Playback, final LocalAssetsManager.AssetStatusListener listener); private static class NullDrmAdapter extends DrmAdapter { @Override - public boolean checkAssetStatus(String localAssetPath, String assetId, LocalAssetsManager.AssetStatusListener listener) { + public boolean checkAssetStatus(String localAssetPath, String assetId, boolean forceWidevineL3Playback, LocalAssetsManager.AssetStatusListener listener) { if (listener != null) { listener.onStatus(localAssetPath, -1, -1, false); } @@ -70,7 +72,7 @@ public boolean checkAssetStatus(String localAssetPath, String assetId, LocalAsse } @Override - public boolean registerAsset(String localAssetPath, String assetId, String licenseUri, PKRequestParams.Adapter adapter, LocalAssetsManager.AssetRegistrationListener listener) { + public boolean registerAsset(String localAssetPath, String assetId, String licenseUri, PKRequestParams.Adapter adapter, boolean forceWidevineL3Playback, LocalAssetsManager.AssetRegistrationListener listener) { if (listener != null) { listener.onRegistered(localAssetPath); } @@ -78,12 +80,12 @@ public boolean registerAsset(String localAssetPath, String assetId, String licen } @Override - public boolean refreshAsset(String localAssetPath, String assetId, String licenseUri, PKRequestParams.Adapter adapter, LocalAssetsManager.AssetRegistrationListener listener) { - return registerAsset(localAssetPath, assetId, licenseUri, adapter, listener); + public boolean refreshAsset(String localAssetPath, String assetId, String licenseUri, PKRequestParams.Adapter adapter, boolean forceWidevineL3Playback, LocalAssetsManager.AssetRegistrationListener listener) { + return registerAsset(localAssetPath, assetId, licenseUri, adapter, forceWidevineL3Playback, listener); } @Override - public boolean unregisterAsset(String localAssetPath, String assetId, LocalAssetsManager.AssetRemovalListener listener) { + public boolean unregisterAsset(String localAssetPath, String assetId, boolean forceWidevineL3Playback, LocalAssetsManager.AssetRemovalListener listener) { if (listener != null) { listener.onRemoved(localAssetPath); } diff --git a/playkit/src/main/java/com/kaltura/playkit/drm/DrmCallback.java b/playkit/src/main/java/com/kaltura/playkit/drm/DrmCallback.java index dbf49c078..28b1c2024 100644 --- a/playkit/src/main/java/com/kaltura/playkit/drm/DrmCallback.java +++ b/playkit/src/main/java/com/kaltura/playkit/drm/DrmCallback.java @@ -1,56 +1,115 @@ package com.kaltura.playkit.drm; import android.net.Uri; +import android.text.TextUtils; -import com.kaltura.android.exoplayer2.drm.ExoMediaDrm; -import com.kaltura.android.exoplayer2.drm.HttpMediaDrmCallback; -import com.kaltura.android.exoplayer2.drm.MediaDrmCallback; -import com.kaltura.android.exoplayer2.drm.MediaDrmCallbackException; -import com.kaltura.android.exoplayer2.upstream.HttpDataSource; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.common.collect.ImmutableMap; +import com.kaltura.androidx.media3.common.C; +import com.kaltura.androidx.media3.exoplayer.drm.ExoMediaDrm; +import com.kaltura.androidx.media3.exoplayer.drm.HttpMediaDrmCallback; +import com.kaltura.androidx.media3.exoplayer.drm.MediaDrmCallback; +import com.kaltura.androidx.media3.exoplayer.drm.MediaDrmCallbackException; +import com.kaltura.androidx.media3.datasource.DataSource; +import com.kaltura.androidx.media3.datasource.DataSourceInputStream; +import com.kaltura.androidx.media3.datasource.DataSpec; +import com.kaltura.androidx.media3.datasource.HttpDataSource; +import com.kaltura.androidx.media3.datasource.StatsDataSource; +import com.kaltura.androidx.media3.common.util.Util; import com.kaltura.playkit.PKLog; import com.kaltura.playkit.PKRequestParams; +import com.kaltura.playkit.player.MediaSupport; + +import org.json.JSONObject; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.UUID; public class DrmCallback implements MediaDrmCallback { private static final PKLog log = PKLog.get("DrmCallback"); - private final HttpDataSource.Factory dataSourceFactory; private final PKRequestParams.Adapter adapter; private HttpMediaDrmCallback callback; + private String licenseUrl; + private final Map headers = new HashMap<>(); + private final Map postBodyMap = new HashMap<>(); - @Override - public byte[] executeProvisionRequest(UUID uuid, ExoMediaDrm.ProvisionRequest request) throws MediaDrmCallbackException { - return callback.executeProvisionRequest(uuid, request); + public DrmCallback(HttpDataSource.Factory dataSourceFactory, PKRequestParams.Adapter adapter) { + this.dataSourceFactory = dataSourceFactory; + this.adapter = adapter; } + @NonNull @Override - public byte[] executeKeyRequest(UUID uuid, ExoMediaDrm.KeyRequest request) throws MediaDrmCallbackException { - return callback.executeKeyRequest(uuid, request); + public byte[] executeProvisionRequest(@NonNull UUID uuid, @NonNull ExoMediaDrm.ProvisionRequest request) throws MediaDrmCallbackException { + return callback.executeProvisionRequest(uuid, request); } - public DrmCallback(HttpDataSource.Factory dataSourceFactory, PKRequestParams.Adapter adapter) { - this.dataSourceFactory = dataSourceFactory; - this.adapter = adapter; + @NonNull + @Override + public byte[] executeKeyRequest(@NonNull UUID uuid, @NonNull ExoMediaDrm.KeyRequest request) throws MediaDrmCallbackException { + if (adapter != null && !postBodyMap.isEmpty()) { + if (TextUtils.isEmpty(licenseUrl)) { + throw new MediaDrmCallbackException( + new DataSpec.Builder().setUri(Uri.EMPTY).build(), + Uri.EMPTY, + ImmutableMap.of(), + 0, + new IllegalStateException("No license URL")); + } + Map requestProperties = new HashMap<>(); + // Add standard request properties for supported schemes. + String contentType = + MediaSupport.PLAYREADY_UUID.equals(uuid) + ? "text/xml" + : (C.CLEARKEY_UUID.equals(uuid) ? "application/json" : "application/octet-stream"); + requestProperties.put("Content-Type", contentType); + if (MediaSupport.PLAYREADY_UUID.equals(uuid)) { + requestProperties.put( + "SOAPAction", "http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense"); + } + if (!headers.isEmpty()) { + requestProperties.putAll(headers); + } + JSONObject postBodyJsonObject = adapter.buildDrmPostParams(request.getData()); + return executePost(dataSourceFactory, licenseUrl, postBodyJsonObject, requestProperties); + } else { + return callback.executeKeyRequest(uuid, request); + } } - void setLicenseUrl(String licenseUrl) { - - if (licenseUrl == null) { + /** + * In case if the adapter is given then + * set the license URL after adapting it from outside. + * Otherwise use the license URL as it is for {@link HttpMediaDrmCallback} + * @param url license URL + */ + void setLicenseUrl(String url) { + if (url == null) { log.e("Invalid license URL = null"); return; } - PKRequestParams params = new PKRequestParams(Uri.parse(licenseUrl), new HashMap<>()); + PKRequestParams params = new PKRequestParams(Uri.parse(url), new HashMap<>()); if (adapter != null) { params = adapter.adapt(params); - if (params.url == null) { + if (params.url != null) { + this.licenseUrl = params.url.toString(); + } else { log.e("Adapter returned null license URL"); return; } + + headers.putAll(params.headers); + + if (params.postBody != null && !params.postBody.isEmpty()) { + postBodyMap.putAll(params.postBody); + } } callback = new HttpMediaDrmCallback(params.url.toString(), dataSourceFactory); @@ -59,4 +118,77 @@ void setLicenseUrl(String licenseUrl) { callback.setKeyRequestProperty(entry.getKey(), entry.getValue()); } } + + /** + * Do network call with customized post params and get the key byte data in response + * Taken from here {...} + * @throws MediaDrmCallbackException + */ + private static byte[] executePost( + DataSource.Factory dataSourceFactory, + String url, + @Nullable JSONObject httpBody, + Map requestProperties) + throws MediaDrmCallbackException { + StatsDataSource dataSource = new StatsDataSource(dataSourceFactory.createDataSource()); + int manualRedirectCount = 0; + DataSpec.Builder dataSpecBuilder = + new DataSpec.Builder() + .setUri(url) + .setHttpRequestHeaders(requestProperties) + .setHttpMethod(DataSpec.HTTP_METHOD_POST) + .setFlags(DataSpec.FLAG_ALLOW_GZIP); + + if (httpBody != null) { + dataSpecBuilder.setHttpBody(httpBody.toString().getBytes()); + } + + DataSpec dataSpec = dataSpecBuilder.build(); + DataSpec originalDataSpec = dataSpec; + try { + while (true) { + DataSourceInputStream inputStream = new DataSourceInputStream(dataSource, dataSpec); + try { + return Util.toByteArray(inputStream); + } catch (HttpDataSource.InvalidResponseCodeException e) { + @Nullable String redirectUrl = getRedirectUrl(e, manualRedirectCount); + if (redirectUrl == null) { + throw e; + } + manualRedirectCount++; + dataSpec = dataSpec.buildUpon().setUri(redirectUrl).build(); + } finally { + Util.closeQuietly(inputStream); + } + } + } catch (Exception e) { + throw new MediaDrmCallbackException( + originalDataSpec, + dataSource.getLastOpenedUri(), + dataSource.getResponseHeaders(), + dataSource.getBytesRead(), + /* cause= */ e); + } + } + + @Nullable + private static String getRedirectUrl( + HttpDataSource.InvalidResponseCodeException exception, int manualRedirectCount) { + // For POST requests, the underlying network stack will not normally follow 307 or 308 + // redirects automatically. Do so manually here. + boolean manuallyRedirect = + (exception.responseCode == 307 || exception.responseCode == 308) + && manualRedirectCount < 10; + if (!manuallyRedirect) { + return null; + } + Map> headerFields = exception.headerFields; + if (headerFields != null) { + @Nullable List locationHeaders = headerFields.get("Location"); + if (locationHeaders != null && !locationHeaders.isEmpty()) { + return locationHeaders.get(0); + } + } + return null; + } } diff --git a/playkit/src/main/java/com/kaltura/playkit/drm/MediaDrmSession.java b/playkit/src/main/java/com/kaltura/playkit/drm/MediaDrmSession.java index d87f4c609..984f1b3c2 100644 --- a/playkit/src/main/java/com/kaltura/playkit/drm/MediaDrmSession.java +++ b/playkit/src/main/java/com/kaltura/playkit/drm/MediaDrmSession.java @@ -19,8 +19,8 @@ import android.os.Build; import androidx.annotation.NonNull; -import com.kaltura.android.exoplayer2.drm.DrmInitData; -import com.kaltura.android.exoplayer2.drm.FrameworkMediaDrm; +import com.kaltura.androidx.media3.common.DrmInitData; +import com.kaltura.androidx.media3.exoplayer.drm.FrameworkMediaDrm; import com.kaltura.playkit.player.MediaSupport; import java.util.Collections; diff --git a/playkit/src/main/java/com/kaltura/playkit/drm/SimpleDashParser.java b/playkit/src/main/java/com/kaltura/playkit/drm/SimpleDashParser.java index d51c3caf1..39ca85135 100644 --- a/playkit/src/main/java/com/kaltura/playkit/drm/SimpleDashParser.java +++ b/playkit/src/main/java/com/kaltura/playkit/drm/SimpleDashParser.java @@ -16,20 +16,20 @@ import android.os.Build; import androidx.annotation.Nullable; -import com.kaltura.android.exoplayer2.C; -import com.kaltura.android.exoplayer2.Format; -import com.kaltura.android.exoplayer2.drm.DrmInitData; -import com.kaltura.android.exoplayer2.extractor.mp4.FragmentedMp4Extractor; -import com.kaltura.android.exoplayer2.extractor.mp4.PsshAtomUtil; -import com.kaltura.android.exoplayer2.source.chunk.BundledChunkExtractor; -import com.kaltura.android.exoplayer2.source.chunk.InitializationChunk; -import com.kaltura.android.exoplayer2.source.dash.manifest.AdaptationSet; -import com.kaltura.android.exoplayer2.source.dash.manifest.DashManifest; -import com.kaltura.android.exoplayer2.source.dash.manifest.DashManifestParser; -import com.kaltura.android.exoplayer2.source.dash.manifest.Period; -import com.kaltura.android.exoplayer2.source.dash.manifest.Representation; -import com.kaltura.android.exoplayer2.upstream.DataSpec; -import com.kaltura.android.exoplayer2.upstream.FileDataSource; +import com.kaltura.androidx.media3.common.C; +import com.kaltura.androidx.media3.common.Format; +import com.kaltura.androidx.media3.common.DrmInitData; +import com.kaltura.androidx.media3.extractor.mp4.FragmentedMp4Extractor; +import com.kaltura.androidx.media3.extractor.mp4.PsshAtomUtil; +import com.kaltura.androidx.media3.exoplayer.source.chunk.BundledChunkExtractor; +import com.kaltura.androidx.media3.exoplayer.source.chunk.InitializationChunk; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.AdaptationSet; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.DashManifest; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.DashManifestParser; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.Period; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.Representation; +import com.kaltura.androidx.media3.datasource.DataSpec; +import com.kaltura.androidx.media3.datasource.FileDataSource; import com.kaltura.playkit.PKLog; import com.kaltura.playkit.player.MediaSupport; import com.kaltura.playkit.utils.Consts; @@ -75,7 +75,7 @@ public SimpleDashParser parse(String localPath) throws IOException { List representations = videoAdaptation.representations; - if (representations == null || representations.isEmpty()) { + if (representations.isEmpty()) { throw new IOException("At least one video representation is required"); } @@ -83,9 +83,7 @@ public SimpleDashParser parse(String localPath) throws IOException { if (representation != null) { format = representation.format; - if (format != null) { - drmInitData = format.drmInitData; - } + drmInitData = format.drmInitData; if (drmInitData != null && drmInitData.schemeDataCount > 0) { hasContentProtection = true; @@ -102,8 +100,12 @@ public byte[] getWidevineInitData() { } private void loadDrmInitData(Representation representation) throws IOException { + + if (representation.baseUrls.isEmpty() || representation.getInitializationUri() == null) { + return; + } - Uri initFile = representation.getInitializationUri().resolveUri(representation.baseUrl); + Uri initFile = representation.getInitializationUri().resolveUri(representation.baseUrls.get(0).url); FileDataSource initChunkSource = new FileDataSource(); DataSpec initDataSpec = new DataSpec(initFile); diff --git a/playkit/src/main/java/com/kaltura/playkit/drm/SimpleHlsParser.kt b/playkit/src/main/java/com/kaltura/playkit/drm/SimpleHlsParser.kt new file mode 100644 index 000000000..f63a06d6c --- /dev/null +++ b/playkit/src/main/java/com/kaltura/playkit/drm/SimpleHlsParser.kt @@ -0,0 +1,99 @@ +package com.kaltura.playkit.drm + +import android.net.Uri +import android.os.Build +import com.kaltura.androidx.media3.common.Format +import com.kaltura.androidx.media3.common.DrmInitData +import com.kaltura.androidx.media3.extractor.mp4.PsshAtomUtil +import com.kaltura.androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist +import com.kaltura.androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist +import com.kaltura.androidx.media3.exoplayer.hls.playlist.HlsPlaylistParser +import com.kaltura.playkit.PKLog +import com.kaltura.playkit.player.MediaSupport +import java.io.BufferedInputStream +import java.io.FileInputStream +import java.io.IOException + +class SimpleHlsParser { + + private val log = PKLog.get("SimpleHlsParser") + + @JvmField + var format: Format? = null + @JvmField + var hlsWidevineInitData: ByteArray? = null + @JvmField + var hasContentProtection: Boolean = false + @JvmField + var drmInitData: DrmInitData? = null + + @Throws(IOException::class) + fun parse(localPath: String): SimpleHlsParser { + val segmentUrl: String? + val inputStreamLocalPath = BufferedInputStream(FileInputStream(localPath)) + val masterPlaylist: HlsMultivariantPlaylist = HlsPlaylistParser().parse(Uri.parse(localPath), inputStreamLocalPath) as HlsMultivariantPlaylist + + val variant = masterPlaylist.variants + if (variant.isNotEmpty()) { + format = variant[0].format + segmentUrl = variant[0].url.toString() + } else { + throw IOException("At least one video representation is required") + } + + val isMediaPlaylist = BufferedInputStream(FileInputStream(segmentUrl)) + val mediaPlaylist: HlsMediaPlaylist = HlsPlaylistParser().parse(Uri.parse(localPath), isMediaPlaylist) as HlsMediaPlaylist + if (mediaPlaylist.segments.isNotEmpty()) { + mediaPlaylist.segments[0].drmInitData?.let { drmData -> + hasContentProtection = true + drmInitData = drmData + } + } + + if (drmInitData == null) { + log.i("no content protection found") + return this + } + + drmInitData?.let { + val schemeInitData = getWidevineSchemeData(drmInitData) + schemeInitData?.let { + hlsWidevineInitData = it.data + } + } + + return this + } + + fun getWidevineInitData(): ByteArray? = hlsWidevineInitData + + private fun getWidevineSchemeData(drmInitData: DrmInitData?): DrmInitData.SchemeData? { + val widevineUUID = MediaSupport.WIDEVINE_UUID + if (drmInitData == null) { + log.e("No PSSH in media") + return null + } + + var schemeData: DrmInitData.SchemeData? = null + for (i in 0 until drmInitData.schemeDataCount) { + if (drmInitData[i].matches(widevineUUID)) { + schemeData = drmInitData[i] + } + } + + if (schemeData == null) { + log.e("No Widevine PSSH in media") + return null + } + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + // Prior to L the Widevine CDM required data to be extracted from the PSSH atom. + val psshData = PsshAtomUtil.parseSchemeSpecificData(schemeData.data!!, widevineUUID) + if (psshData != null) { + schemeData = DrmInitData.SchemeData(widevineUUID, schemeData.mimeType, psshData) + } + } + return schemeData + } +} + diff --git a/playkit/src/main/java/com/kaltura/playkit/drm/WidevineClassicAdapter.java b/playkit/src/main/java/com/kaltura/playkit/drm/WidevineClassicAdapter.java index 34da6c37e..59bf205e7 100644 --- a/playkit/src/main/java/com/kaltura/playkit/drm/WidevineClassicAdapter.java +++ b/playkit/src/main/java/com/kaltura/playkit/drm/WidevineClassicAdapter.java @@ -37,7 +37,7 @@ class WidevineClassicAdapter extends DrmAdapter { } @Override - public boolean checkAssetStatus(String localAssetPath, String assetId, final LocalAssetsManager.AssetStatusListener listener) { + public boolean checkAssetStatus(String localAssetPath, String assetId, boolean forceWidevineL3Playback, final LocalAssetsManager.AssetStatusListener listener) { WidevineClassicDrm widevineClassicDrm = new WidevineClassicDrm(context); WidevineClassicDrm.RightsInfo info = widevineClassicDrm.getRightsInfo(localAssetPath); if (listener != null) { @@ -47,7 +47,7 @@ public boolean checkAssetStatus(String localAssetPath, String assetId, final Loc } @Override - public boolean registerAsset(final String localAssetPath, String assetId, String licenseUri, PKRequestParams.Adapter adapter, final LocalAssetsManager.AssetRegistrationListener listener) { + public boolean registerAsset(final String localAssetPath, String assetId, String licenseUri, PKRequestParams.Adapter adapter, boolean forceWidevineL3Playback, final LocalAssetsManager.AssetRegistrationListener listener) { WidevineClassicDrm widevineClassicDrm = new WidevineClassicDrm(context); widevineClassicDrm.setEventListener(new WidevineClassicDrm.EventListener() { @Override @@ -62,12 +62,10 @@ public void onError(DrmErrorEvent event) { @Override public void onEvent(DrmEvent event) { log.d(event.toString()); - switch (event.getType()) { - case DrmInfoEvent.TYPE_RIGHTS_INSTALLED: - if (listener != null) { - listener.onRegistered(localAssetPath); - } - break; + if (event.getType() == DrmInfoEvent.TYPE_RIGHTS_INSTALLED) { + if (listener != null) { + listener.onRegistered(localAssetPath); + } } } }); @@ -77,12 +75,12 @@ public void onEvent(DrmEvent event) { } @Override - public boolean refreshAsset(String localAssetPath, String assetId, String licenseUri, PKRequestParams.Adapter adapter,LocalAssetsManager.AssetRegistrationListener listener) { - return registerAsset(localAssetPath, assetId, licenseUri, null, listener); + public boolean refreshAsset(String localAssetPath, String assetId, String licenseUri, PKRequestParams.Adapter adapter, boolean forceWidevineL3Playback, LocalAssetsManager.AssetRegistrationListener listener) { + return registerAsset(localAssetPath, assetId, licenseUri, null, forceWidevineL3Playback, listener); } @Override - public boolean unregisterAsset(final String localAssetPath, String assetId, final LocalAssetsManager.AssetRemovalListener listener) { + public boolean unregisterAsset(final String localAssetPath, String assetId, boolean forceWidevineL3Playback, final LocalAssetsManager.AssetRemovalListener listener) { WidevineClassicDrm widevineClassicDrm = new WidevineClassicDrm(context); widevineClassicDrm.setEventListener(new WidevineClassicDrm.EventListener() { @Override @@ -93,12 +91,10 @@ public void onError(DrmErrorEvent event) { @Override public void onEvent(DrmEvent event) { log.d(event.toString()); - switch (event.getType()) { - case DrmInfoEvent.TYPE_RIGHTS_REMOVED: - if (listener != null) { - listener.onRemoved(localAssetPath); - } - break; + if (event.getType() == DrmInfoEvent.TYPE_RIGHTS_REMOVED) { + if (listener != null) { + listener.onRemoved(localAssetPath); + } } } }); diff --git a/playkit/src/main/java/com/kaltura/playkit/drm/WidevineClassicDrm.java b/playkit/src/main/java/com/kaltura/playkit/drm/WidevineClassicDrm.java index fef29601e..39d49af8f 100644 --- a/playkit/src/main/java/com/kaltura/playkit/drm/WidevineClassicDrm.java +++ b/playkit/src/main/java/com/kaltura/playkit/drm/WidevineClassicDrm.java @@ -309,7 +309,7 @@ public void registerPortal() { * returns whether or not we should acquire rights for this url * * @param assetUri - * @return + * @return boolean */ public boolean needToAcquireRights(String assetUri) { mDrmManager.acquireDrmInfo(createDrmInfoRequest(assetUri)); diff --git a/playkit/src/main/java/com/kaltura/playkit/drm/WidevineModularAdapter.java b/playkit/src/main/java/com/kaltura/playkit/drm/WidevineModularAdapter.java index 2a9d425b5..27d15544c 100644 --- a/playkit/src/main/java/com/kaltura/playkit/drm/WidevineModularAdapter.java +++ b/playkit/src/main/java/com/kaltura/playkit/drm/WidevineModularAdapter.java @@ -23,13 +23,13 @@ import android.os.Build; import androidx.annotation.NonNull; -import com.kaltura.android.exoplayer2.ExoPlayerLibraryInfo; -import com.kaltura.android.exoplayer2.drm.ExoMediaDrm; -import com.kaltura.android.exoplayer2.drm.FrameworkMediaDrm; -import com.kaltura.android.exoplayer2.drm.HttpMediaDrmCallback; -import com.kaltura.android.exoplayer2.drm.UnsupportedDrmException; -import com.kaltura.android.exoplayer2.upstream.DefaultHttpDataSourceFactory; -import com.kaltura.android.exoplayer2.upstream.HttpDataSource; +import com.kaltura.androidx.media3.common.MediaLibraryInfo; +import com.kaltura.androidx.media3.exoplayer.drm.ExoMediaDrm; +import com.kaltura.androidx.media3.exoplayer.drm.FrameworkMediaDrm; +import com.kaltura.androidx.media3.exoplayer.drm.HttpMediaDrmCallback; +import com.kaltura.androidx.media3.exoplayer.drm.UnsupportedDrmException; +import com.kaltura.androidx.media3.datasource.DefaultHttpDataSource; +import com.kaltura.androidx.media3.datasource.HttpDataSource; import com.kaltura.playkit.*; import com.kaltura.playkit.player.MediaSupport; @@ -49,20 +49,21 @@ public class WidevineModularAdapter extends DrmAdapter { private static final PKLog log = PKLog.get("WidevineModularAdapter"); + private String WIDEVINE_SECURITY_LEVEL_1 = "L1"; + private String WIDEVINE_SECURITY_LEVEL_3 = "L3"; + private String SECURITY_LEVEL_PROPERTY = "securityLevel"; private Context context; private final LocalDataStore localDataStore; - public WidevineModularAdapter(Context context, LocalDataStore localDataStore) { this.context = context; this.localDataStore = localDataStore; } @Override - public boolean registerAsset(String localAssetPath, String assetId, String licenseUri, PKRequestParams.Adapter adapter, LocalAssetsManager.AssetRegistrationListener listener) { - + public boolean registerAsset(String localAssetPath, String assetId, String licenseUri, PKRequestParams.Adapter adapter, boolean forceWidevineL3Playback, LocalAssetsManager.AssetRegistrationListener listener) { try { - boolean result = registerAsset(localAssetPath, assetId, licenseUri, adapter); + boolean result = registerAsset(localAssetPath, assetId, licenseUri, forceWidevineL3Playback, adapter); if (listener != null) { listener.onRegistered(localAssetPath); } @@ -75,32 +76,34 @@ public boolean registerAsset(String localAssetPath, String assetId, String licen } } - private boolean registerAsset(String localAssetPath, String assetId, String licenseUri, PKRequestParams.Adapter requestParamsAdapter) throws LocalAssetsManager.RegisterException { - - // obtain the dash manifest. - SimpleDashParser dash = parseDash(localAssetPath, assetId); + private boolean registerAsset(String localAssetPath, String assetId, String licenseUri, boolean forceWidevineL3Playback, PKRequestParams.Adapter requestParamsAdapter) throws LocalAssetsManager.RegisterException { + AssetParsingStatus assetParsingStatus = parseWidevineAsset(localAssetPath, assetId); + if (assetParsingStatus == null) { + throw new LocalAssetsManager.RegisterException("Unable to parse the widevine data", null); + } - if (!dash.hasContentProtection) { + if (!assetParsingStatus.hasContentProtection) { // Not protected -- nothing to do. return true; } - String mimeType = dash.format.containerMimeType; - byte[] initData = dash.widevineInitData; + String mimeType = assetParsingStatus.mimeType; + byte[] initData = assetParsingStatus.initData; - registerAsset(initData, mimeType, licenseUri, requestParamsAdapter); + registerAsset(initData, mimeType, licenseUri, forceWidevineL3Playback, requestParamsAdapter); return true; } - public void registerAsset(byte[] initData, String mimeType, String licenseUri, PKRequestParams.Adapter requestParamsAdapter) throws LocalAssetsManager.RegisterException { - final byte[] offlineKeyId = downloadOfflineLicense(licenseUri, requestParamsAdapter, mimeType, initData); + public void registerAsset(byte[] initData, String mimeType, String licenseUri, boolean forceWidevineL3Playback, PKRequestParams.Adapter requestParamsAdapter) throws LocalAssetsManager.RegisterException { + final byte[] offlineKeyId = downloadOfflineLicense(licenseUri, requestParamsAdapter, mimeType, initData, forceWidevineL3Playback); localDataStore.save(toBase64(initData), offlineKeyId); } - private byte[] downloadOfflineLicense(String licenseUri, PKRequestParams.Adapter requestParamsAdapter, String mimeType, byte[] initData) throws LocalAssetsManager.RegisterException { + private byte[] downloadOfflineLicense(String licenseUri, PKRequestParams.Adapter requestParamsAdapter, String mimeType, byte[] initData, boolean forceWidevineL3Playback) throws LocalAssetsManager.RegisterException { MediaDrmSession session; - FrameworkMediaDrm mediaDrm = createMediaDrm(); + FrameworkMediaDrm mediaDrm = createMediaDrm(forceWidevineL3Playback); + try { session = MediaDrmSession.open(mediaDrm); } catch (Exception e) { @@ -142,10 +145,10 @@ private byte[] downloadOfflineLicense(String licenseUri, PKRequestParams.Adapter } @Override - public boolean unregisterAsset(String localAssetPath, String assetId, LocalAssetsManager.AssetRemovalListener listener) { + public boolean unregisterAsset(String localAssetPath, String assetId, boolean forceWidevineL3Playback, LocalAssetsManager.AssetRemovalListener listener) { try { - unregisterAsset(localAssetPath, assetId); + unregisterAsset(localAssetPath, assetId, forceWidevineL3Playback); return true; } catch (LocalAssetsManager.RegisterException e) { log.e("Failed to unregister", e); @@ -157,16 +160,24 @@ public boolean unregisterAsset(String localAssetPath, String assetId, LocalAsset } } - private boolean unregisterAsset(String localAssetPath, String assetId) throws LocalAssetsManager.RegisterException { + private boolean unregisterAsset(String localAssetPath, String assetId, boolean forceWidevineL3Playback) throws LocalAssetsManager.RegisterException { + AssetParsingStatus assetParsingStatus = parseWidevineAsset(localAssetPath, assetId); + if (assetParsingStatus == null) { + throw new LocalAssetsManager.RegisterException("Unable to parse the widevine data", null); + } - SimpleDashParser dash = parseDash(localAssetPath, assetId); - if (!dash.hasContentProtection) { + if (!assetParsingStatus.hasContentProtection) { // Not protected -- nothing to do. return true; } + byte[] widevineInitData = assetParsingStatus.initData; + if (widevineInitData == null) { + throw new LocalAssetsManager.RegisterException("Unregister Error: Could not find drm init data", null); + } + // obtain key with which we will load the saved keySetId. - String key = toBase64(dash.widevineInitData); + String key = toBase64(widevineInitData); byte[] keySetId; try { @@ -175,7 +186,7 @@ private boolean unregisterAsset(String localAssetPath, String assetId) throws Lo throw new LocalAssetsManager.RegisterException("Can't unregister -- keySetId not found", e); } - FrameworkMediaDrm mediaDrm = createMediaDrm(); + FrameworkMediaDrm mediaDrm = createMediaDrm(forceWidevineL3Playback); FrameworkMediaDrm.KeyRequest releaseRequest; try { releaseRequest = mediaDrm.getKeyRequest(keySetId, null, MediaDrm.KEY_TYPE_RELEASE, null); @@ -191,16 +202,16 @@ private boolean unregisterAsset(String localAssetPath, String assetId) throws Lo } @Override - public boolean refreshAsset(String localAssetPath, String assetId, String licenseUri, PKRequestParams.Adapter adapter, LocalAssetsManager.AssetRegistrationListener listener) { + public boolean refreshAsset(String localAssetPath, String assetId, String licenseUri, PKRequestParams.Adapter adapter, boolean forceWidevineL3Playback, LocalAssetsManager.AssetRegistrationListener listener) { // TODO -- verify that we just need to register again - return registerAsset(localAssetPath, assetId, licenseUri, adapter, listener); + return registerAsset(localAssetPath, assetId, licenseUri, adapter, forceWidevineL3Playback, listener); } @Override - public boolean checkAssetStatus(String localAssetPath, String assetId, LocalAssetsManager.AssetStatusListener listener) { + public boolean checkAssetStatus(String localAssetPath, String assetId, boolean forceWidevineL3Playback, LocalAssetsManager.AssetStatusListener listener) { try { - Map assetStatus = checkAssetStatus(localAssetPath, assetId); + Map assetStatus = checkAssetStatus(localAssetPath, assetId, forceWidevineL3Playback); if (assetStatus != null) { long licenseDurationRemaining = 0; long playbackDurationRemaining = 0; @@ -241,23 +252,27 @@ public boolean checkAssetStatus(String localAssetPath, String assetId, LocalAsse return true; } - private Map checkAssetStatus(String localAssetPath, String assetId) throws LocalAssetsManager.RegisterException { - SimpleDashParser dash = parseDash(localAssetPath, assetId); + private Map checkAssetStatus(String localAssetPath, String assetId, boolean forceWidevineL3Playback) throws LocalAssetsManager.RegisterException { + AssetParsingStatus assetParsingStatus = parseWidevineAsset(localAssetPath, assetId); + if (assetParsingStatus == null) { + throw new LocalAssetsManager.RegisterException("Unable to parse the widevine data", null); + } //no content protection, so there could not be any status info, so return null. - if (!dash.hasContentProtection) { + if (!assetParsingStatus.hasContentProtection) { return null; } - if (dash.widevineInitData == null) { + byte[] widevineInitData = assetParsingStatus.initData; + if (widevineInitData == null) { throw new NoWidevinePSSHException("No Widevine PSSH in media", null); } - return checkAssetStatus(dash.widevineInitData); + return checkAssetStatus(widevineInitData, forceWidevineL3Playback); } - public Map checkAssetStatus(byte[] widevineInitData) throws LocalAssetsManager.RegisterException { - FrameworkMediaDrm mediaDrm = createMediaDrm(); + public Map checkAssetStatus(byte[] widevineInitData, boolean forceWidevineL3Playback) throws LocalAssetsManager.RegisterException { + FrameworkMediaDrm mediaDrm = createMediaDrm(forceWidevineL3Playback); MediaDrmSession session; try { @@ -278,10 +293,13 @@ public Map checkAssetStatus(byte[] widevineInitData) throws Loca } @NonNull - private FrameworkMediaDrm createMediaDrm() throws LocalAssetsManager.RegisterException { + private FrameworkMediaDrm createMediaDrm(boolean forceWidevineL3Playback) throws LocalAssetsManager.RegisterException { FrameworkMediaDrm mediaDrm = null; try { mediaDrm = FrameworkMediaDrm.newInstance(MediaSupport.WIDEVINE_UUID); + if (forceWidevineL3Playback) { + mediaDrm.setPropertyString(SECURITY_LEVEL_PROPERTY, WIDEVINE_SECURITY_LEVEL_3); + } } catch (UnsupportedDrmException e) { e.printStackTrace(); } @@ -318,6 +336,50 @@ private SimpleDashParser parseDash(String localPath, String assetId) throws Loca return dashParser; } + /** + * Parse the hls manifest for the specified file. + * + * @param localPath - file from which to parse the dash manifest. + * @param assetId - the asset id. + * @return - {@link SimpleHlsParser} which contains the manifest data we need. + * @throws LocalAssetsManager.RegisterException - {@link LocalAssetsManager.RegisterException} + */ + private SimpleHlsParser parseHls(String localPath, String assetId) throws LocalAssetsManager.RegisterException { + SimpleHlsParser hlsParser; + try { + hlsParser = new SimpleHlsParser().parse(localPath); + if (hlsParser.format == null) { + throw new LocalAssetsManager.RegisterException("Unknown format", null); + } + if (hlsParser.hasContentProtection && hlsParser.hlsWidevineInitData == null) { + throw new NoWidevinePSSHException("No Widevine PSSH in media", null); + } + } catch (IOException e) { + throw new LocalAssetsManager.RegisterException("Can't parse local hls", e); + } + + return hlsParser; + } + + private AssetParsingStatus parseWidevineAsset(String localAssetPath, String assetId) throws LocalAssetsManager.RegisterException { + AssetParsingStatus assetParsingStatus = null; + if (isAssetFormatMatching(localAssetPath, PKMediaFormat.dash.pathExt)) { + SimpleDashParser dash = parseDash(localAssetPath, assetId); + assetParsingStatus = new AssetParsingStatus(dash.format.containerMimeType, dash.widevineInitData, dash.hasContentProtection); + } else if (isAssetFormatMatching(localAssetPath, PKMediaFormat.hls.pathExt)) { + SimpleHlsParser hls = parseHls(localAssetPath, assetId); + assetParsingStatus = new AssetParsingStatus(hls.format != null ? hls.format.containerMimeType : null, + hls.hlsWidevineInitData, + hls.hasContentProtection); + } + + return assetParsingStatus; + } + + private boolean isAssetFormatMatching(String localFilePath, String assetFormat) { + return localFilePath.endsWith(assetFormat); + } + private MediaDrmSession openSessionWithKeys(FrameworkMediaDrm mediaDrm, String key) throws MediaDrmException, MediaCryptoException, FileNotFoundException { byte[] keySetId = localDataStore.load(key); @@ -329,9 +391,6 @@ private MediaDrmSession openSessionWithKeys(FrameworkMediaDrm mediaDrm, String k } private byte[] executeKeyRequest(String licenseUrl, ExoMediaDrm.KeyRequest keyRequest, PKRequestParams.Adapter adapter) throws Exception { - - - HttpMediaDrmCallback httpMediaDrmCallback = new HttpMediaDrmCallback(licenseUrl, buildDataSourceFactory()); if (adapter != null) { PKRequestParams params = new PKRequestParams(Uri.parse(licenseUrl), new HashMap<>()); @@ -348,7 +407,7 @@ private byte[] executeKeyRequest(String licenseUrl, ExoMediaDrm.KeyRequest keyRe } private HttpDataSource.Factory buildDataSourceFactory() { - return new DefaultHttpDataSourceFactory(getUserAgent(context), null); + return new DefaultHttpDataSource.Factory().setUserAgent((getUserAgent(context))); } private static String getUserAgent(Context context) { @@ -364,12 +423,24 @@ private static String getUserAgent(Context context) { String sdkName = "PlayKit/" + BuildConfig.VERSION_NAME; return sdkName + " " + applicationName + " (Linux;Android " + Build.VERSION.RELEASE - + ") " + "ExoPlayerLib/" + ExoPlayerLibraryInfo.VERSION; + + ") " + MediaLibraryInfo.VERSION_SLASHY; } - private class NoWidevinePSSHException extends LocalAssetsManager.RegisterException { + private static class NoWidevinePSSHException extends LocalAssetsManager.RegisterException { NoWidevinePSSHException(String detailMessage, Throwable throwable) { super(detailMessage, throwable); } } + + private static class AssetParsingStatus { + private final String mimeType; + private final byte[] initData; + private final boolean hasContentProtection; + + public AssetParsingStatus(String mimeType, byte[] initData, boolean hasContentProtection) { + this.mimeType = mimeType; + this.initData = initData; + this.hasContentProtection = hasContentProtection; + } + } } diff --git a/playkit/src/main/java/com/kaltura/playkit/player/ABRSettings.java b/playkit/src/main/java/com/kaltura/playkit/player/ABRSettings.java index aac66457e..0bdaf7470 100644 --- a/playkit/src/main/java/com/kaltura/playkit/player/ABRSettings.java +++ b/playkit/src/main/java/com/kaltura/playkit/player/ABRSettings.java @@ -2,41 +2,155 @@ public class ABRSettings { - /** - * Set minVideoBitrate in ABR - * - * @param minVideoBitrate - minimum video bitrate in ABR - * @return - Player Settings. - */ + public enum InitialBitrateEstimatePolicy { + INITIAL_SETUP_ONLY, + RESET_ON_MEDIA_CHANGE + } + + private Long initialBitrateEstimate = null; + private InitialBitrateEstimatePolicy initialBitrateEstimatePolicy = InitialBitrateEstimatePolicy.INITIAL_SETUP_ONLY; + private Long maxVideoBitrate = Long.MAX_VALUE; private Long minVideoBitrate = Long.MIN_VALUE; + private Long maxVideoHeight = Long.MAX_VALUE; + private Long minVideoHeight = Long.MIN_VALUE; + private Long maxVideoWidth = Long.MAX_VALUE; + private Long minVideoWidth = Long.MIN_VALUE; + /** - * Set maxVideoBitrate in ABR - * - * @param maxVideoBitrate - maximum video bitrate in ABR - * @return - Player Settings. + * Reset the ABR Settings. */ - private Long maxVideoBitrate = Long.MAX_VALUE; + public final static ABRSettings RESET = new ABRSettings() + .setMinVideoBitrate(Long.MIN_VALUE) + .setMaxVideoBitrate(Long.MAX_VALUE) + .setMinVideoHeight(Long.MIN_VALUE) + .setMaxVideoHeight(Long.MAX_VALUE) + .setMinVideoWidth(Long.MIN_VALUE) + .setMaxVideoWidth(Long.MAX_VALUE); + /** * Sets the initial bitrate estimate in bits per second that should be assumed when a bandwidth * estimate is unavailable. * + * To reset it, set it to null. + * + *
+ *
+ * If App is using {@link com.kaltura.playkit.Player#updateABRSettings(ABRSettings)} + *
+ * Then Using {@link ABRSettings#setInitialBitrateEstimate(Long)} is unaffected because + * initial bitrate is only meant at the start of the playback + *
+ * * @param initialBitrateEstimate The initial bitrate estimate in bits per second. - * @return - Player Settings. + * @return ABRSettings */ - private Long initialBitrateEstimate; + public ABRSettings setInitialBitrateEstimate(Long initialBitrateEstimate) { + this.initialBitrateEstimate = initialBitrateEstimate; + return this; + } + /** + * Sets the initial bitrate estimate policy. + * + *
+ *
+ * Using {@link ABRSettings.InitialBitrateEstimatePolicy#INITIAL_SETUP_ONLY} + * will set the provided initial bitrate only on start on playback + *
+ * Using {@link ABRSettings.InitialBitrateEstimatePolicy#RESET_ON_MEDIA_CHANGE} + * will set the provided initial bitrate on each media change + *
+ * Default is {@link ABRSettings.InitialBitrateEstimatePolicy#INITIAL_SETUP_ONLY} + * + * @param initialBitrateEstimatePolicy The initial bitrate estimate policy. + * @return ABRSettings + */ + public ABRSettings setInitialBitrateEstimatePolicy(InitialBitrateEstimatePolicy initialBitrateEstimatePolicy) { + this.initialBitrateEstimatePolicy = initialBitrateEstimatePolicy; + return this; + } + + /** + * Set minVideoBitrate in ABR + * + * @param minVideoBitrate - minimum video bitrate in ABR + * @return - ABRSettings + */ public ABRSettings setMinVideoBitrate(long minVideoBitrate) { + if (minVideoBitrate < 0) { + minVideoBitrate = Long.MIN_VALUE; + } this.minVideoBitrate = minVideoBitrate; return this; } + /** + * Set maxVideoBitrate in ABR + * + * @param maxVideoBitrate - maximum video bitrate in ABR + * @return - ABRSettings + */ public ABRSettings setMaxVideoBitrate(long maxVideoBitrate) { + if (maxVideoBitrate < 0) { + maxVideoBitrate = Long.MAX_VALUE; + } this.maxVideoBitrate = maxVideoBitrate; return this; } - public ABRSettings setInitialBitrateEstimate(long initialBitrateEstimate) { - this.initialBitrateEstimate = initialBitrateEstimate; + /** + * Set maxVideoHeight in ABR + * + * @param maxVideoHeight - maximum video height in ABR + * @return - ABRSettings + */ + public ABRSettings setMaxVideoHeight(long maxVideoHeight) { + if (maxVideoHeight < 0) { + maxVideoHeight = Long.MAX_VALUE; + } + this.maxVideoHeight = maxVideoHeight; + return this; + } + + /** + * Set minVideoHeight in ABR + * + * @param minVideoHeight - minimum video height in ABR + * @return - ABRSettings + */ + public ABRSettings setMinVideoHeight(long minVideoHeight) { + if (minVideoHeight < 0) { + minVideoHeight = Long.MIN_VALUE; + } + this.minVideoHeight = minVideoHeight; + return this; + } + + /** + * Set maxVideoWidth in ABR + * + * @param maxVideoWidth - maximum video width in ABR + * @return - ABRSettings + */ + public ABRSettings setMaxVideoWidth(long maxVideoWidth) { + if (maxVideoWidth < 0) { + maxVideoWidth = Long.MAX_VALUE; + } + this.maxVideoWidth = maxVideoWidth; + return this; + } + + /** + * Set minVideoWidth in ABR + * + * @param minVideoWidth - minimum video width in ABR + * @return - ABRSettings + */ + public ABRSettings setMinVideoWidth(long minVideoWidth) { + if (minVideoWidth < 0) { + minVideoWidth = Long.MIN_VALUE; + } + this.minVideoWidth = minVideoWidth; return this; } @@ -52,4 +166,54 @@ public Long getInitialBitrateEstimate() { return initialBitrateEstimate; } + public InitialBitrateEstimatePolicy getAbrInitialBitrateEstimatePolicy() { + return initialBitrateEstimatePolicy; + } + + public Long getMaxVideoHeight() { + return maxVideoHeight; + } + + public Long getMinVideoHeight() { + return minVideoHeight; + } + + public Long getMaxVideoWidth() { + return maxVideoWidth; + } + + public Long getMinVideoWidth() { + return minVideoWidth; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + ABRSettings that = (ABRSettings) o; + + if (minVideoBitrate != null ? !minVideoBitrate.equals(that.minVideoBitrate) : that.minVideoBitrate != null) + return false; + if (maxVideoBitrate != null ? !maxVideoBitrate.equals(that.maxVideoBitrate) : that.maxVideoBitrate != null) + return false; + if (maxVideoHeight != null ? !maxVideoHeight.equals(that.maxVideoHeight) : that.maxVideoHeight != null) + return false; + if (minVideoHeight != null ? !minVideoHeight.equals(that.minVideoHeight) : that.minVideoHeight != null) + return false; + if (maxVideoWidth != null ? !maxVideoWidth.equals(that.maxVideoWidth) : that.maxVideoWidth != null) + return false; + return minVideoWidth != null ? minVideoWidth.equals(that.minVideoWidth) : that.minVideoWidth == null; + } + + @Override + public int hashCode() { + int result = minVideoBitrate != null ? minVideoBitrate.hashCode() : 0; + result = 31 * result + (maxVideoBitrate != null ? maxVideoBitrate.hashCode() : 0); + result = 31 * result + (maxVideoHeight != null ? maxVideoHeight.hashCode() : 0); + result = 31 * result + (minVideoHeight != null ? minVideoHeight.hashCode() : 0); + result = 31 * result + (maxVideoWidth != null ? maxVideoWidth.hashCode() : 0); + result = 31 * result + (minVideoWidth != null ? minVideoWidth.hashCode() : 0); + return result; + } } diff --git a/playkit/src/main/java/com/kaltura/playkit/player/AudioCodecSettings.java b/playkit/src/main/java/com/kaltura/playkit/player/AudioCodecSettings.java index 8ff908087..86ee7b40b 100644 --- a/playkit/src/main/java/com/kaltura/playkit/player/AudioCodecSettings.java +++ b/playkit/src/main/java/com/kaltura/playkit/player/AudioCodecSettings.java @@ -8,13 +8,14 @@ public class AudioCodecSettings { private List codecPriorityList = new ArrayList<>(); private boolean allowMixedCodecs = false; + private boolean allowMixedBitrates = false; public AudioCodecSettings() { codecPriorityList = getDefaultCodecsPriorityList(); } public AudioCodecSettings(List codecPriorityList, boolean allowMixedCodecs) { - if (codecPriorityList != null || codecPriorityList.isEmpty()) { + if (codecPriorityList != null && !codecPriorityList.isEmpty()) { this.codecPriorityList = codecPriorityList; } else { getDefaultCodecsPriorityList(); @@ -30,6 +31,10 @@ public boolean getAllowMixedCodecs() { return allowMixedCodecs; } + public boolean getAllowMixedBitrates() { + return allowMixedBitrates; + } + private List getDefaultCodecsPriorityList() { if (codecPriorityList == null) { codecPriorityList = new ArrayList<>(); @@ -56,4 +61,9 @@ public AudioCodecSettings setAllowMixedCodecs(boolean allowMixedCodecs) { this.allowMixedCodecs = allowMixedCodecs; return this; } + + public AudioCodecSettings setAllowMixedBitrates(boolean allowMixedBitrates) { + this.allowMixedBitrates = allowMixedBitrates; + return this; + } } diff --git a/playkit/src/main/java/com/kaltura/playkit/player/AudioTrack.java b/playkit/src/main/java/com/kaltura/playkit/player/AudioTrack.java index 1e04fb19f..c15f5c316 100644 --- a/playkit/src/main/java/com/kaltura/playkit/player/AudioTrack.java +++ b/playkit/src/main/java/com/kaltura/playkit/player/AudioTrack.java @@ -12,6 +12,7 @@ package com.kaltura.playkit.player; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.kaltura.playkit.PKAudioCodec; @@ -20,7 +21,7 @@ * Audio track data holder. * Created by anton.afanasiev on 17/11/2016. */ -public class AudioTrack extends BaseTrack { +public class AudioTrack extends BaseTrack implements Comparable { private long bitrate; private String label; @@ -98,4 +99,9 @@ public int getChannelCount() { @Nullable public String getCodecName() { return codecName; } + + @Override + public int compareTo(@NonNull AudioTrack track) { + return Long.compare(this.getBitrate(), track.getBitrate()); + } } diff --git a/playkit/src/main/java/com/kaltura/playkit/player/BaseExoplayerView.java b/playkit/src/main/java/com/kaltura/playkit/player/BaseExoplayerView.java index fd1c0beb7..8c7936be2 100644 --- a/playkit/src/main/java/com/kaltura/playkit/player/BaseExoplayerView.java +++ b/playkit/src/main/java/com/kaltura/playkit/player/BaseExoplayerView.java @@ -3,10 +3,11 @@ import android.content.Context; import android.util.AttributeSet; -import com.kaltura.android.exoplayer2.SimpleExoPlayer; -import com.kaltura.android.exoplayer2.ui.SubtitleView; +import com.kaltura.androidx.media3.exoplayer.ExoPlayer; +import com.kaltura.androidx.media3.ui.SubtitleView; +import com.kaltura.androidx.media3.exoplayer.video.KVideoRendererFirstFrameWhenStartedEventListener; -public abstract class BaseExoplayerView extends PlayerView { +public abstract class BaseExoplayerView extends PlayerView implements KVideoRendererFirstFrameWhenStartedEventListener { public BaseExoplayerView(Context context) { super(context); @@ -20,10 +21,11 @@ public BaseExoplayerView(Context context, AttributeSet attrs) { super(context, attrs); } - public abstract void setPlayer(SimpleExoPlayer player, boolean useTextureView, boolean isSurfaceSecured, boolean hideVideoViews); + public abstract void setPlayer(ExoPlayer player, boolean useTextureView, boolean isSurfaceSecured, boolean hideVideoViews); public abstract void setVideoSurfaceProperties(boolean useTextureView, boolean isSurfaceSecured, boolean hideVideoViews); public abstract SubtitleView getSubtitleView(); + public abstract void applySubtitlesChanges(); } diff --git a/playkit/src/main/java/com/kaltura/playkit/player/ExternalTextTrackLoadErrorPolicy.java b/playkit/src/main/java/com/kaltura/playkit/player/CustomLoadErrorHandlingPolicy.java similarity index 51% rename from playkit/src/main/java/com/kaltura/playkit/player/ExternalTextTrackLoadErrorPolicy.java rename to playkit/src/main/java/com/kaltura/playkit/player/CustomLoadErrorHandlingPolicy.java index 76aa0edd5..5ca44b8bf 100644 --- a/playkit/src/main/java/com/kaltura/playkit/player/ExternalTextTrackLoadErrorPolicy.java +++ b/playkit/src/main/java/com/kaltura/playkit/player/CustomLoadErrorHandlingPolicy.java @@ -2,9 +2,9 @@ import android.net.Uri; -import com.kaltura.android.exoplayer2.upstream.DataSpec; -import com.kaltura.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; -import com.kaltura.android.exoplayer2.upstream.HttpDataSource; +import com.kaltura.androidx.media3.datasource.DataSpec; +import com.kaltura.androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy; +import com.kaltura.androidx.media3.datasource.HttpDataSource; import com.kaltura.playkit.PKError; import com.kaltura.playkit.PKLog; import com.kaltura.playkit.PKSubtitleFormat; @@ -12,37 +12,34 @@ import java.io.IOException; -public class ExternalTextTrackLoadErrorPolicy extends DefaultLoadErrorHandlingPolicy { +public class CustomLoadErrorHandlingPolicy extends DefaultLoadErrorHandlingPolicy { - private static final PKLog log = PKLog.get("ExternalTextTrackLoadError"); + private static final PKLog log = PKLog.get("LoadErrorHandlingPolicy"); - private ExternalTextTrackLoadErrorPolicy.OnTextTrackLoadErrorListener textTrackLoadErrorListener; + private CustomLoadErrorHandlingPolicy.OnTextTrackLoadErrorListener textTrackLoadErrorListener; + public static final int LOADABLE_RETRY_COUNT_UNSET = 0; + private final int maximumLoadableRetryCount; public interface OnTextTrackLoadErrorListener { void onTextTrackLoadError(PKError currentError); } - public void setOnTextTrackErrorListener(ExternalTextTrackLoadErrorPolicy.OnTextTrackLoadErrorListener onTextTrackErrorListener) { - this.textTrackLoadErrorListener = onTextTrackErrorListener; + public CustomLoadErrorHandlingPolicy(int maximumLoadableRetryCount) { + this.maximumLoadableRetryCount = maximumLoadableRetryCount; } - //public void onTextTrackLoadError(PKError currentError) { - // textTrackLoadErrorListener.onTextTrackLoadError(currentError); - //} TODO // need to test with vtt errored file + public void setOnTextTrackErrorListener(CustomLoadErrorHandlingPolicy.OnTextTrackLoadErrorListener onTextTrackErrorListener) { + this.textTrackLoadErrorListener = onTextTrackErrorListener; + } @Override public long getRetryDelayMsFor(LoadErrorInfo loadErrorInfo) { - if (loadErrorInfo == null) { - return Consts.TIME_UNSET; - } - IOException exception = loadErrorInfo.exception; Uri pathSegment = getPathSegmentUri(exception); if (pathSegment == null || !(exception instanceof HttpDataSource.HttpDataSourceException)) { - return super.getRetryDelayMsFor(loadErrorInfo); + return getRetryDelay(loadErrorInfo); } - String lastPathSegment = pathSegment.getLastPathSegment(); if (lastPathSegment != null && (lastPathSegment.endsWith(PKSubtitleFormat.vtt.pathExt) || lastPathSegment.endsWith(PKSubtitleFormat.srt.pathExt))) { PKError currentError = new PKError(PKPlayerErrorType.SOURCE_ERROR, PKError.Severity.Recoverable, "TextTrack is invalid url=" + pathSegment, exception); @@ -52,8 +49,37 @@ public long getRetryDelayMsFor(LoadErrorInfo loadErrorInfo) { } return Consts.TIME_UNSET; } else { - return super.getRetryDelayMsFor(loadErrorInfo); + return getRetryDelay(loadErrorInfo); + } + } + + /** + * Delay in the each retry request + * Max delay is 5ms (Formula: min((loadErrorInfo.errorCount - 1) * 1000, 5000)) + * + * @param loadErrorInfo Object containing the data about error + * @return delay + */ + private long getRetryDelay(LoadErrorInfo loadErrorInfo) { + if (maximumLoadableRetryCount > LOADABLE_RETRY_COUNT_UNSET && loadErrorInfo.errorCount >= maximumLoadableRetryCount) { + return Consts.TIME_UNSET; + } + return super.getRetryDelayMsFor(loadErrorInfo); + } + + /** + * Override the retry count on ExoPlayer. + * This retry count is valid for all types of Player requests (Manifest/DRM/Chunk) + * + * @param dataType Type of request + * @return No of retries + */ + @Override + public int getMinimumLoadableRetryCount(int dataType) { + if (maximumLoadableRetryCount > LOADABLE_RETRY_COUNT_UNSET) { + return maximumLoadableRetryCount; } + return super.getMinimumLoadableRetryCount(dataType); } private Uri getPathSegmentUri(IOException ioException) { diff --git a/playkit/src/main/java/com/kaltura/playkit/player/DRMSettings.java b/playkit/src/main/java/com/kaltura/playkit/player/DRMSettings.java new file mode 100644 index 000000000..44bdebc5a --- /dev/null +++ b/playkit/src/main/java/com/kaltura/playkit/player/DRMSettings.java @@ -0,0 +1,91 @@ +package com.kaltura.playkit.player; + +import com.kaltura.playkit.PKDrmParams; + +public class DRMSettings { + private PKDrmParams.Scheme drmScheme; + private boolean isMultiSession = false; + private boolean isForceDefaultLicenseUri = false; + private boolean isAllowClearlead = true; + private boolean isForceWidevineL3Playback = false; + + public DRMSettings(PKDrmParams.Scheme drmScheme) { + this.drmScheme = drmScheme; + if (drmScheme == PKDrmParams.Scheme.PlayReadyCENC) { + this.isForceDefaultLicenseUri = true; + } + } + + public PKDrmParams.Scheme getDrmScheme() { + return drmScheme; + } + + public DRMSettings setDrmScheme(PKDrmParams.Scheme drmScheme) { + this.drmScheme = drmScheme; + if (drmScheme == PKDrmParams.Scheme.PlayReadyCENC) { + this.isForceDefaultLicenseUri = true; + } + return this; + } + + public boolean getIsMultiSession() { + return isMultiSession; + } + + /** + * Sets whether the DRM configuration is multi session enabled. + */ + public DRMSettings setIsMultiSession(boolean isMultiSession) { + this.isMultiSession = isMultiSession; + return this; + } + + public boolean getIsForceDefaultLicenseUri() { + return isForceDefaultLicenseUri; + } + + /** + * Sets whether to force use the default DRM license server URI even if the media specifies its + * own DRM license server URI. + *
+ * Default is set to `true` for Playready DRM streams otherwise `false` for Widevine DRM streams. + * Means in both the cases, DRM license URL should be passed for the playback. + *
+ *
+ *
+ * If license URL is not passed for Playready Stream and + * manifest has InStream license URL then set it `false`, + * by doing this; Player will take the license URL from the manifest. + *
+ *
+ *
+ * Passing `true` for Widevine streams is not applicable. + *
+ */ + public DRMSettings setIsForceDefaultLicenseUri(boolean isForceDefaultLicenseUri) { + this.isForceDefaultLicenseUri = isForceDefaultLicenseUri; + return this; + } + + public boolean getAllowClearlead() { + return isAllowClearlead; + } + + /** + * Sets whether clear samples within protected content should be played when keys for the + * encrypted part of the content have yet to be loaded. + */ + public DRMSettings setIsAllowClearlead(boolean isAllowClearlead) { + this.isAllowClearlead = isAllowClearlead; + return this; + } + + public boolean getIsForceWidevineL3Playback() { + return isForceWidevineL3Playback; + } + + public DRMSettings setIsForceWidevineL3Playback(boolean isForceWidevineL3Playback) { + this.isForceWidevineL3Playback = isForceWidevineL3Playback; + return this; + } +} diff --git a/playkit/src/main/java/com/kaltura/playkit/player/DashImageTrack.java b/playkit/src/main/java/com/kaltura/playkit/player/DashImageTrack.java new file mode 100644 index 000000000..e8f024bdb --- /dev/null +++ b/playkit/src/main/java/com/kaltura/playkit/player/DashImageTrack.java @@ -0,0 +1,64 @@ +/* + * ============================================================================ + * Copyright (C) 2017 Kaltura Inc. + * + * Licensed under the AGPLv3 license, unless a different license for a + * particular library is specified in the applicable library path. + * + * You may obtain a copy of the License at + * https://www.gnu.org/licenses/agpl-3.0.html + * ============================================================================ + */ + +package com.kaltura.playkit.player; + +/** + * Image track data holder. + * + */ +public class DashImageTrack extends ImageTrack { + + private long presentationTimeOffset; + private long timeScale; + private long startNumber; + private long endNumber; + + DashImageTrack(String uniqueId, + String label, + long bitrate, + float width, + float height, + int cols, + int rows, + long duration, + String url, + long presentationTimeOffset, + long timeScale, + long startNumber, + long endtNumber + ) { + super(uniqueId, label, bitrate, width, height, cols, rows, duration, url); + + this.presentationTimeOffset = presentationTimeOffset; + this.timeScale = timeScale; + this.startNumber = startNumber; + this.endNumber = endtNumber; + } + + public long getPresentationTimeOffset() { + return presentationTimeOffset; + } + + public long getTimeScale() { + return timeScale; + } + + public long getStartNumber() { + return startNumber; + } + + public long getEndNumber() { + return endNumber; + } + +} diff --git a/playkit/src/main/java/com/kaltura/playkit/player/ExoAnalyticsAggregator.java b/playkit/src/main/java/com/kaltura/playkit/player/ExoAnalyticsAggregator.java index 1009482eb..7002c4bb7 100644 --- a/playkit/src/main/java/com/kaltura/playkit/player/ExoAnalyticsAggregator.java +++ b/playkit/src/main/java/com/kaltura/playkit/player/ExoAnalyticsAggregator.java @@ -1,19 +1,32 @@ package com.kaltura.playkit.player; +import static com.kaltura.playkit.utils.Consts.HTTP_METHOD_GET; + import android.os.SystemClock; +import android.text.TextUtils; -import com.kaltura.android.exoplayer2.C; -import com.kaltura.android.exoplayer2.analytics.AnalyticsListener; -import com.kaltura.android.exoplayer2.decoder.DecoderCounters; -import com.kaltura.android.exoplayer2.source.LoadEventInfo; -import com.kaltura.android.exoplayer2.source.MediaLoadData; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.common.collect.ImmutableList; +import com.kaltura.androidx.media3.common.C; +import com.kaltura.androidx.media3.common.Format; +import com.kaltura.androidx.media3.common.Tracks; +import com.kaltura.androidx.media3.exoplayer.analytics.AnalyticsListener; +import com.kaltura.androidx.media3.exoplayer.DecoderCounters; +import com.kaltura.androidx.media3.exoplayer.DecoderReuseEvaluation; +import com.kaltura.androidx.media3.exoplayer.source.LoadEventInfo; +import com.kaltura.androidx.media3.exoplayer.source.MediaLoadData; +import com.kaltura.androidx.media3.common.TrackGroup; import com.kaltura.playkit.PKLog; import com.kaltura.playkit.player.metadata.URIConnectionAcquiredInfo; import java.io.IOException; import java.net.InetAddress; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import okhttp3.Call; @@ -21,10 +34,13 @@ import okhttp3.EventListener; import okhttp3.Handshake; -import static com.kaltura.playkit.utils.Consts.HTTP_METHOD_GET; - class ExoAnalyticsAggregator extends EventListener implements AnalyticsListener { + public interface InputFormatChangedListener { + void onVideoInputFormatChanged(@NonNull Format format); + void onAudioInputFormatChanged(@NonNull Format format); + } + private static final PKLog log = PKLog.get("ExoAnalyticsAggregator"); private final Map urlCallTimeMap = new ConcurrentHashMap<>(); private long totalDroppedFrames; @@ -32,7 +48,8 @@ class ExoAnalyticsAggregator extends EventListener implements AnalyticsListener private int renderedOutputBufferCount; private int skippedOutputBufferCount; - private PlayerEngine.AnalyticsListener listener; + @Nullable private PlayerEngine.AnalyticsListener listener; + @Nullable private InputFormatChangedListener inputFormatChangedListener; void reset() { totalDroppedFrames = 0; @@ -41,8 +58,24 @@ void reset() { skippedOutputBufferCount = 0; } + /** + * Listener for extra info. Being used to send the events + * @param listener listener for PlayerController + */ + public void setListener(@Nullable PlayerEngine.AnalyticsListener listener) { + this.listener = listener; + } + + /** + * Listener to get the selected Video/Audio format by the Player + * @param inputFormatChangedListener listener for ExoPlayerWrapper + */ + public void setInputFormatChangedListener(@Nullable InputFormatChangedListener inputFormatChangedListener) { + this.inputFormatChangedListener = inputFormatChangedListener; + } + @Override - public void onDroppedVideoFrames(EventTime eventTime, int droppedFrames, long elapsedMs) { + public void onDroppedVideoFrames(@NonNull EventTime eventTime, int droppedFrames, long elapsedMs) { totalDroppedFrames += droppedFrames; if (listener != null) { listener.onDroppedFrames(droppedFrames, elapsedMs, totalDroppedFrames); @@ -50,22 +83,33 @@ public void onDroppedVideoFrames(EventTime eventTime, int droppedFrames, long el } @Override - public void onVideoDisabled(EventTime eventTime, DecoderCounters decoderCounters) { + public void onVideoDisabled(@NonNull EventTime eventTime, DecoderCounters decoderCounters) { skippedOutputBufferCount = decoderCounters.skippedOutputBufferCount; renderedOutputBufferCount = decoderCounters.renderedOutputBufferCount; if (listener != null) { listener.onDecoderDisabled(skippedOutputBufferCount, renderedOutputBufferCount); + listener.onVideoDisabled(); + } + } + + @Override + public void onVideoEnabled(EventTime eventTime, DecoderCounters decoderCounters) { + if (listener != null) { + listener.onVideoEnabled(); } } - + @Override - public void onLoadCompleted(EventTime eventTime, LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) { + public void onLoadCompleted(@NonNull EventTime eventTime, LoadEventInfo loadEventInfo, @NonNull MediaLoadData mediaLoadData) { if (loadEventInfo.bytesLoaded > 0) { if (mediaLoadData.trackType == C.TRACK_TYPE_VIDEO || mediaLoadData.trackType == C.TRACK_TYPE_AUDIO || mediaLoadData.trackType == C.TRACK_TYPE_DEFAULT) { // in HLS track type 0 is sent in dash type 1 is sent totalBytesLoaded += loadEventInfo.bytesLoaded; } - log.v("onLoadCompleted trackType = " + mediaLoadData.trackType + ", mediaLoadData.dataType " + mediaLoadData.dataType + ", " + loadEventInfo.loadDurationMs + " " + loadEventInfo.uri.toString()); + log.v("onLoadCompleted trackType = " + mediaLoadData.trackType + ", mediaLoadData.dataType " + mediaLoadData.dataType + ", " + loadEventInfo.loadDurationMs + " " + loadEventInfo.uri); if (listener != null) { + if (mediaLoadData.trackType == C.TRACK_TYPE_UNKNOWN && mediaLoadData.dataType == C.DATA_TYPE_MANIFEST) { + listener.onManifestRedirected(loadEventInfo.uri.toString()); + } listener.onBytesLoaded(mediaLoadData.trackType, mediaLoadData.dataType, loadEventInfo.bytesLoaded, loadEventInfo.loadDurationMs, totalBytesLoaded); } } @@ -73,31 +117,28 @@ public void onLoadCompleted(EventTime eventTime, LoadEventInfo loadEventInfo, Me @Override public void onIsLoadingChanged(EventTime eventTime, boolean isLoading) { - log.v("onIsLoadingChanged eventPlaybackPositionMs = " + eventTime.eventPlaybackPositionMs + " totalBufferedDurationMs = " + eventTime.totalBufferedDurationMs + " isLoading = " + Boolean.toString(isLoading)); + log.v("onIsLoadingChanged eventPlaybackPositionMs = " + eventTime.eventPlaybackPositionMs + " totalBufferedDurationMs = " + eventTime.totalBufferedDurationMs + " isLoading = " + isLoading); } @Override - public void onLoadCanceled(EventTime eventTime, LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) { + public void onLoadCanceled(@NonNull EventTime eventTime, @NonNull LoadEventInfo loadEventInfo, @NonNull MediaLoadData mediaLoadData) { onLoadCompleted(eventTime, loadEventInfo, mediaLoadData); // in case there are bytes loaded } @Override - public void onLoadError(EventTime eventTime, LoadEventInfo loadEventInfo,MediaLoadData mediaLoadData, IOException error, boolean wasCanceled) { + public void onLoadError(@NonNull EventTime eventTime, @NonNull LoadEventInfo loadEventInfo, @NonNull MediaLoadData mediaLoadData, @NonNull IOException error, boolean wasCanceled) { + log.v("onLoadError Uri = " + loadEventInfo.uri); onLoadCompleted(eventTime, loadEventInfo, mediaLoadData); // in case there are bytes loaded if (listener != null) { listener.onLoadError(error, wasCanceled); } } - public void setListener(PlayerEngine.AnalyticsListener listener) { - this.listener = listener; - } - @Override // OKHTTTP public void callStart(Call call) { String loadedURL = call.request().url().toString(); log.v("callStart = " + loadedURL); - if (HTTP_METHOD_GET.equals(call.request().method())) { + if (!TextUtils.isEmpty(loadedURL) && HTTP_METHOD_GET.equals(call.request().method())) { URIConnectionAcquiredInfo connectionInfo = new URIConnectionAcquiredInfo(); connectionInfo.connectDurationMs = SystemClock.elapsedRealtime(); connectionInfo.url = loadedURL; @@ -107,22 +148,28 @@ public void callStart(Call call) { } @Override // OKHTTTP - public void connectionAcquired(Call call, Connection connection) { + public void connectionAcquired(Call call, @NonNull Connection connection) { String loadedURL = call.request().url().toString(); log.v("connectionAcquired = " + loadedURL); - if (urlCallTimeMap.containsKey(loadedURL) && urlCallTimeMap.get(loadedURL) != null) { - urlCallTimeMap.get(loadedURL).connectDurationMs = (SystemClock.elapsedRealtime() - urlCallTimeMap.get(loadedURL).connectDurationMs); + if (isLoadedURLExists(loadedURL)) { + URIConnectionAcquiredInfo uriConnectionAcquiredInfo = urlCallTimeMap.get(loadedURL); + if (uriConnectionAcquiredInfo != null) { + uriConnectionAcquiredInfo.connectDurationMs = (SystemClock.elapsedRealtime() - uriConnectionAcquiredInfo.connectDurationMs); + } } } @Override // OKHTTTP - public void connectionReleased(Call call, Connection connection) { + public void connectionReleased(Call call, @NonNull Connection connection) { String loadedURL = call.request().url().toString(); log.v("connectionReleased = " + loadedURL); - if (urlCallTimeMap.containsKey(loadedURL) && urlCallTimeMap.get(loadedURL) != null) { + if (isLoadedURLExists(loadedURL)) { if (listener != null) { listener.onConnectionAcquired(urlCallTimeMap.get(loadedURL)); - log.v("connectionReleased SEND EVENT " + urlCallTimeMap.get(loadedURL).toString()); + URIConnectionAcquiredInfo uriConnectionAcquiredInfo = urlCallTimeMap.get(loadedURL); + if (uriConnectionAcquiredInfo != null) { + log.v("connectionReleased SEND EVENT"); + } } urlCallTimeMap.remove(loadedURL); @@ -130,22 +177,26 @@ public void connectionReleased(Call call, Connection connection) { } @Override - public void dnsStart(Call call, String domainName) { + public void dnsStart(Call call, @NonNull String domainName) { log.v("dnsStart"); String loadedURL = call.request().url().toString(); - if (urlCallTimeMap.containsKey(loadedURL) && urlCallTimeMap.get(loadedURL) != null) { - if (urlCallTimeMap.containsKey(loadedURL)) { - urlCallTimeMap.get(loadedURL).dnsDurationMs = SystemClock.elapsedRealtime(); + if (isLoadedURLExists(loadedURL)) { + URIConnectionAcquiredInfo uriConnectionAcquiredInfo = urlCallTimeMap.get(loadedURL); + if (uriConnectionAcquiredInfo != null) { + uriConnectionAcquiredInfo.dnsDurationMs = SystemClock.elapsedRealtime(); } } } @Override - public void dnsEnd(Call call, String domainName, List inetAddressList) { + public void dnsEnd(Call call, @NonNull String domainName, @NonNull List inetAddressList) { log.v("dnsEnd"); String loadedURL = call.request().url().toString(); - if (urlCallTimeMap.containsKey(loadedURL) && urlCallTimeMap.get(loadedURL) != null) { - urlCallTimeMap.get(loadedURL).dnsDurationMs = (SystemClock.elapsedRealtime() - urlCallTimeMap.get(loadedURL).dnsDurationMs); + if (isLoadedURLExists(loadedURL)) { + URIConnectionAcquiredInfo uriConnectionAcquiredInfo = urlCallTimeMap.get(loadedURL); + if (uriConnectionAcquiredInfo != null) { + uriConnectionAcquiredInfo.dnsDurationMs = (SystemClock.elapsedRealtime() - uriConnectionAcquiredInfo.dnsDurationMs); + } } } @@ -153,8 +204,11 @@ public void dnsEnd(Call call, String domainName, List inetAddressLi public void secureConnectStart(Call call) { log.v("secureConnectStart"); String loadedURL = call.request().url().toString(); - if (urlCallTimeMap.containsKey(loadedURL) && urlCallTimeMap.get(loadedURL) != null) { - urlCallTimeMap.get(loadedURL).tlsDurationMs = SystemClock.elapsedRealtime(); + if (isLoadedURLExists(loadedURL)) { + URIConnectionAcquiredInfo uriConnectionAcquiredInfo = urlCallTimeMap.get(loadedURL); + if (uriConnectionAcquiredInfo != null) { + uriConnectionAcquiredInfo.tlsDurationMs = SystemClock.elapsedRealtime(); + } } } @@ -162,9 +216,122 @@ public void secureConnectStart(Call call) { public void secureConnectEnd(Call call, Handshake handshake) { log.v("secureConnectEnd"); String loadedURL = call.request().url().toString(); - if (urlCallTimeMap.containsKey(loadedURL) && urlCallTimeMap.get(loadedURL) != null) { - urlCallTimeMap.get(loadedURL).tlsDurationMs = (SystemClock.elapsedRealtime() - urlCallTimeMap.get(loadedURL).tlsDurationMs); + if (isLoadedURLExists(loadedURL)) { + URIConnectionAcquiredInfo uriConnectionAcquiredInfo = urlCallTimeMap.get(loadedURL); + if (uriConnectionAcquiredInfo != null) { + uriConnectionAcquiredInfo.tlsDurationMs = (SystemClock.elapsedRealtime() - uriConnectionAcquiredInfo.tlsDurationMs); + } + } + } + + private boolean isLoadedURLExists(String loadedURL) { + return loadedURL != null && urlCallTimeMap.containsKey(loadedURL) && urlCallTimeMap.get(loadedURL) != null; + } + + @Override + public void onVideoInputFormatChanged(@NonNull EventTime eventTime, @NonNull Format format, @Nullable DecoderReuseEvaluation decoderReuseEvaluation) { + if (inputFormatChangedListener != null) { + inputFormatChangedListener.onVideoInputFormatChanged(format); + } + } + + @Override + public void onAudioInputFormatChanged(@NonNull EventTime eventTime, @NonNull Format format, @Nullable DecoderReuseEvaluation decoderReuseEvaluation) { + if (inputFormatChangedListener != null) { + inputFormatChangedListener.onAudioInputFormatChanged(format); + } + } + + @Override + public void onTracksChanged(@NonNull EventTime eventTime, @NonNull Tracks tracks) { + if (tracks.isEmpty() || tracks.getGroups().isEmpty()) { + return; + } + + ImmutableList trackGroups = tracks.getGroups(); + StringBuilder logOutput = new StringBuilder(); + Set availableTrackType = new HashSet<>(); + + for (int groupIndex = 0; groupIndex < trackGroups.size(); groupIndex++) { + Tracks.Group trackGroup = trackGroups.get(groupIndex); + String trackType = getTrackType(trackGroup.getType()); + + if (!availableTrackType.contains(trackType)) { + availableTrackType.add(trackType); + logOutput + .append("\n\"").append(trackType).append("\"") + .append(":") + .append(" [").append("\n"); + } + + final TrackGroup mediaTrackGroup = trackGroup.getMediaTrackGroup(); + + for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) { + boolean isTrackSelected = trackGroup.isTrackSelected(trackIndex); + // Get format of each track group present individually in video/audio/text + Format format = mediaTrackGroup.getFormat(trackIndex); + String mediaFormat = Format.toLogString(format); + logOutput.append("\t{").append("\n"); + + logOutput.append("\t\"").append("isTrackSelected").append("\"").append(":") + .append("\t\"").append(isTrackSelected).append("\"") + .append("\t,").append("\n"); + + logOutput.append("\t\"").append("Format").append("\"").append(":") + .append("\t\"").append(mediaFormat).append("\"").append("\n"); + + logOutput.append("\t}"); + if (trackIndex + 1 < trackGroup.length) { + logOutput.append(",").append("\n"); + } + } + String nextTrackType = ""; + if (groupIndex + 1 < trackGroups.size()) { + Tracks.Group nextTrackGroup = trackGroups.get(groupIndex + 1); + nextTrackType = getTrackType(nextTrackGroup.getType()); + } + + if (!availableTrackType.contains(nextTrackType)) { + logOutput.append("]"); + } + + if (groupIndex + 1 < trackGroups.size()) { + logOutput.append(","); + } + } + log.d("{" + logOutput + "\n }"); + } + + private String getTrackType(int type) { + String trackType; + switch (type) { + case C.TRACK_TYPE_VIDEO: + trackType = "Video"; + break; + case C.TRACK_TYPE_AUDIO: + trackType = "Audio"; + break; + case C.TRACK_TYPE_TEXT: + trackType = "Text"; + break; + case C.TRACK_TYPE_IMAGE: + trackType = "Image"; + break; + default: + trackType = "None"; + break; } + return trackType; + } + + @Override + public void onVideoCodecError(@NonNull EventTime eventTime, @NonNull Exception videoCodecError) { + log.v("onVideoCodecError Exception " + videoCodecError.getMessage()); + } + + @Override + public void onDrmSessionManagerError(@NonNull EventTime eventTime, @NonNull Exception error) { + log.v("onDrmSessionManagerError Exception " + error.getMessage()); } } diff --git a/playkit/src/main/java/com/kaltura/playkit/player/ExoPlayerView.java b/playkit/src/main/java/com/kaltura/playkit/player/ExoPlayerView.java index f002144c5..7d7cd2780 100644 --- a/playkit/src/main/java/com/kaltura/playkit/player/ExoPlayerView.java +++ b/playkit/src/main/java/com/kaltura/playkit/player/ExoPlayerView.java @@ -16,10 +16,6 @@ import android.graphics.Color; import android.graphics.Matrix; import android.graphics.RectF; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - import android.os.Build; import android.util.AttributeSet; import android.view.Gravity; @@ -29,13 +25,15 @@ import android.view.ViewGroup; import android.widget.FrameLayout; -import com.kaltura.android.exoplayer2.Player; -import com.kaltura.android.exoplayer2.SimpleExoPlayer; -import com.kaltura.android.exoplayer2.text.Cue; -import com.kaltura.android.exoplayer2.text.TextOutput; -import com.kaltura.android.exoplayer2.ui.AspectRatioFrameLayout; -import com.kaltura.android.exoplayer2.ui.SubtitleView; -import com.kaltura.android.exoplayer2.video.VideoListener; +import androidx.annotation.NonNull; + +import com.kaltura.androidx.media3.exoplayer.ExoPlayer; +import com.kaltura.androidx.media3.common.Player; +import com.kaltura.androidx.media3.common.text.Cue; +import com.kaltura.androidx.media3.common.text.CueGroup; +import com.kaltura.androidx.media3.ui.AspectRatioFrameLayout; +import com.kaltura.androidx.media3.ui.SubtitleView; +import com.kaltura.androidx.media3.common.VideoSize; import com.kaltura.playkit.PKLog; import java.util.ArrayList; @@ -55,46 +53,88 @@ class ExoPlayerView extends BaseExoplayerView { private SubtitleView subtitleView; private AspectRatioFrameLayout contentFrame; - private SimpleExoPlayer player; + private ExoPlayer player; private ComponentListener componentListener; - private Player.EventListener playerEventListener; + private Player.Listener playerEventListener; private int textureViewRotation; private @AspectRatioFrameLayout.ResizeMode int resizeMode; private PKSubtitlePosition subtitleViewPosition; private boolean isVideoViewVisible; + private List lastReportedCues; + private boolean shutterStaysOnRenderedFirstFrame; + private boolean muteWhenShutterVisible; + + private boolean usingSpeedAdjustedRenderer; - ExoPlayerView(Context context) { - this(context, null); + ExoPlayerView(Context context, boolean shutterStaysOnRenderedFirstFrame, boolean muteWhenShutterVisible) { + this(context, null, shutterStaysOnRenderedFirstFrame, muteWhenShutterVisible); } - ExoPlayerView(Context context, AttributeSet attrs) { - this(context, attrs, 0); + ExoPlayerView(Context context, AttributeSet attrs, boolean shutterStaysOnRenderedFirstFrame, boolean muteWhenShutterVisible) { + this(context, attrs, 0, shutterStaysOnRenderedFirstFrame, muteWhenShutterVisible); } - ExoPlayerView(Context context, AttributeSet attrs, int defStyleAttr) { + ExoPlayerView(Context context, AttributeSet attrs, int defStyleAttr, boolean shutterStaysOnRenderedFirstFrame, boolean muteWhenShutterVisible) { super(context, attrs, defStyleAttr); componentListener = new ComponentListener(); playerEventListener = getPlayerEventListener(); initContentFrame(); initSubtitleLayout(); initPosterView(); + setShutterStaysOnRenderedFirstFrame(shutterStaysOnRenderedFirstFrame); + setMuteWhenShutterVisible(muteWhenShutterVisible); + } + + public void setShutterStaysOnRenderedFirstFrame(boolean shutterStaysOnRenderedFirstFrame) { + this.shutterStaysOnRenderedFirstFrame = shutterStaysOnRenderedFirstFrame; + } + + public void setMuteWhenShutterVisible(boolean muteWhenShutterVisible) { + this.muteWhenShutterVisible = muteWhenShutterVisible; + } + + public void setUsingSpeedAdjustedRenderer(boolean usingSpeedAdjustedRenderer) { + this.usingSpeedAdjustedRenderer = usingSpeedAdjustedRenderer; + } + + @Override + public void onRenderedFirstFrameWhenStarted() { + log.d("onRenderedFirstFrameWhenStarted"); + if (shutterView != null) { + shutterView.setVisibility(INVISIBLE); + if (shutterStaysOnRenderedFirstFrame && usingSpeedAdjustedRenderer && muteWhenShutterVisible) { + player.setVolume(1.0f); + } + } + } + + private boolean shouldHideShutterView() { + return shutterView != null && !(shutterStaysOnRenderedFirstFrame && usingSpeedAdjustedRenderer); } @NonNull - private Player.EventListener getPlayerEventListener() { - return new Player.EventListener() { + private Player.Listener getPlayerEventListener() { + return new Player.Listener() { @Override public void onPlaybackStateChanged(int playbackState) { switch (playbackState) { + case Player.STATE_IDLE: + if (shutterStaysOnRenderedFirstFrame && usingSpeedAdjustedRenderer) { + showShutterView(); + } + break; case Player.STATE_READY: if (player != null && player.getPlayWhenReady()) { log.d("ExoPlayerView READY. playWhenReady => true"); - if (shutterView != null) { + if (shouldHideShutterView()) { shutterView.setVisibility(INVISIBLE); } } break; + + case Player.STATE_BUFFERING: + case Player.STATE_ENDED: default: break; } @@ -103,7 +143,7 @@ public void onPlaybackStateChanged(int playbackState) { @Override public void onIsPlayingChanged(boolean isPlaying) { log.d("ExoPlayerView onIsPlayingChanged isPlaying = " + isPlaying); - if (isPlaying && shutterView != null) { + if (isPlaying && shouldHideShutterView()) { shutterView.setVisibility(INVISIBLE); } } @@ -111,14 +151,14 @@ public void onIsPlayingChanged(boolean isPlaying) { } /** - * Set the {@link SimpleExoPlayer} to use. If ExoplayerView instance already has + * Set the {@link ExoPlayer} to use. If ExoplayerView instance already has * player attached to it, it will remove and clear videoSurface first. * - * @param player - The {@link SimpleExoPlayer} to use. + * @param player - The {@link ExoPlayer} to use. * @param isSurfaceSecured - should allow secure rendering of the surface */ @Override - public void setPlayer(SimpleExoPlayer player, boolean useTextureView, boolean isSurfaceSecured, boolean hideVideoViews) { + public void setPlayer(ExoPlayer player, boolean useTextureView, boolean isSurfaceSecured, boolean hideVideoViews) { if (this.player == player) { return; } @@ -154,27 +194,19 @@ private void addVideoSurface(boolean useTextureView, boolean isSurfaceSecured, b resetViews(); createVideoSurface(useTextureView); - Player.VideoComponent newVideoComponent = player.getVideoComponent(); - Player.TextComponent newTextComponent = player.getTextComponent(); player.addListener(playerEventListener); //Decide which type of videoSurface should be set. - if (newVideoComponent != null) { - if (videoSurface instanceof TextureView) { - newVideoComponent.setVideoTextureView((TextureView) videoSurface); - } else { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { - ((SurfaceView) videoSurface).setSecure(isSurfaceSecured); - } - newVideoComponent.setVideoSurfaceView((SurfaceView) videoSurface); + if (videoSurface instanceof TextureView) { + player.setVideoTextureView((TextureView) videoSurface); + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + ((SurfaceView) videoSurface).setSecure(isSurfaceSecured); } - //Set listeners - newVideoComponent.addVideoListener(componentListener); - } - - if (newTextComponent != null) { - newTextComponent.addTextOutput(componentListener); + player.setVideoSurfaceView((SurfaceView) videoSurface); } + //Set listeners + player.addListener(componentListener); contentFrame.addView(videoSurface, 0); @@ -190,28 +222,23 @@ private void addVideoSurface(boolean useTextureView, boolean isSurfaceSecured, b * Clear all the listeners and detach Surface from view hierarchy. */ private void removeVideoSurface() { - Player.VideoComponent oldVideoComponent = player.getVideoComponent(); - Player.TextComponent oldTextComponent = player.getTextComponent(); + if (playerEventListener != null) { player.removeListener(playerEventListener); } //Remove existed videoSurface from player. - if (oldVideoComponent != null) { - - if (videoSurface instanceof SurfaceView) { - oldVideoComponent.clearVideoSurfaceView((SurfaceView) videoSurface); - } else if (videoSurface instanceof TextureView) { - oldVideoComponent.clearVideoTextureView((TextureView) videoSurface); - } - - //Clear listeners. - oldVideoComponent.removeVideoListener(componentListener); + if (videoSurface instanceof SurfaceView) { + player.clearVideoSurfaceView((SurfaceView) videoSurface); + } else if (videoSurface instanceof TextureView) { + player.clearVideoTextureView((TextureView) videoSurface); } - if (oldTextComponent != null) { - oldTextComponent.removeTextOutput(componentListener); + //Clear listeners. + if (componentListener != null) { + player.removeListener(componentListener); } + lastReportedCues = null; contentFrame.removeView(videoSurface); } @@ -281,6 +308,13 @@ public SubtitleView getSubtitleView() { return subtitleView; } + @Override + public void applySubtitlesChanges() { + if (subtitleView != null && lastReportedCues != null) { + subtitleView.setCues(getModifiedSubtitlePosition(lastReportedCues, subtitleViewPosition)); + } + } + @Override public void setVisibility(int visibility) { super.setVisibility(visibility); @@ -314,10 +348,17 @@ private void initSubtitleLayout() { contentFrame.addView(subtitleView); } - private void resetViews() { + private void showShutterView() { if (shutterView != null) { shutterView.setVisibility(VISIBLE); + if (shutterStaysOnRenderedFirstFrame && usingSpeedAdjustedRenderer && muteWhenShutterVisible) { + player.setVolume(0.0f); + } } + } + + private void resetViews() { + showShutterView(); if (subtitleView != null) { subtitleView.setCues(null); } @@ -326,32 +367,33 @@ private void resetViews() { /** * Local listener implementation. */ - private final class ComponentListener implements TextOutput, VideoListener, OnLayoutChangeListener { + private final class ComponentListener implements Player.Listener, OnLayoutChangeListener { @Override - public void onCues(List cues) { - + public void onCues(CueGroup cueGroup) { + lastReportedCues = cueGroup.cues; + List cueList = null; if (subtitleViewPosition != null) { - cues = getModifiedSubtitlePosition(cues, subtitleViewPosition); + cueList = getModifiedSubtitlePosition(lastReportedCues, subtitleViewPosition); } if (subtitleView != null) { - subtitleView.onCues(cues); + subtitleView.setCues((cueList != null && !cueList.isEmpty()) ? cueList : lastReportedCues); } } @Override - public void onVideoSizeChanged(int width, int height, int unappliedRotationDegrees, float pixelWidthHeightRatio) { - if (contentFrame == null) { + public void onVideoSizeChanged(@NonNull VideoSize videoSize) { + if (contentFrame == null || videoSize.equals(VideoSize.UNKNOWN)) { return; } float videoAspectRatio = - (height == 0 || width == 0) ? 1 : (width * pixelWidthHeightRatio) / height; + (videoSize.height == 0 || videoSize.width == 0) ? 1 : (videoSize.width * videoSize.pixelWidthHeightRatio) / videoSize.height; if (videoSurface instanceof TextureView) { // Try to apply rotation transformation when our surface is a TextureView. - if (unappliedRotationDegrees == 90 || unappliedRotationDegrees == 270) { + if (videoSize.unappliedRotationDegrees == 90 || videoSize.unappliedRotationDegrees == 270) { // We will apply a rotation 90/270 degree to the output texture of the TextureView. // In this case, the output video's width and height will be swapped. videoAspectRatio = 1 / videoAspectRatio; @@ -359,7 +401,7 @@ public void onVideoSizeChanged(int width, int height, int unappliedRotationDegre if (textureViewRotation != 0) { videoSurface.removeOnLayoutChangeListener(this); } - textureViewRotation = unappliedRotationDegrees; + textureViewRotation = videoSize.unappliedRotationDegrees; if (textureViewRotation != 0) { // The texture view's dimensions might be changed after layout step. // So add an OnLayoutChangeListener to apply rotation after layout step. @@ -377,7 +419,7 @@ public void onVideoSizeChanged(int width, int height, int unappliedRotationDegre @Override public void onRenderedFirstFrame() { - if (shutterView != null) { + if (shouldHideShutterView()) { shutterView.setVisibility(GONE); } } @@ -426,7 +468,7 @@ private void applyTextureViewRotation(TextureView textureView, int textureViewRo @Override public void setSurfaceAspectRatioResizeMode(PKAspectRatioResizeMode resizeMode) { - this.resizeMode = ExoPlayerView.getExoPlayerAspectRatioResizeMode(resizeMode); + this.resizeMode = PKAspectRatioResizeMode.getExoPlayerAspectRatioResizeMode(resizeMode); if (contentFrame != null) { contentFrame.setResizeMode(this.resizeMode); } @@ -437,31 +479,8 @@ public void setSubtitleViewPosition(PKSubtitlePosition subtitleViewPosition) { this.subtitleViewPosition = subtitleViewPosition; } - public static @AspectRatioFrameLayout.ResizeMode int getExoPlayerAspectRatioResizeMode(PKAspectRatioResizeMode resizeMode) { - @AspectRatioFrameLayout.ResizeMode int exoPlayerAspectRatioResizeMode; - switch(resizeMode) { - case fixedWidth: - exoPlayerAspectRatioResizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH; - break; - case fixedHeight: - exoPlayerAspectRatioResizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT; - break; - case fill: - exoPlayerAspectRatioResizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL; - break; - case zoom: - exoPlayerAspectRatioResizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM; - break; - case fit: - default: - exoPlayerAspectRatioResizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT; - break; - } - return exoPlayerAspectRatioResizeMode; - } - /** - * Creates new cue configuration if `isIgnoreCueSettings` is set to true by application + * Creates new cue configuration if `isOverrideInlineCueConfig` is set to true by application * Checks if the application wants to ignore the in-stream CueSettings otherwise goes with existing Cue configuration * * @param cueList cue list coming in stream @@ -469,24 +488,23 @@ public void setSubtitleViewPosition(PKSubtitlePosition subtitleViewPosition) { * @return List of modified Cues */ public List getModifiedSubtitlePosition(List cueList, PKSubtitlePosition subtitleViewPosition) { - if (cueList != null && !cueList.isEmpty()) { + if (subtitleViewPosition != null && cueList != null && !cueList.isEmpty()) { List newCueList = new ArrayList<>(); for (Cue cue : cueList) { - if ((cue.line != Cue.DIMEN_UNSET || cue.position != Cue.DIMEN_UNSET) - && !subtitleViewPosition.isOverrideInlineCueConfig()) { + if (!subtitleViewPosition.isOverrideInlineCueConfig()) { newCueList.add(cue); continue; } + CharSequence text = cue.text; if (text != null) { - Cue newCue = new Cue.Builder(). - setText(text). - setTextAlignment(subtitleViewPosition.getSubtitleHorizontalPosition()). - setLine(subtitleViewPosition.getVerticalPositionPercentage(), subtitleViewPosition.getLineType()). - setLineAnchor(cue.lineAnchor). - setPosition(cue.position). - setPositionAnchor(cue.positionAnchor). - setSize(subtitleViewPosition.getHorizontalPositionPercentage()).build(); + Cue newCue = new Cue.Builder() + .setText(text) + .setTextAlignment(subtitleViewPosition.getSubtitleHorizontalPosition()) + .setLine(subtitleViewPosition.getVerticalPositionPercentage(), subtitleViewPosition.getLineType()) + .setSize(subtitleViewPosition.getHorizontalPositionPercentage()) + .build(); + newCueList.add(newCue); } } diff --git a/playkit/src/main/java/com/kaltura/playkit/player/ExoPlayerWrapper.java b/playkit/src/main/java/com/kaltura/playkit/player/ExoPlayerWrapper.java index fa6008353..81cb14bf0 100644 --- a/playkit/src/main/java/com/kaltura/playkit/player/ExoPlayerWrapper.java +++ b/playkit/src/main/java/com/kaltura/playkit/player/ExoPlayerWrapper.java @@ -12,76 +12,123 @@ package com.kaltura.playkit.player; +import static com.kaltura.playkit.utils.Consts.MILLISECONDS_MULTIPLIER; +import static com.kaltura.playkit.utils.Consts.TIME_UNSET; +import static com.kaltura.playkit.utils.Consts.TRACK_TYPE_AUDIO; +import static com.kaltura.playkit.utils.Consts.TRACK_TYPE_TEXT; +import static com.kaltura.playkit.utils.Consts.TRACK_TYPE_VIDEO; + import android.content.Context; import android.net.Uri; import android.os.Handler; import android.os.Looper; +import android.text.TextUtils; +import android.util.Pair; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.kaltura.android.exoplayer2.C; -import com.kaltura.android.exoplayer2.DefaultLoadControl; -import com.kaltura.android.exoplayer2.DefaultRenderersFactory; -import com.kaltura.android.exoplayer2.ExoPlaybackException; -import com.kaltura.android.exoplayer2.ExoPlayerLibraryInfo; -import com.kaltura.android.exoplayer2.LoadControl; -import com.kaltura.android.exoplayer2.MediaItem; -import com.kaltura.android.exoplayer2.PlaybackParameters; -import com.kaltura.android.exoplayer2.Player; -import com.kaltura.android.exoplayer2.SimpleExoPlayer; -import com.kaltura.android.exoplayer2.Timeline; -import com.kaltura.android.exoplayer2.audio.AudioAttributes; -import com.kaltura.android.exoplayer2.drm.DrmSessionManager; -import com.kaltura.android.exoplayer2.ext.okhttp.OkHttpDataSourceFactory;; -import com.kaltura.android.exoplayer2.mediacodec.MediaCodecRenderer; -import com.kaltura.android.exoplayer2.mediacodec.MediaCodecUtil; -import com.kaltura.android.exoplayer2.metadata.Metadata; -import com.kaltura.android.exoplayer2.metadata.MetadataOutput; -import com.kaltura.android.exoplayer2.source.BehindLiveWindowException; -import com.kaltura.android.exoplayer2.source.DefaultMediaSourceFactory; -import com.kaltura.android.exoplayer2.source.MediaSourceFactory; -import com.kaltura.android.exoplayer2.source.TrackGroupArray; -import com.kaltura.android.exoplayer2.trackselection.DefaultTrackSelector; -import com.kaltura.android.exoplayer2.trackselection.TrackSelectionArray; -import com.kaltura.android.exoplayer2.ui.SubtitleView; -import com.kaltura.android.exoplayer2.upstream.BandwidthMeter; -import com.kaltura.android.exoplayer2.upstream.DataSource; -import com.kaltura.android.exoplayer2.upstream.DefaultAllocator; -import com.kaltura.android.exoplayer2.upstream.DefaultBandwidthMeter; -import com.kaltura.android.exoplayer2.upstream.DefaultDataSourceFactory; -import com.kaltura.android.exoplayer2.upstream.DefaultHttpDataSource; -import com.kaltura.android.exoplayer2.upstream.DefaultHttpDataSourceFactory; -import com.kaltura.android.exoplayer2.upstream.HttpDataSource; -import com.kaltura.android.exoplayer2.video.CustomLoadControl; - -import com.kaltura.playkit.*; +import com.google.common.base.Charsets; +import com.kaltura.android.exoplayer2.upstream.KBandwidthMeter; +import com.kaltura.android.exoplayer2.upstream.KDefaultBandwidthMeter; +import com.kaltura.androidx.media3.common.C; +import com.kaltura.androidx.media3.common.MediaLibraryInfo; +import com.kaltura.androidx.media3.exoplayer.DefaultRenderersFactory; +import com.kaltura.androidx.media3.exoplayer.ExoPlayer; +import com.kaltura.androidx.media3.common.Format; +import com.kaltura.androidx.media3.exoplayer.LoadControl; +import com.kaltura.androidx.media3.common.MediaItem; +import com.kaltura.androidx.media3.common.PlaybackException; +import com.kaltura.androidx.media3.common.PlaybackParameters; +import com.kaltura.androidx.media3.common.Player; +import com.kaltura.androidx.media3.common.Timeline; +import com.kaltura.androidx.media3.common.Tracks; +import com.kaltura.androidx.media3.common.AudioAttributes; +import com.kaltura.androidx.media3.exoplayer.dashmanifestparser.CustomDashManifest; +import com.kaltura.androidx.media3.exoplayer.dashmanifestparser.CustomDashManifestParser; +import com.kaltura.androidx.media3.exoplayer.drm.DrmSessionManager; +import com.kaltura.androidx.media3.exoplayer.drm.DrmSessionManagerProvider; +import com.kaltura.androidx.media3.datasource.okhttp.OkHttpDataSource; +import com.kaltura.androidx.media3.extractor.ExtractorsFactory; +import com.kaltura.androidx.media3.extractor.ts.DefaultTsPayloadReaderFactory; +import com.kaltura.androidx.media3.extractor.ts.TsExtractor; +import com.kaltura.androidx.media3.common.Metadata; +import com.kaltura.androidx.media3.exoplayer.metadata.MetadataOutput; +import com.kaltura.androidx.media3.exoplayer.source.DefaultMediaSourceFactory; +import com.kaltura.androidx.media3.exoplayer.source.MediaSource; +import com.kaltura.androidx.media3.exoplayer.source.MergingMediaSource; +import com.kaltura.androidx.media3.exoplayer.source.ProgressiveMediaSource; +import com.kaltura.androidx.media3.exoplayer.source.SingleSampleMediaSource; +import com.kaltura.androidx.media3.exoplayer.dash.DashMediaSource; +import com.kaltura.androidx.media3.exoplayer.dash.DefaultDashChunkSource; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.DashManifest; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.DashManifestParserForThumbnail; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.EventStream; +import com.kaltura.androidx.media3.exoplayer.hls.HlsMediaSource; +import com.kaltura.androidx.media3.exoplayer.trackselection.DefaultTrackSelector; +import com.kaltura.androidx.media3.ui.SubtitleView; +import com.kaltura.androidx.media3.exoplayer.upstream.BandwidthMeter; +import com.kaltura.androidx.media3.datasource.ByteArrayDataSink; +import com.kaltura.androidx.media3.datasource.DataSource; +import com.kaltura.androidx.media3.datasource.DataSpec; +import com.kaltura.androidx.media3.exoplayer.upstream.DefaultAllocator; +import com.kaltura.androidx.media3.exoplayer.upstream.DefaultBandwidthMeter; +import com.kaltura.androidx.media3.datasource.DefaultDataSource; +import com.kaltura.androidx.media3.datasource.DefaultHttpDataSource; +import com.kaltura.androidx.media3.datasource.HttpDataSource; +import com.kaltura.androidx.media3.datasource.TeeDataSource; +import com.kaltura.androidx.media3.datasource.TransferListener; +import com.kaltura.androidx.media3.datasource.UdpDataSource; +import com.kaltura.androidx.media3.datasource.cache.Cache; +import com.kaltura.androidx.media3.datasource.cache.CacheDataSource; +import com.kaltura.androidx.media3.common.util.TimestampAdjuster; +import com.kaltura.androidx.media3.exoplayer.video.ConfigurableLoadControl; +import com.kaltura.playkit.KDefaultRenderersFactory; +import com.kaltura.playkit.LocalAssetsManager; +import com.kaltura.playkit.LocalAssetsManagerExo; +import com.kaltura.playkit.PKAbrFilter; +import com.kaltura.playkit.PKDrmParams; +import com.kaltura.playkit.PKError; +import com.kaltura.playkit.PKLog; +import com.kaltura.playkit.PKMediaEntry; +import com.kaltura.playkit.PKMediaFormat; +import com.kaltura.playkit.PKMediaSource; +import com.kaltura.playkit.PKPlaybackException; +import com.kaltura.playkit.PKRequestConfig; +import com.kaltura.playkit.PKRequestParams; +import com.kaltura.playkit.PlaybackInfo; +import com.kaltura.playkit.PlayerEvent; +import com.kaltura.playkit.PlayerState; +import com.kaltura.playkit.SpeedAdjustedRenderersFactory; +import com.kaltura.playkit.Utils; import com.kaltura.playkit.drm.DeferredDrmSessionManager; import com.kaltura.playkit.drm.DrmCallback; import com.kaltura.playkit.player.metadata.MetadataConverter; import com.kaltura.playkit.player.metadata.PKMetadata; +import com.kaltura.playkit.player.thumbnail.ThumbnailInfo; import com.kaltura.playkit.utils.Consts; import com.kaltura.playkit.utils.NativeCookieJarBridge; +import java.io.IOException; import java.net.CookieHandler; import java.net.CookieManager; import java.net.CookiePolicy; - import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.UUID; import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; +import okhttp3.Call; import okhttp3.OkHttpClient; -import static com.kaltura.playkit.utils.Consts.TIME_UNSET; -import static com.kaltura.playkit.utils.Consts.TRACK_TYPE_AUDIO; -import static com.kaltura.playkit.utils.Consts.TRACK_TYPE_TEXT; +public class ExoPlayerWrapper implements PlayerEngine, Player.Listener, MetadataOutput, BandwidthMeter.EventListener, ExoAnalyticsAggregator.InputFormatChangedListener { -public class ExoPlayerWrapper implements PlayerEngine, Player.EventListener, MetadataOutput, BandwidthMeter.EventListener { + private ByteArrayDataSink dashLastDataSink; + private String dashManifestString; public interface LoadControlStrategy { LoadControl getCustomLoadControl(); @@ -97,16 +144,18 @@ public interface LoadControlStrategy { private ExoAnalyticsAggregator analyticsAggregator = new ExoAnalyticsAggregator(); private Context context; - private SimpleExoPlayer player; + private ExoPlayer player; private BaseExoplayerView exoPlayerView; private PlayerView rootView; private boolean rootViewUpdated; private PKTracks tracks; + private List eventStreams; private Timeline.Window window; private TrackSelectionHelper trackSelectionHelper; private DeferredDrmSessionManager drmSessionManager; - private MediaSourceFactory mediaSourceFactory; + private MediaSource.Factory mediaSourceFactory; + private LoadControl configurableLoadControl; private PlayerEvent.Type currentEvent; private PlayerState currentState = PlayerState.IDLE; private PlayerState previousState; @@ -116,6 +165,7 @@ public interface LoadControlStrategy { private boolean isSeeking; private boolean useTextureView; + private boolean useSpeedAdjustingRenderer; private boolean isSurfaceSecured; private boolean shouldGetTracksInfo; private boolean preferredLanguageWasSelected; @@ -130,19 +180,24 @@ public interface LoadControlStrategy { private float lastKnownPlaybackRate = Consts.DEFAULT_PLAYBACK_RATE_SPEED; private List metadataList = new ArrayList<>(); - private String[] lastSelectedTrackIds = {TrackSelectionHelper.NONE, TrackSelectionHelper.NONE, TrackSelectionHelper.NONE}; + private String[] lastSelectedTrackIds = {TrackSelectionHelper.NONE, TrackSelectionHelper.NONE, TrackSelectionHelper.NONE, TrackSelectionHelper.NONE}; + private String[] lastDisabledTrackIds = {TrackSelectionHelper.NONE, TrackSelectionHelper.NONE, TrackSelectionHelper.NONE, TrackSelectionHelper.NONE}; private TrackSelectionHelper.TracksInfoListener tracksInfoListener = initTracksInfoListener(); private TrackSelectionHelper.TracksErrorListener tracksErrorListener = initTracksErrorListener(); - private ExternalTextTrackLoadErrorPolicy externalTextTrackLoadErrorPolicy; + private CustomLoadErrorHandlingPolicy customLoadErrorHandlingPolicy; private DeferredDrmSessionManager.DrmSessionListener drmSessionListener = initDrmSessionListener(); private PKMediaSourceConfig sourceConfig; @NonNull private Profiler profiler = Profiler.NOOP; private Timeline.Period period; + private Cache downloadCache; + ExoPlayerWrapper(Context context, PlayerSettings playerSettings, PlayerView rootPlayerView) { - this(context, new ExoPlayerView(context), playerSettings, rootPlayerView); + this(context, new ExoPlayerView(context, + playerSettings.isShutterStaysOnRenderedFirstFrame(), + playerSettings.isMuteWhenShutterVisible()), playerSettings, rootPlayerView); } ExoPlayerWrapper(Context context, BaseExoplayerView exoPlayerView, PlayerSettings settings, PlayerView rootPlayerView) { @@ -155,7 +210,7 @@ public interface LoadControlStrategy { if (customLoadControlStrategy != null && customLoadControlStrategy.getCustomBandwidthMeter() != null) { bandwidthMeter = customLoadControlStrategy.getCustomBandwidthMeter(); } else { - DefaultBandwidthMeter.Builder bandwidthMeterBuilder = new DefaultBandwidthMeter.Builder(context); + KDefaultBandwidthMeter.Builder bandwidthMeterBuilder = new KDefaultBandwidthMeter.Builder(context); Long initialBitrateEstimate = playerSettings.getAbrSettings().getInitialBitrateEstimate(); @@ -175,7 +230,7 @@ public interface LoadControlStrategy { private LoadControlStrategy getCustomLoadControlStrategy() { Object loadControlStrategyObj = playerSettings.getCustomLoadControlStrategy(); - if (loadControlStrategyObj != null && loadControlStrategyObj instanceof LoadControlStrategy) { + if (loadControlStrategyObj instanceof LoadControlStrategy) { return ((LoadControlStrategy) loadControlStrategyObj); } else { return null; @@ -184,30 +239,47 @@ private LoadControlStrategy getCustomLoadControlStrategy() { @Override public void onBandwidthSample(int elapsedMs, long bytes, long bitrate) { - if (!isPlayerReleased && player != null && trackSelectionHelper != null) { + if (assertPlayerIsNotNull("onBandwidthSample") && !isPlayerReleased && trackSelectionHelper != null) { sendEvent(PlayerEvent.Type.PLAYBACK_INFO_UPDATED); } } private void initializePlayer() { + initializePlayer(false); + } + + private void initializePlayer(boolean skipFirstCodecReusage) { DefaultTrackSelector trackSelector = initializeTrackSelector(); - DefaultRenderersFactory renderersFactory = new DefaultRenderersFactory(context); + if (exoPlayerView instanceof ExoPlayerView) { + ((ExoPlayerView)exoPlayerView).setUsingSpeedAdjustedRenderer(this.useSpeedAdjustingRenderer); + } + DefaultRenderersFactory renderersFactory = this.useSpeedAdjustingRenderer + ? SpeedAdjustedRenderersFactory.createSpeedAdjustedRenderersFactory(context, playerSettings, exoPlayerView) + : KDefaultRenderersFactory.createDecoderInitErrorRetryFactory(context, playerSettings, skipFirstCodecReusage); renderersFactory.setAllowedVideoJoiningTimeMs(playerSettings.getLoadControlBuffers().getAllowedVideoJoiningTimeMs()); renderersFactory.setEnableDecoderFallback(playerSettings.enableDecoderFallback()); + addCustomLoadErrorPolicy(); mediaSourceFactory = new DefaultMediaSourceFactory(getDataSourceFactory(Collections.emptyMap())); - player = new SimpleExoPlayer.Builder(context, renderersFactory) + mediaSourceFactory.setLoadErrorHandlingPolicy(customLoadErrorHandlingPolicy); + configurableLoadControl = getUpdatedLoadControl(); + player = new ExoPlayer.Builder(context, renderersFactory) .setTrackSelector(trackSelector) - .setLoadControl(getUpdatedLoadControl()) + .setLoadControl(configurableLoadControl) .setMediaSourceFactory(mediaSourceFactory) - .setBandwidthMeter(bandwidthMeter).build(); + .setBandwidthMeter(bandwidthMeter) + .setUsePlatformDiagnostics(false) + .build(); + player.setAudioAttributes(AudioAttributes.DEFAULT, /* handleAudioFocus= */ playerSettings.isHandleAudioFocus()); player.setHandleAudioBecomingNoisy(playerSettings.isHandleAudioBecomingNoisyEnabled()); player.setWakeMode(playerSettings.getWakeMode().ordinal()); window = new Timeline.Window(); setPlayerListeners(); - exoPlayerView.setSurfaceAspectRatioResizeMode(playerSettings.getAspectRatioResizeMode()); + if (playerSettings.getAspectRatioResizeMode() != null) { + configureAspectRatioResizeMode(playerSettings.getAspectRatioResizeMode()); + } exoPlayerView.setPlayer(player, useTextureView, isSurfaceSecured, playerSettings.isVideoViewHidden()); player.setPlayWhenReady(false); @@ -221,36 +293,52 @@ private LoadControl getUpdatedLoadControl() { } else { final LoadControlBuffers loadControl = playerSettings.getLoadControlBuffers(); if (!loadControl.isDefaultValuesModified()) { - return new DefaultLoadControl(); + return new ConfigurableLoadControl(); } - return new CustomLoadControl(new DefaultAllocator(/* trimOnReset= */ true, C.DEFAULT_BUFFER_SEGMENT_SIZE), - loadControl.getMaxPlayerBufferMs(), // minBufferVideoMs is set same as the maxBufferMs due to issue in exo player FEM-2707 + return new ConfigurableLoadControl(new DefaultAllocator(/* trimOnReset= */ true, C.DEFAULT_BUFFER_SEGMENT_SIZE), + loadControl.getMinPlayerBufferMs(), loadControl.getMaxPlayerBufferMs(), loadControl.getMinBufferAfterInteractionMs(), loadControl.getMinBufferAfterReBufferMs(), - DefaultLoadControl.DEFAULT_TARGET_BUFFER_BYTES, - DefaultLoadControl.DEFAULT_PRIORITIZE_TIME_OVER_SIZE_THRESHOLDS, + loadControl.getTargetBufferBytes(), + loadControl.getPrioritizeTimeOverSizeThresholds(), loadControl.getBackBufferDurationMs(), loadControl.getRetainBackBufferFromKeyframe()); } } + @Override + public void updateLoadControlBuffers(LoadControlBuffers loadControlBuffers) { + if (configurableLoadControl instanceof ConfigurableLoadControl) { + Handler playerHandler = new Handler(player.getApplicationLooper()); + playerHandler.post(() -> { + ((ConfigurableLoadControl) configurableLoadControl).setMinBufferUs(loadControlBuffers.getMinPlayerBufferMs() * MILLISECONDS_MULTIPLIER); + ((ConfigurableLoadControl) configurableLoadControl).setMaxBufferUs(loadControlBuffers.getMaxPlayerBufferMs() * MILLISECONDS_MULTIPLIER); + ((ConfigurableLoadControl) configurableLoadControl).setBufferForPlaybackUs(loadControlBuffers.getMinBufferAfterInteractionMs()* MILLISECONDS_MULTIPLIER); + ((ConfigurableLoadControl) configurableLoadControl).setBufferForPlaybackAfterRebufferUs(loadControlBuffers.getMinBufferAfterReBufferMs() * MILLISECONDS_MULTIPLIER); + ((ConfigurableLoadControl) configurableLoadControl).setBackBufferDurationUs(loadControlBuffers.getBackBufferDurationMs() * MILLISECONDS_MULTIPLIER); + ((ConfigurableLoadControl) configurableLoadControl).setRetainBackBufferFromKeyframe(loadControlBuffers.getRetainBackBufferFromKeyframe()); + ((ConfigurableLoadControl) configurableLoadControl).setTargetBufferBytes(loadControlBuffers.getTargetBufferBytes()); + ((ConfigurableLoadControl) configurableLoadControl).setPrioritizeTimeOverSizeThresholds(loadControlBuffers.getPrioritizeTimeOverSizeThresholds()); + }); + } + } + private void setPlayerListeners() { log.v("setPlayerListeners"); if (assertPlayerIsNotNull("setPlayerListeners()")) { player.addListener(this); - player.addMetadataOutput(this); // PlaybackStatsListener playbackStatsListener = new PlaybackStatsListener(true, new PlaybackStatsListener.Callback() { // @Override -// public void onPlaybackStatsReady(com.kaltura.android.exoplayer2.analytics.AnalyticsListener.EventTime eventTime, PlaybackStats playbackStats) { +// public void onPlaybackStatsReady(com.kaltura.androidx.media3.exoplayer.analytics.AnalyticsListener.EventTime eventTime, PlaybackStats playbackStats) { // log.d("PlaybackStatsListener playbackCount = " + playbackStats.playbackCount); // } // }); // player.addAnalyticsListener(playbackStatsListener); player.addAnalyticsListener(analyticsAggregator); - final com.kaltura.android.exoplayer2.analytics.AnalyticsListener exoAnalyticsListener = profiler.getExoAnalyticsListener(); + final com.kaltura.androidx.media3.exoplayer.analytics.AnalyticsListener exoAnalyticsListener = profiler.getExoAnalyticsListener(); if (exoAnalyticsListener != null) { player.addAnalyticsListener(exoAnalyticsListener); } @@ -260,8 +348,8 @@ private void setPlayerListeners() { private DefaultTrackSelector initializeTrackSelector() { DefaultTrackSelector trackSelector = new DefaultTrackSelector(context); - DefaultTrackSelector.ParametersBuilder parametersBuilder = new DefaultTrackSelector.ParametersBuilder(context); - trackSelectionHelper = new TrackSelectionHelper(context, trackSelector, lastSelectedTrackIds); + DefaultTrackSelector.Parameters.Builder parametersBuilder = new DefaultTrackSelector.Parameters.Builder(context); + trackSelectionHelper = new TrackSelectionHelper(context, trackSelector, lastSelectedTrackIds, lastDisabledTrackIds); trackSelectionHelper.updateTrackSelectorParameter(playerSettings, parametersBuilder); trackSelector.setParameters(parametersBuilder.build()); @@ -277,13 +365,24 @@ private void preparePlayer(@NonNull PKMediaSourceConfig sourceConfig) { metadataList.clear(); isLoadedMetaDataFired = false; shouldGetTracksInfo = true; + // Need to clear the overrides of DefaultTrackSelector before loading the next media. + trackSelectionHelper.clearPreviousMediaOverrides(); trackSelectionHelper.applyPlayerSettings(playerSettings); + MediaSource mediaSource = null; MediaItem mediaItem = buildExoMediaItem(sourceConfig); + if (mediaItem != null && !isLocalMediaItem(sourceConfig) && !isLocalMediaSource(sourceConfig)) { + mediaSource = buildInternalExoMediaSource(mediaItem, sourceConfig); + } if (mediaItem != null) { profiler.onPrepareStarted(sourceConfig); - player.setMediaItems(Collections.singletonList(mediaItem), 0, playerPosition == TIME_UNSET ? 0 : playerPosition); + if (mediaSource == null) { + player.setMediaItems(Collections.singletonList(mediaItem), 0, playerPosition == TIME_UNSET ? 0 : playerPosition); + } else { + player.setMediaSources(Collections.singletonList(mediaSource), 0, playerPosition == TIME_UNSET ? 0 : playerPosition); + } + player.prepare(); changeState(PlayerState.LOADING); @@ -296,6 +395,14 @@ private void preparePlayer(@NonNull PKMediaSourceConfig sourceConfig) { } } + private boolean isLocalMediaSource(@NonNull PKMediaSourceConfig sourceConfig) { + return sourceConfig.mediaSource instanceof LocalAssetsManager.LocalMediaSource; + } + + private boolean isLocalMediaItem(@NonNull PKMediaSourceConfig sourceConfig) { + return sourceConfig.mediaSource instanceof LocalAssetsManagerExo.LocalExoMediaItem; + } + private void sendPrepareSourceError(@NonNull PKMediaSourceConfig sourceConfig) { String errorMessage = "Media Error"; if (sourceConfig == null) { @@ -312,6 +419,10 @@ private void sendPrepareSourceError(@NonNull PKMediaSourceConfig sourceConfig) { private MediaItem buildExoMediaItem(PKMediaSourceConfig sourceConfig) { List externalSubtitleList = null; + if (sourceConfig == null || sourceConfig.mediaSource == null) { + return null; + } + if (sourceConfig.getExternalSubtitleList() != null) { externalSubtitleList = sourceConfig.getExternalSubtitleList().size() > 0 ? sourceConfig.getExternalSubtitleList() : null; @@ -321,100 +432,345 @@ private MediaItem buildExoMediaItem(PKMediaSourceConfig sourceConfig) { if (assertTrackSelectionIsNotNull("buildExoMediaItem")) { trackSelectionHelper.hasExternalSubtitles(false); } - removeExternalTextTrackListener(); } else { if (assertTrackSelectionIsNotNull("buildExoMediaItem")) { trackSelectionHelper.hasExternalSubtitles(true); } - addExternalTextTrackErrorListener(); + } + + if (isLocalMediaSource(sourceConfig) && sourceConfig.mediaSource.hasDrmParams()) { + drmSessionManager = getDeferredDRMSessionManager(); + drmSessionManager.setMediaSource(sourceConfig.mediaSource); } MediaItem mediaItem; - if (sourceConfig.mediaSource instanceof LocalAssetsManagerExo.LocalExoMediaItem) { - final LocalAssetsManagerExo.LocalExoMediaItem pkMediaSource = (LocalAssetsManagerExo.LocalExoMediaItem) sourceConfig.mediaSource; + if (isLocalMediaItem(sourceConfig)) { + LocalAssetsManagerExo.LocalExoMediaItem pkMediaSource = (LocalAssetsManagerExo.LocalExoMediaItem) sourceConfig.mediaSource; mediaItem = pkMediaSource.getExoMediaItem(); } else { - if (sourceConfig.mediaSource instanceof LocalAssetsManager.LocalMediaSource && sourceConfig.mediaSource.hasDrmParams()) { - final DrmCallback drmCallback = new DrmCallback(getHttpDataSourceFactory(null), playerSettings.getLicenseRequestAdapter()); - drmSessionManager = new DeferredDrmSessionManager(mainHandler, drmCallback, drmSessionListener,playerSettings.allowClearLead()); - drmSessionManager.setMediaSource(sourceConfig.mediaSource); - } mediaItem = buildInternalExoMediaItem(sourceConfig, externalSubtitleList); } - mediaSourceFactory.setDrmSessionManager(sourceConfig.mediaSource.hasDrmParams() ? drmSessionManager : DrmSessionManager.getDummyDrmSessionManager()); + if (mediaItem == null) { + return mediaItem; + } + + if (mediaItem.localConfiguration != null) { + MediaItem.DrmConfiguration drmConfiguration = mediaItem.localConfiguration.drmConfiguration; + if (!(sourceConfig.mediaSource instanceof LocalAssetsManager.LocalMediaSource) && + drmConfiguration != null && + drmConfiguration.licenseUri != null && + !TextUtils.isEmpty(drmConfiguration.licenseUri.toString())) { + if (playerSettings.getDRMSettings().getDrmScheme() != PKDrmParams.Scheme.PlayReadyCENC) { + drmSessionManager = getDeferredDRMSessionManager(); + drmSessionManager.setLicenseUrl(drmConfiguration.licenseUri.toString()); + } + } + } + + if (drmSessionManager != null) { + mediaSourceFactory.setDrmSessionManagerProvider(getDrmSessionManagerProvider(sourceConfig.mediaSource)); + } return mediaItem; } - private void removeExternalTextTrackListener() { - if (externalTextTrackLoadErrorPolicy != null) { - externalTextTrackLoadErrorPolicy.setOnTextTrackErrorListener(null); - externalTextTrackLoadErrorPolicy = null; - } + private DeferredDrmSessionManager getDeferredDRMSessionManager() { + final DrmCallback drmCallback = new DrmCallback(getHttpDataSourceFactory(null), playerSettings.getLicenseRequestAdapter()); + return new DeferredDrmSessionManager(mainHandler, drmCallback, drmSessionListener, playerSettings.allowClearLead(), playerSettings.isForceWidevineL3Playback()); } - private void addExternalTextTrackErrorListener() { - if (externalTextTrackLoadErrorPolicy == null) { - externalTextTrackLoadErrorPolicy = new ExternalTextTrackLoadErrorPolicy(); - externalTextTrackLoadErrorPolicy.setOnTextTrackErrorListener(err -> { - currentError = err; - if (eventListener != null) { - log.e("Error-Event sent, type = " + currentError.errorType); - eventListener.onEvent(PlayerEvent.Type.ERROR); + private DrmSessionManagerProvider getDrmSessionManagerProvider(PKMediaSource pkMediaSource) { + return drmMediaItem -> { + if (pkMediaSource.hasDrmParams()) { + return drmSessionManager; + } else { + return DrmSessionManager.DRM_UNSUPPORTED; + } + }; + } + + private MediaSource buildInternalExoMediaSource(MediaItem mediaItem, PKMediaSourceConfig sourceConfig) { + List externalSubtitleList = null; + + if (sourceConfig.getExternalSubtitleList() != null) { + externalSubtitleList = sourceConfig.getExternalSubtitleList().size() > 0 ? + sourceConfig.getExternalSubtitleList() : null; + } + + PKMediaFormat format = sourceConfig.mediaSource.getMediaFormat(); + + if (format == null) { + return null; + } + + PKRequestParams requestParams = sourceConfig.getRequestParams(); + final DataSource.Factory dataSourceFactory = getDataSourceFactory(requestParams.headers); + MediaSource mediaSource; + switch (format) { + case dash: + + final DataSource.Factory teedDtaSourceFactory = () -> { + dashManifestString = null; + dashLastDataSink = new ByteArrayDataSink(); + TeeDataSource teeDataSource = new TeeDataSource(dataSourceFactory.createDataSource(), dashLastDataSink); + teeDataSource.addTransferListener(new TransferListener() { + @Override + public void onTransferInitializing(@NonNull DataSource dataSource, @NonNull DataSpec dataSpec, boolean b) { + + } + + @Override + public void onTransferStart(@NonNull DataSource dataSource, @NonNull DataSpec dataSpec, boolean b) { + + } + + @Override + public void onBytesTransferred(@NonNull DataSource dataSource, @NonNull DataSpec dataSpec, boolean b, int i) { + + } + + @Override + public void onTransferEnd(@NonNull DataSource dataSource, @NonNull DataSpec dataSpec, boolean b) { + log.d("teeDataSource onTransferEnd"); + if (dashManifestString != null) { + return; + } + if (dashLastDataSink == null) { + return; + } + + byte[] bytes = dashLastDataSink.getData(); + if (bytes == null) { + return; + } + + dashManifestString = new String(bytes, Charsets.UTF_8); + //log.d("teeDataSource manifest " + dashManifestString); + } + }); + return teeDataSource; + }; + + DashMediaSource.Factory dashMediaSourceFactory = new DashMediaSource.Factory( + new DefaultDashChunkSource.Factory(dataSourceFactory), teedDtaSourceFactory) + .setManifestParser(new DashManifestParserForThumbnail()) + .setLoadErrorHandlingPolicy(customLoadErrorHandlingPolicy); + + if (playerSettings.getDRMSettings().getDrmScheme() != PKDrmParams.Scheme.PlayReadyCENC) { + dashMediaSourceFactory.setDrmSessionManagerProvider(getDrmSessionManagerProvider(sourceConfig.mediaSource)); } - }); + mediaSource = dashMediaSourceFactory.createMediaSource(mediaItem); + break; + + case hls: + mediaSource = new HlsMediaSource.Factory(dataSourceFactory) + .setDrmSessionManagerProvider(getDrmSessionManagerProvider(sourceConfig.mediaSource)) + .setLoadErrorHandlingPolicy(customLoadErrorHandlingPolicy) + .setAllowChunklessPreparation(playerSettings.isAllowChunklessPreparation()) + .createMediaSource(mediaItem); + break; + + // mp4 and mp3 both use ExtractorMediaSource + case mp4: + case mp3: + mediaSource = new ProgressiveMediaSource.Factory(dataSourceFactory) + .setLoadErrorHandlingPolicy(customLoadErrorHandlingPolicy) + .createMediaSource(mediaItem); + break; + + case udp: + MulticastSettings multicastSettings = (playerSettings != null) ? playerSettings.getMulticastSettings() : new MulticastSettings(); + if (multicastSettings.getUseExoDefaultSettings()) { + mediaSource = new ProgressiveMediaSource.Factory(dataSourceFactory) + .setLoadErrorHandlingPolicy(customLoadErrorHandlingPolicy) + .createMediaSource(mediaItem); + } else { + DataSource.Factory udpDatasourceFactory = () -> new UdpDataSource(multicastSettings.getMaxPacketSize(), multicastSettings.getSocketTimeoutMillis()); + ExtractorsFactory tsExtractorFactory = () -> new TsExtractor[]{ + new TsExtractor(multicastSettings.getExtractorMode().mode, + new TimestampAdjuster(multicastSettings.getFirstSampleTimestampUs()), new DefaultTsPayloadReaderFactory()) + }; + mediaSource = new ProgressiveMediaSource.Factory(udpDatasourceFactory, tsExtractorFactory) + .setLoadErrorHandlingPolicy(customLoadErrorHandlingPolicy) + .createMediaSource(mediaItem); + } + break; + + default: + throw new IllegalArgumentException("Unknown media format: " + format + " for url: " + requestParams.url); + } + + if (externalSubtitleList == null || externalSubtitleList.isEmpty()) { + return mediaSource; + } else { + addExternalTextTrackErrorListener(); + return new MergingMediaSource(buildMediaSourceList(mediaSource, externalSubtitleList)); } } + /** + * Return the media source with external subtitles if exists + * @param externalSubtitleList External subtitle List + * @return Media Source array + */ + + private MediaSource[] buildMediaSourceList(MediaSource mediaSource, List externalSubtitleList) { + List streamMediaSources = new ArrayList<>(); + List mediaItemSubtitles = buildSubtitlesList(externalSubtitleList); + if (externalSubtitleList != null && externalSubtitleList.size() > 0) { + for (int subtitlePosition = 0 ; subtitlePosition < externalSubtitleList.size() ; subtitlePosition ++) { + MediaSource subtitleMediaSource = buildExternalSubtitleSource(mediaItemSubtitles.get(subtitlePosition)); + streamMediaSources.add(subtitleMediaSource); + } + } + // 0th position is secured for dash/hls/extractor media source + streamMediaSources.add(0, mediaSource); + return streamMediaSources.toArray(new MediaSource[0]); + } + + /** + * Create single Media Source object with each subtitle + * @param mediaItemSubtitle External subtitle object + * @return An object of external subtitle media source + */ + + @NonNull + private MediaSource buildExternalSubtitleSource(MediaItem.SubtitleConfiguration mediaItemSubtitle) { + return new SingleSampleMediaSource.Factory(getDataSourceFactory(null)) + .setLoadErrorHandlingPolicy(customLoadErrorHandlingPolicy) + .setTreatLoadErrorsAsEndOfStream(true) + .createMediaSource(mediaItemSubtitle, C.TIME_UNSET); + } + + private void removeCustomLoadErrorPolicy() { + if (customLoadErrorHandlingPolicy != null) { + customLoadErrorHandlingPolicy.setOnTextTrackErrorListener(null); + customLoadErrorHandlingPolicy = null; + } + } + + private void addCustomLoadErrorPolicy() { + if (customLoadErrorHandlingPolicy == null) { + customLoadErrorHandlingPolicy = new CustomLoadErrorHandlingPolicy(playerSettings.getPKRequestConfig().getMaxRetries()); + } + } + + private void addExternalTextTrackErrorListener() { + addCustomLoadErrorPolicy(); + customLoadErrorHandlingPolicy.setOnTextTrackErrorListener(err -> { + currentError = err; + if (eventListener != null) { + log.e("Error-Event sent, type = " + currentError.errorType); + eventListener.onEvent(PlayerEvent.Type.ERROR); + } + }); + } + private MediaItem buildInternalExoMediaItem(PKMediaSourceConfig sourceConfig, List externalSubtitleList) { PKMediaFormat format = sourceConfig.mediaSource.getMediaFormat(); PKRequestParams requestParams = sourceConfig.getRequestParams(); - if (format == null) { - throw new IllegalArgumentException("Unknown media format: " + format + " for url: " + requestParams.url); + if (format == null || TextUtils.isEmpty(requestParams.url.toString())) { + return null; // No MediaItem will be created and returning null will send SOURCE_ERROR with Fatal severity } + MediaItem.ClippingConfiguration clippingConfiguration = new MediaItem.ClippingConfiguration + .Builder() + .setStartPositionMs(0L) + .setEndPositionMs(C.TIME_END_OF_SOURCE) + .build(); + Uri uri = requestParams.url; MediaItem.Builder builder = new MediaItem.Builder() .setUri(uri) .setMimeType(format.mimeType) - .setSubtitles(buildSubtitlesList(externalSubtitleList)) - .setClipStartPositionMs(0L) - .setClipEndPositionMs(C.TIME_END_OF_SOURCE); + .setSubtitleConfigurations(buildSubtitlesList(externalSubtitleList)) + .setClippingConfiguration(clippingConfiguration); + + if (playerSettings.getPKLowLatencyConfig() != null && (isLiveMediaWithDvr() || isLiveMediaWithoutDvr())) { + MediaItem.LiveConfiguration lowLatencyConfiguration = getLowLatencyConfigFromPlayerSettings(); + builder.setLiveConfiguration(lowLatencyConfiguration); + } - if (format == PKMediaFormat.dash && sourceConfig.mediaSource.hasDrmParams()) { + if (format == PKMediaFormat.dash || (format == PKMediaFormat.hls && sourceConfig.mediaSource.hasDrmParams())) { setMediaItemBuilderDRMParams(sourceConfig, builder); - } else if (format == PKMediaFormat.udp) { + } else if (Utils.isMulticastMedia(format)) { builder.setMimeType(null); } return builder.build(); } + @NonNull + private MediaItem.LiveConfiguration getLowLatencyConfigFromPlayerSettings() { + MediaItem.LiveConfiguration.Builder liveConfigurationBuilder = new MediaItem.LiveConfiguration.Builder(); + + if (playerSettings.getPKLowLatencyConfig().getTargetOffsetMs() > 0) { + liveConfigurationBuilder.setTargetOffsetMs(playerSettings.getPKLowLatencyConfig().getTargetOffsetMs()); + } + if (playerSettings.getPKLowLatencyConfig().getMinOffsetMs() > 0) { + liveConfigurationBuilder.setMinOffsetMs(playerSettings.getPKLowLatencyConfig().getMinOffsetMs()); + } + if (playerSettings.getPKLowLatencyConfig().getMaxOffsetMs() > 0) { + liveConfigurationBuilder.setMaxOffsetMs(playerSettings.getPKLowLatencyConfig().getMaxOffsetMs()); + } + if (playerSettings.getPKLowLatencyConfig().getMinPlaybackSpeed() > 0) { + liveConfigurationBuilder.setMinPlaybackSpeed(playerSettings.getPKLowLatencyConfig().getMinPlaybackSpeed()); + } + if (playerSettings.getPKLowLatencyConfig().getMaxPlaybackSpeed() > 0) { + liveConfigurationBuilder.setMaxPlaybackSpeed(playerSettings.getPKLowLatencyConfig().getMaxPlaybackSpeed()); + } + + return liveConfigurationBuilder.build(); + } + private void setMediaItemBuilderDRMParams(PKMediaSourceConfig sourceConfig, MediaItem.Builder builder) { - // selecting WidevineCENC as default right now - PKDrmParams.Scheme scheme = PKDrmParams.Scheme.WidevineCENC; + PKDrmParams.Scheme scheme = playerSettings.getDRMSettings().getDrmScheme(); + log.d("PKDrmParams.Scheme = " + scheme); + String licenseUri = getDrmLicenseUrl(sourceConfig.mediaSource, scheme); + UUID uuid = (scheme == PKDrmParams.Scheme.WidevineCENC) ? MediaSupport.WIDEVINE_UUID : MediaSupport.PLAYREADY_UUID; + boolean isForceDefaultLicenseUri = playerSettings.getDRMSettings().getIsForceDefaultLicenseUri(); - if (licenseUri != null) { - PKRequestParams licenseRequestParams = new PKRequestParams(Uri.parse(licenseUri), new HashMap<>()); + if (!TextUtils.isEmpty(licenseUri) || + (uuid == MediaSupport.PLAYREADY_UUID && TextUtils.isEmpty(licenseUri) && !isForceDefaultLicenseUri)) { - if (playerSettings.getLicenseRequestAdapter() != null) { - licenseRequestParams = playerSettings.getLicenseRequestAdapter().adapt(licenseRequestParams); + MediaItem.DrmConfiguration.Builder drmConfigurationBuilder = new MediaItem.DrmConfiguration + .Builder(uuid) + .setLicenseUri(licenseUri) + .setMultiSession(playerSettings.getDRMSettings().getIsMultiSession()) + .setForceDefaultLicenseUri(isForceDefaultLicenseUri); + + Map licenseRequestParamsHeaders = getLicenseRequestParamsHeaders(licenseUri); + if (licenseRequestParamsHeaders != null) { + drmConfigurationBuilder.setLicenseRequestHeaders(licenseRequestParamsHeaders); } - Map licenseRequestParamsHeaders = licenseRequestParams.headers; + builder.setDrmConfiguration(drmConfigurationBuilder.build()); + } + } - builder - .setDrmUuid((scheme == PKDrmParams.Scheme.WidevineCENC) ? MediaSupport.WIDEVINE_UUID : MediaSupport.PLAYREADY_UUID) - .setDrmLicenseUri(licenseUri) - .setDrmMultiSession(false) - .setDrmForceDefaultLicenseUri(false) - .setDrmLicenseRequestHeaders(licenseRequestParamsHeaders); + /** + * Get the license request headers from the Adapter + * + * @param licenseUri license URI for the media + * @return return the license request header's map + */ + @Nullable + private Map getLicenseRequestParamsHeaders(String licenseUri) { + Map licenseRequestParamsHeaders = null; + if (!TextUtils.isEmpty(licenseUri)) { + PKRequestParams licenseRequestParams = new PKRequestParams(Uri.parse(licenseUri), new HashMap<>()); + if (playerSettings.getLicenseRequestAdapter() != null) { + licenseRequestParams = playerSettings.getLicenseRequestAdapter().adapt(licenseRequestParams); + } + licenseRequestParamsHeaders = licenseRequestParams.headers; } + return licenseRequestParamsHeaders; } + @Nullable private String getDrmLicenseUrl(PKMediaSource mediaSource, PKDrmParams.Scheme scheme) { String licenseUrl = null; @@ -430,14 +786,26 @@ private String getDrmLicenseUrl(PKMediaSource mediaSource, PKDrmParams.Scheme sc return licenseUrl; } - private List buildSubtitlesList(List externalSubtitleList) { - List subtitleList = new ArrayList<>(); + private List buildSubtitlesList(List externalSubtitleList) { + List subtitleList = new ArrayList<>(); if (externalSubtitleList != null && externalSubtitleList.size() > 0) { for (int subtitlePosition = 0 ; subtitlePosition < externalSubtitleList.size() ; subtitlePosition ++) { PKExternalSubtitle pkExternalSubtitle = externalSubtitleList.get(subtitlePosition); String subtitleMimeType = pkExternalSubtitle.getMimeType() == null ? "Unknown" : pkExternalSubtitle.getMimeType(); - MediaItem.Subtitle subtitleMediaItem = new MediaItem.Subtitle(Uri.parse(pkExternalSubtitle.getUrl()), subtitleMimeType, pkExternalSubtitle.getLanguage() + "-" + subtitleMimeType, pkExternalSubtitle.getSelectionFlags(), pkExternalSubtitle.getRoleFlag(), pkExternalSubtitle.getLabel()); + + MediaItem.SubtitleConfiguration.Builder builder = new MediaItem.SubtitleConfiguration.Builder(Uri.parse(pkExternalSubtitle.getUrl())); + builder.setMimeType(subtitleMimeType); + + // "-" is important to be added between lang and mimetype + // This is how we understand that this is external subtitle in TrackSelectionHelper + builder.setLanguage(pkExternalSubtitle.getLanguage() + "-" + subtitleMimeType); + + builder.setSelectionFlags(pkExternalSubtitle.getSelectionFlags()); + builder.setRoleFlags(pkExternalSubtitle.getRoleFlag()); + builder.setLabel(pkExternalSubtitle.getLabel()); + + MediaItem.SubtitleConfiguration subtitleMediaItem = builder.build(); subtitleList.add(subtitleMediaItem); } } @@ -447,7 +815,11 @@ private List buildSubtitlesList(List ext private HttpDataSource.Factory getHttpDataSourceFactory(Map headers) { HttpDataSource.Factory httpDataSourceFactory; final String userAgent = getUserAgent(context); - final boolean crossProtocolRedirectEnabled = playerSettings.crossProtocolRedirectEnabled(); + + final PKRequestConfig pkRequestConfig = playerSettings.getPKRequestConfig(); + final int connectTimeout = pkRequestConfig.getConnectTimeoutMs(); + final int readTimeout = pkRequestConfig.getReadTimeoutMs(); + final boolean crossProtocolRedirectEnabled = pkRequestConfig.getCrossProtocolRedirectEnabled(); if (CookieHandler.getDefault() == null) { CookieHandler.setDefault(new CookieManager(null, CookiePolicy.ACCEPT_ORIGINAL_SERVER)); @@ -459,8 +831,8 @@ private HttpDataSource.Factory getHttpDataSourceFactory(Map head .cookieJar(NativeCookieJarBridge.sharedCookieJar) .followRedirects(true) .followSslRedirects(crossProtocolRedirectEnabled) - .connectTimeout(DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS) - .readTimeout(DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS); + .connectTimeout(connectTimeout, TimeUnit.MILLISECONDS) + .readTimeout(readTimeout, TimeUnit.MILLISECONDS); builder.eventListener(analyticsAggregator); if (profiler != Profiler.NOOP) { final okhttp3.EventListener.Factory okListenerFactory = profiler.getOkListenerFactory(); @@ -468,30 +840,32 @@ private HttpDataSource.Factory getHttpDataSourceFactory(Map head builder.eventListenerFactory(okListenerFactory); } } - httpDataSourceFactory = new OkHttpDataSourceFactory(builder.build(), userAgent); + httpDataSourceFactory = new OkHttpDataSource.Factory((Call.Factory) builder.build()).setUserAgent(userAgent); } else { - httpDataSourceFactory = new DefaultHttpDataSourceFactory(userAgent, - DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS, - DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS, - crossProtocolRedirectEnabled); + httpDataSourceFactory = new DefaultHttpDataSource.Factory().setUserAgent(userAgent) + .setConnectTimeoutMs(connectTimeout) + .setReadTimeoutMs(readTimeout) + .setAllowCrossProtocolRedirects(crossProtocolRedirectEnabled); } - if (headers != null) { - HttpDataSource.RequestProperties defaultRequestProperties = httpDataSourceFactory.getDefaultRequestProperties(); - for (Map.Entry headerEntry : headers.entrySet()) { - defaultRequestProperties.set(headerEntry.getKey(), headerEntry.getValue()); - } + if (headers != null && !headers.isEmpty()) { + httpDataSourceFactory.setDefaultRequestProperties(headers); } return httpDataSourceFactory; } private DataSource.Factory getDataSourceFactory(Map headers) { - return new DefaultDataSourceFactory(context, getHttpDataSourceFactory(headers)); + DataSource.Factory httpDataSourceFactory = new DefaultDataSource.Factory(context, getHttpDataSourceFactory(headers)); + if (downloadCache != null) { + return buildReadOnlyCacheDataSource(httpDataSourceFactory, downloadCache); + } else { + return httpDataSourceFactory; + } } private static String getUserAgent(Context context) { - return Utils.getUserAgent(context) + " ExoPlayerLib/" + ExoPlayerLibraryInfo.VERSION; + return Utils.getUserAgent(context) + " " + MediaLibraryInfo.VERSION_SLASHY; } private void changeState(PlayerState newState) { @@ -505,6 +879,15 @@ private void changeState(PlayerState newState) { } } + private CacheDataSource.Factory buildReadOnlyCacheDataSource( + DataSource.Factory upstreamFactory, Cache cache) { + return new CacheDataSource.Factory() + .setCache(cache) + .setUpstreamDataSourceFactory(upstreamFactory) + .setCacheWriteDataSinkFactory(null) + .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR); + } + private void sendDistinctEvent(PlayerEvent.Type newEvent) { if (newEvent.equals(currentEvent)) { return; @@ -607,32 +990,49 @@ public void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) { } @Override - public void onTimelineChanged(Timeline timeline, int reason) { + public void onTimelineChanged(@NonNull Timeline timeline, int reason) { log.d("onTimelineChanged reason = " + reason + " duration = " + getDuration()); if (reason == Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) { isLoadedMetaDataFired = false; - if (getDuration() != TIME_UNSET) { + if (getDuration() != TIME_UNSET || Utils.isMulticastMedia(sourceConfig.mediaSource.getMediaFormat())) { sendDistinctEvent(PlayerEvent.Type.DURATION_CHANGE); profiler.onDurationChanged(getDuration()); } } - if (reason == Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE && getDuration() != TIME_UNSET) { - if (!isLoadedMetaDataFired) { - sendDistinctEvent(PlayerEvent.Type.LOADED_METADATA); + if (reason == Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) { + if (getDuration() != TIME_UNSET) { + if (!isLoadedMetaDataFired) { + sendDistinctEvent(PlayerEvent.Type.LOADED_METADATA); + } + sendDistinctEvent(PlayerEvent.Type.DURATION_CHANGE); + } + + if (player.getCurrentManifest() instanceof DashManifest) { + if (((DashManifest) player.getCurrentManifest()).getPeriodCount() > 0) { + List eventStreamList = ((DashManifest) player.getCurrentManifest()).getPeriod(0).eventStreams; + if (!eventStreamList.isEmpty()) { + eventStreams = eventStreamList; + sendDistinctEvent(PlayerEvent.Type.EVENT_STREAM_CHANGED); + } + } } - sendDistinctEvent(PlayerEvent.Type.DURATION_CHANGE); } } @Override - public void onPlayerError(ExoPlaybackException error) { - log.d("onPlayerError error type => " + error.type); - if (isBehindLiveWindow(error) && sourceConfig != null) { + public void onPlayerError(PlaybackException playbackException) { + log.d("onPlayerError error type => " + playbackException.errorCode); + if (isBehindLiveWindow(playbackException) && sourceConfig != null) { log.d("onPlayerError BehindLiveWindowException received, re-preparing player"); MediaItem mediaItem = buildExoMediaItem(sourceConfig); if (mediaItem != null) { - player.setMediaItems(Collections.singletonList(mediaItem), 0, C.TIME_UNSET); + PKMediaFormat format = sourceConfig.mediaSource.getMediaFormat(); + if (format == null) { + player.setMediaItems(Collections.singletonList(mediaItem), 0, C.TIME_UNSET); + } else { + player.setMediaSources(Collections.singletonList(buildInternalExoMediaSource(mediaItem, sourceConfig)), 0, playerPosition == TIME_UNSET ? 0 : playerPosition); + } player.prepare(); } else { sendPrepareSourceError(sourceConfig); @@ -640,129 +1040,79 @@ public void onPlayerError(ExoPlaybackException error) { return; } - Enum errorType; - String errorMessage = error.getMessage(); - - switch (error.type) { - case ExoPlaybackException.TYPE_SOURCE: - errorType = PKPlayerErrorType.SOURCE_ERROR; - errorMessage = getSourceErrorMessage(error, errorMessage); - break; - case ExoPlaybackException.TYPE_RENDERER: - errorType = PKPlayerErrorType.RENDERER_ERROR; - errorMessage = getDecoderInitializationErrorMessage(error, errorMessage); - break; - case ExoPlaybackException.TYPE_OUT_OF_MEMORY: - errorType = PKPlayerErrorType.OUT_OF_MEMORY; - errorMessage = getOutOfMemoryErrorMessage(error, errorMessage); - break; - case ExoPlaybackException.TYPE_TIMEOUT: - errorType = PKPlayerErrorType.TIMEOUT; - errorMessage = getTimeoutErrorMessage(error, errorMessage); - break; - case ExoPlaybackException.TYPE_REMOTE: - errorType = PKPlayerErrorType.REMOTE_COMPONENT_ERROR; - break; - case ExoPlaybackException.TYPE_UNEXPECTED: - default: - errorType = PKPlayerErrorType.UNEXPECTED; - errorMessage = getUnexpectedErrorMessage(error, errorMessage); - break; + // Fire the more accurate error codes coming under PlaybackException from ExoPlayer + Pair exceptionPair = PKPlaybackException.getPlaybackExceptionType(playbackException); + if (exceptionPair.first == PKPlayerErrorType.TIMEOUT && exceptionPair.second.contains(Consts.EXO_TIMEOUT_OPERATION_RELEASE)) { + // ExoPlayer is being stopped internally in other EXO_TIMEOUT_EXCEPTION types + currentError = new PKError(PKPlayerErrorType.TIMEOUT, PKError.Severity.Recoverable, exceptionPair.second, playbackException); + } else { + currentError = new PKError(exceptionPair.first, exceptionPair.second, playbackException); } + log.e("ExoPlaybackException, type = " + exceptionPair.first); - String errorStr = (errorMessage == null) ? "Player error: " + errorType.name() : errorMessage; - - log.e(errorStr); - currentError = new PKError(errorType, errorStr, error); if (eventListener != null) { - log.e("Error-Event sent, type = " + error.type); + log.e("Error-Event Sent"); eventListener.onEvent(PlayerEvent.Type.ERROR); } else { - log.e("eventListener is null cannot send Error-Event type = " + error.type); - } - } - - private String getDecoderInitializationErrorMessage(ExoPlaybackException error, String errorMessage) { - Exception cause = error.getRendererException(); - if (cause instanceof MediaCodecRenderer.DecoderInitializationException) { - // Special case for decoder initialization failures. - MediaCodecRenderer.DecoderInitializationException decoderInitializationException = - (MediaCodecRenderer.DecoderInitializationException) cause; - if (decoderInitializationException.codecInfo == null) { - if (decoderInitializationException.getCause() instanceof MediaCodecUtil.DecoderQueryException) { - errorMessage = "Unable to query device decoders"; - } else if (decoderInitializationException.secureDecoderRequired) { - errorMessage = "This device does not provide a secure decoder for " + decoderInitializationException.mimeType; - } else { - errorMessage = "This device does not provide a decoder for " + decoderInitializationException.mimeType; - } - } else { - errorMessage = "Unable to instantiate decoder" + decoderInitializationException.codecInfo.name; - } - } - return errorMessage; - } - - private String getUnexpectedErrorMessage(ExoPlaybackException error, String errorMessage) { - Exception cause = error.getUnexpectedException(); - if (cause.getCause() != null) { - errorMessage = cause.getCause().getMessage(); - } - return errorMessage; - } - - private String getSourceErrorMessage(ExoPlaybackException error, String errorMessage) { - Exception cause = error.getSourceException(); - if (cause.getCause() != null) { - errorMessage = cause.getCause().getMessage(); + log.e("eventListener is null cannot send Error-Event type = " + playbackException.getErrorCodeName()); } - return errorMessage; - } - - private String getOutOfMemoryErrorMessage(ExoPlaybackException error, String errorMessage) { - OutOfMemoryError cause = error.getOutOfMemoryError(); - if (cause.getCause() != null) { - errorMessage = cause.getCause().getMessage(); - } - return errorMessage; - } - - private String getTimeoutErrorMessage(ExoPlaybackException error, String errorMessage) { - TimeoutException cause = error.getTimeoutException(); - if (cause.getCause() != null) { - errorMessage = cause.getCause().getMessage(); - } - return errorMessage; } @Override - public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { + public void onPlaybackParametersChanged(@NonNull PlaybackParameters playbackParameters) { sendEvent(PlayerEvent.Type.PLAYBACK_RATE_CHANGED); } @Override - public void onPositionDiscontinuity(int reason) { + public void onPositionDiscontinuity(@NonNull Player.PositionInfo oldPosition, @NonNull Player.PositionInfo newPosition, @Player.DiscontinuityReason int reason) { log.d("onPositionDiscontinuity reason = " + reason); } @Override - public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { - + public void onTracksChanged(@NonNull Tracks tracks) { log.d("onTracksChanged"); //if onTracksChanged happened when application went background, do not update the tracks. if (assertTrackSelectionIsNotNull("onTracksChanged()")) { //if the track info new -> map the available tracks. and when ready, notify user about available tracks. if (shouldGetTracksInfo) { - shouldGetTracksInfo = !trackSelectionHelper.prepareTracks(trackSelections); + CustomDashManifest customDashManifest = null; + // Local Configuration includes the local playback properties + MediaItem.LocalConfiguration localConfiguration = player.getMediaItemAt(0).localConfiguration; + if (!TextUtils.isEmpty(dashManifestString) && dashLastDataSink != null + && player.getCurrentManifest() instanceof DashManifest && localConfiguration != null) { + // byte[] bytes = dashLastDataSink.getData(); + try { + customDashManifest = new CustomDashManifestParser().parse(localConfiguration.uri, dashManifestString); + } catch (IOException e) { + log.e("imageTracks assemble error " + e.getMessage()); + } finally { + dashLastDataSink = null; + dashManifestString = null; + } + } + shouldGetTracksInfo = !trackSelectionHelper.prepareTracks(tracks, sourceConfig.getExternalVttThumbnailUrl(), customDashManifest); } + trackSelectionHelper.notifyAboutTrackChange(tracks); + } + } - trackSelectionHelper.notifyAboutTrackChange(trackSelections); + @Override + public void onVideoInputFormatChanged(@NonNull Format format) { + if (assertTrackSelectionIsNotNull("onVideoInputFormatChanged")) { + trackSelectionHelper.setCurrentVideoFormat(format); + } + } + + @Override + public void onAudioInputFormatChanged(@NonNull Format format) { + if (assertTrackSelectionIsNotNull("onAudioInputFormatChanged")) { + trackSelectionHelper.setCurrentAudioFormat(format); } } @Override - public void onMetadata(Metadata metadata) { + public void onMetadata(@NonNull Metadata metadata) { this.metadataList = MetadataConverter.convert(metadata); sendEvent(PlayerEvent.Type.METADATA_AVAILABLE); } @@ -774,26 +1124,27 @@ public void load(PKMediaSourceConfig mediaSourceConfig) { if (player == null) { this.useTextureView = playerSettings.useTextureView(); this.isSurfaceSecured = playerSettings.isSurfaceSecured(); + this.useSpeedAdjustingRenderer = shouldUseSpeedAdjustingRenderer(mediaSourceConfig.mediaSource.getMediaFormat()); initializePlayer(); } else { // for change media case need to verify if surface swap is needed maybeChangePlayerRenderView(); + + maybeResetBitrateEstimate(); + + // for change speed adjustment case need to verify if re-init is required + maybeReInitPlayerOnSpeedAdjustmentChange(mediaSourceConfig.mediaSource.getMediaFormat()); } preparePlayer(mediaSourceConfig); } - private boolean isBehindLiveWindow(ExoPlaybackException e) { - if (e.type != ExoPlaybackException.TYPE_SOURCE) { - return false; - } - Throwable cause = e.getSourceException(); - while (cause != null) { - if (cause instanceof BehindLiveWindowException) { - return true; - } - cause = cause.getCause(); - } - return false; + private boolean shouldUseSpeedAdjustingRenderer(PKMediaFormat format) { + return format == PKMediaFormat.udp + && playerSettings.getMulticastSettings().getExperimentalAdjustSpeedOnNegativePosition(); + } + + private boolean isBehindLiveWindow(PlaybackException e) { + return e.errorCode == PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW; } private void maybeChangePlayerRenderView() { @@ -811,6 +1162,25 @@ private void maybeChangePlayerRenderView() { exoPlayerView.setVideoSurfaceProperties(playerSettings.useTextureView(), playerSettings.isSurfaceSecured(), playerSettings.isVideoViewHidden()); } + private void maybeReInitPlayerOnSpeedAdjustmentChange(PKMediaFormat format) { + boolean useSpeedAdjustingRenderer = shouldUseSpeedAdjustingRenderer(format); + if (useSpeedAdjustingRenderer != this.useSpeedAdjustingRenderer) { + // Do not reuse codec first time after switching from multicast to dash content + boolean skipFirstCodecReusage = !useSpeedAdjustingRenderer; + this.useSpeedAdjustingRenderer = useSpeedAdjustingRenderer; + destroyPlayer(false); + initializePlayer(skipFirstCodecReusage); + } + } + + private void maybeResetBitrateEstimate() { + if (playerSettings.getAbrSettings().getAbrInitialBitrateEstimatePolicy() == ABRSettings.InitialBitrateEstimatePolicy.RESET_ON_MEDIA_CHANGE) { + if (bandwidthMeter instanceof KBandwidthMeter) { + ((KBandwidthMeter)bandwidthMeter).resetBitrateEstimate(); + } + } + } + @Override public PlayerView getView() { return exoPlayerView; @@ -852,7 +1222,6 @@ public void pause() { if (currentEvent == PlayerEvent.Type.ENDED) { return; } - sendDistinctEvent(PlayerEvent.Type.PAUSE); profiler.onPauseRequested(); player.setPlayWhenReady(false); @@ -886,7 +1255,7 @@ public long getProgramStartTime() { log.v("getProgramStartTime"); long windowStartTimeMs = TIME_UNSET; if (assertPlayerIsNotNull("getProgramStartTime()")) { - final int currentWindowIndex = player.getCurrentWindowIndex(); + final int currentWindowIndex = player.getCurrentMediaItemIndex(); if (currentWindowIndex == C.INDEX_UNSET) { return windowStartTimeMs; } @@ -904,11 +1273,12 @@ public long getProgramStartTime() { @Override public void seekTo(long position) { log.v("seekTo"); + if (assertPlayerIsNotNull("seekTo()")) { isSeeking = true; sendDistinctEvent(PlayerEvent.Type.SEEKING); profiler.onSeekRequested(position); - if (player.getDuration() == TIME_UNSET) { + if (player.getDuration() == TIME_UNSET && !Utils.isMulticastMedia(sourceConfig.mediaSource.getMediaFormat())) { return; } if (isLive() && position >= player.getDuration()) { @@ -924,6 +1294,14 @@ public void seekTo(long position) { } } + @Override + public void seekToDefaultPosition() { + log.v("seekToDefaultPosition"); + if (assertPlayerIsNotNull("seekToDefaultPosition()")) { + player.seekToDefaultPosition(); + } + } + @Override public long getDuration() { log.v("getDuration"); @@ -942,6 +1320,15 @@ public long getBufferedPosition() { return Consts.POSITION_UNSET; } + @Override + public long getCurrentLiveOffset() { + log.v("getCurrentLiveOffset"); + if (assertPlayerIsNotNull("getCurrentLiveOffset()")) { + return player.getCurrentLiveOffset(); + } + return TIME_UNSET; + } + @Override public void release() { log.v("release"); @@ -952,6 +1339,7 @@ public void release() { if (bandwidthMeter != null) { bandwidthMeter.removeEventListener(this); } + removeCustomLoadErrorPolicy(); if (assertTrackSelectionIsNotNull("release()")) { trackSelectionHelper.release(); trackSelectionHelper = null; @@ -993,19 +1381,33 @@ private boolean isLiveMediaWithoutDvr() { return false; } + private boolean isLiveMediaWithDvr() { + if (sourceConfig != null) { + return (PKMediaEntry.MediaEntryType.DvrLive == sourceConfig.mediaEntryType); + } + return false; + } + @Override public void destroy() { log.v("destroy"); closeProfilerSession(); + removeCustomLoadErrorPolicy(); + destroyPlayer(true); + } + + private void destroyPlayer(boolean releasePlayerView) { if (assertPlayerIsNotNull("destroy()")) { player.release(); } window = null; player = null; - if (exoPlayerView != null) { - exoPlayerView.removeAllViews(); + if (releasePlayerView) { + if (exoPlayerView != null) { + exoPlayerView.removeAllViews(); + } + exoPlayerView = null; } - exoPlayerView = null; playerPosition = TIME_UNSET; } @@ -1015,27 +1417,72 @@ public void changeTrack(String uniqueId) { try { trackSelectionHelper.changeTrack(uniqueId); } catch (IllegalArgumentException ex) { + int trackTypeId = trackSelectionHelper.getTrackTypeId(uniqueId); + if (trackTypeId >= 0) { + lastSelectedTrackIds[trackTypeId] = TrackSelectionHelper.NONE; + } sendTrackSelectionError(uniqueId, ex); } } } + @Override + public void disableVideoTracks(boolean isDisabled) { + if (trackSelectionHelper == null) { + log.w("Attempt to invoke 'disableVideoTracks(" + isDisabled + ")' on null instance of the tracksSelectionHelper"); + return; + } + trackSelectionHelper.disableVideoTracks(isDisabled); + if (isDisabled) { + lastDisabledTrackIds[TRACK_TYPE_VIDEO] = TrackSelectionHelper.DISABLED; + } else { + lastDisabledTrackIds[TRACK_TYPE_VIDEO] = TrackSelectionHelper.NONE; + } + } + + @Override + public void disableAudioTracks(boolean isDisabled) { + if (trackSelectionHelper == null) { + log.w("Attempt to invoke 'disableAudioTracks(" + isDisabled + ")'' on null instance of the tracksSelectionHelper"); + return; + } + trackSelectionHelper.disableAudioTracks(isDisabled); + if (isDisabled) { + lastDisabledTrackIds[TRACK_TYPE_AUDIO] = TrackSelectionHelper.DISABLED; + } else { + lastDisabledTrackIds[TRACK_TYPE_AUDIO] = TrackSelectionHelper.NONE; + } + } + + public void disableTextTracks(boolean isDisabled) { + if (trackSelectionHelper == null) { + log.w("Attempt to invoke 'disableTextTracks(" + isDisabled + ")'' on null instance of the tracksSelectionHelper"); + return; + } + trackSelectionHelper.disableTextTracks(isDisabled); + if (isDisabled) { + lastDisabledTrackIds[TRACK_TYPE_TEXT] = TrackSelectionHelper.DISABLED; + } else { + lastDisabledTrackIds[TRACK_TYPE_TEXT] = TrackSelectionHelper.NONE; + } + } @Override - public void overrideMediaDefaultABR(long minVideoBitrate, long maxVideoBitrate) { + public void overrideMediaDefaultABR(long minAbr, long maxAbr, PKAbrFilter pkAbrFilter) { if (trackSelectionHelper == null) { log.w("Attempt to invoke 'overrideMediaDefaultABR()' on null instance of the tracksSelectionHelper"); return; } - if (minVideoBitrate > maxVideoBitrate || maxVideoBitrate <= 0) { - minVideoBitrate = Long.MIN_VALUE; - maxVideoBitrate = Long.MAX_VALUE; - String errorMessage = "given maxVideoBitrate is not greater than the minVideoBitrate"; + if (minAbr > maxAbr || maxAbr <= 0) { + minAbr = Long.MIN_VALUE; + maxAbr = Long.MAX_VALUE; + pkAbrFilter = PKAbrFilter.NONE; + String errorMessage = "Either given min ABR value is greater than max ABR or max ABR is <= 0"; sendInvalidVideoBitrateRangeIfNeeded(errorMessage); } - trackSelectionHelper.overrideMediaDefaultABR(minVideoBitrate, maxVideoBitrate); + trackSelectionHelper.overrideMediaDefaultABR(minAbr, maxAbr, pkAbrFilter); } @Override @@ -1093,6 +1540,20 @@ public void setAnalyticsListener(AnalyticsListener analyticsListener) { this.analyticsAggregator.setListener(analyticsListener); } + @Override + public void setInputFormatChangedListener(Boolean enableListener) { + this.analyticsAggregator.setInputFormatChangedListener(enableListener != null ? this : null); + } + + @Override + public void setRedirectedManifestURL(String redirectedManifestURL) { + if (player.getCurrentMediaItem() != null && + player.getCurrentMediaItem().localConfiguration != null && + player.getCurrentMediaItem().localConfiguration.uri != null) { + trackSelectionHelper.setRedirectedManifestURL(player.getCurrentMediaItem().localConfiguration.uri.toString(), redirectedManifestURL); + } + } + @Override public void replay() { log.v("replay"); @@ -1172,7 +1633,9 @@ public void stop() { preferredLanguageWasSelected = false; lastKnownVolume = Consts.DEFAULT_VOLUME; lastKnownPlaybackRate = Consts.DEFAULT_PLAYBACK_RATE_SPEED; - lastSelectedTrackIds = new String[]{TrackSelectionHelper.NONE, TrackSelectionHelper.NONE, TrackSelectionHelper.NONE}; + lastSelectedTrackIds = new String[]{TrackSelectionHelper.NONE, TrackSelectionHelper.NONE, TrackSelectionHelper.NONE, TrackSelectionHelper.NONE}; + lastDisabledTrackIds = new String[]{TrackSelectionHelper.NONE, TrackSelectionHelper.NONE, TrackSelectionHelper.NONE, TrackSelectionHelper.NONE}; + if (assertTrackSelectionIsNotNull("stop()")) { trackSelectionHelper.stop(); } @@ -1181,7 +1644,8 @@ public void stop() { if (assertPlayerIsNotNull("stop()")) { player.setPlayWhenReady(false); - player.stop(true); + player.stop(); + player.clearMediaItems(); } analyticsAggregator.reset(); @@ -1193,7 +1657,7 @@ private void savePlayerPosition() { log.v("savePlayerPosition"); if (assertPlayerIsNotNull("savePlayerPosition()")) { currentError = null; - playerWindow = player.getCurrentWindowIndex(); + playerWindow = player.getCurrentMediaItemIndex(); Timeline timeline = player.getCurrentTimeline(); if (timeline != null && !timeline.isEmpty() && playerWindow >= 0 && playerWindow < player.getCurrentTimeline().getWindowCount() && timeline.getWindow(playerWindow, window).isSeekable) { playerPosition = player.getCurrentPosition(); @@ -1222,6 +1686,30 @@ public void onUnsupportedVideoTracksError(PKError pkError) { eventListener.onEvent(PlayerEvent.Type.ERROR); } } + + @Override + public void onUnsupportedAudioTracksError(PKError pkError) { + currentError = pkError; + if (eventListener != null) { + eventListener.onEvent(PlayerEvent.Type.ERROR); + } + } + + @Override + public void onUnsupportedAudioVideoTracksError(PKError pkError) { + currentError = pkError; + if (eventListener != null) { + eventListener.onEvent(PlayerEvent.Type.ERROR); + } + } + + @Override + public void onUnsupportedTracksAvailableError(PKError pkError) { + currentError = pkError; + if (eventListener != null) { + eventListener.onEvent(PlayerEvent.Type.ERROR); + } + } }; } @@ -1229,10 +1717,12 @@ private TrackSelectionHelper.TracksInfoListener initTracksInfoListener() { return new TrackSelectionHelper.TracksInfoListener() { @Override public void onTracksInfoReady(PKTracks tracksReady) { - boolean isABREnabled = playerSettings.getAbrSettings().getMinVideoBitrate() != Long.MIN_VALUE || playerSettings.getAbrSettings().getMaxVideoBitrate() != Long.MAX_VALUE; + HashMap> abrPrecedence = checkABRPriority(); + PKAbrFilter abrFilter = getPKAbrFilter(abrPrecedence); + boolean isABREnabled = abrFilter != PKAbrFilter.NONE; if(isABREnabled) { - overrideMediaDefaultABR(playerSettings.getAbrSettings().getMinVideoBitrate(), playerSettings.getAbrSettings().getMaxVideoBitrate()); + overrideMediaDefaultABR(abrPrecedence.get(abrFilter).first, abrPrecedence.get(abrFilter).second, abrFilter); } else { overrideMediaVideoCodec(); } @@ -1258,8 +1748,9 @@ public void onTracksInfoReady(PKTracks tracksReady) { } @Override - public void onRelease(String[] selectedTrackIds) { + public void onRelease(String[] selectedTrackIds, String[] disbledTrackIds) { lastSelectedTrackIds = selectedTrackIds; + lastDisabledTrackIds = disbledTrackIds; } @Override @@ -1278,9 +1769,57 @@ public void onAudioTrackChanged() { public void onTextTrackChanged() { sendEvent(PlayerEvent.Type.TEXT_TRACK_CHANGED); } + + @Override + public void onImageTrackChanged() { + sendEvent(PlayerEvent.Type.IMAGE_TRACK_CHANGED); + } + + @Override + public void onEventStreamsChanged(List eventStreamList) { + eventStreams = eventStreamList; + sendDistinctEvent(PlayerEvent.Type.EVENT_STREAM_CHANGED); + } }; } + private PKAbrFilter getPKAbrFilter(HashMap> abrPrecedence) { + Set abrSet = abrPrecedence.keySet(); + PKAbrFilter abrFilter = PKAbrFilter.NONE; + + if (abrSet != null && abrSet.size() == 1) { + abrFilter = (PKAbrFilter) abrSet.toArray()[0]; + } + return abrFilter; + } + + private HashMap> checkABRPriority() { + HashMap> abrPriorityMap = new HashMap<>(); + + ABRSettings abrSettings = playerSettings.getAbrSettings(); + Long minVideoHeight = abrSettings.getMinVideoHeight(); + Long maxVideoHeight = abrSettings.getMaxVideoHeight(); + Long minVideoWidth = abrSettings.getMinVideoWidth(); + Long maxVideoWidth = abrSettings.getMaxVideoWidth(); + Long minVideoBitrate = abrSettings.getMinVideoBitrate(); + Long maxVideoBitrate = abrSettings.getMaxVideoBitrate(); + + if ((maxVideoHeight != Long.MAX_VALUE && minVideoHeight != Long.MIN_VALUE) && + (maxVideoWidth != Long.MAX_VALUE && minVideoWidth != Long.MIN_VALUE)) { + abrPriorityMap.put(PKAbrFilter.PIXEL, new Pair<>(minVideoWidth * minVideoHeight, maxVideoWidth * maxVideoHeight)); + } else if (maxVideoHeight != Long.MAX_VALUE || minVideoHeight != Long.MIN_VALUE) { + abrPriorityMap.put(PKAbrFilter.HEIGHT, new Pair<>(minVideoHeight, maxVideoHeight)); + } else if (maxVideoWidth != Long.MAX_VALUE || minVideoWidth != Long.MIN_VALUE) { + abrPriorityMap.put(PKAbrFilter.WIDTH, new Pair<>(minVideoWidth, maxVideoWidth)); + } else if (maxVideoBitrate != Long.MAX_VALUE || minVideoBitrate != Long.MIN_VALUE) { + abrPriorityMap.put(PKAbrFilter.BITRATE, new Pair<>(minVideoBitrate, maxVideoBitrate)); + } else { + abrPriorityMap.put(PKAbrFilter.NONE, new Pair<>(Long.MIN_VALUE, Long.MAX_VALUE)); + } + + return abrPriorityMap; + } + private void sendInvalidVideoBitrateRangeIfNeeded(String errorMessage) { if (eventListener != null) { currentError = new PKError(PKPlayerErrorType.UNEXPECTED, PKError.Severity.Recoverable, errorMessage, new IllegalArgumentException(errorMessage)); @@ -1303,15 +1842,40 @@ public BaseTrack getLastSelectedTrack(int renderType) { return null; } + @Override + public List getEventStreams() { + return eventStreams; + } + @Override public boolean isLive() { log.v("isLive"); if (assertPlayerIsNotNull("isLive()")) { - return player.isCurrentWindowLive(); + return player.isCurrentMediaItemLive(); } return false; } + @Override + public void setDownloadCache(Cache downloadCache) { + this.downloadCache = downloadCache; + } + + @Override + public ThumbnailInfo getThumbnailInfo(long positionMS) { + log.v("getThumbnailInfo positionMS = " + positionMS); + if (assertPlayerIsNotNull("getThumbnailInfo()")) { + if (isLive()) { + Timeline timeline = player.getCurrentTimeline(); + if (!timeline.isEmpty()) { + positionMS -= timeline.getPeriod(player.getCurrentPeriodIndex(), new Timeline.Period()).getPositionInWindowMs(); + } + } + return trackSelectionHelper.getThumbnailInfo(positionMS); + } + return null; + } + private void closeProfilerSession() { profiler.onSessionFinished(); } @@ -1378,44 +1942,125 @@ public void setProfiler(Profiler profiler) { } private void configureSubtitleView() { - SubtitleView exoPlayerSubtitleView = null; + SubtitleView exoPlayerSubtitleView; SubtitleStyleSettings subtitleStyleSettings = playerSettings.getSubtitleStyleSettings(); - if(exoPlayerView != null) { + if (exoPlayerView != null) { if (subtitleStyleSettings.getSubtitlePosition() != null) { exoPlayerView.setSubtitleViewPosition(subtitleStyleSettings.getSubtitlePosition()); } exoPlayerSubtitleView = exoPlayerView.getSubtitleView(); } else { log.e("ExoPlayerView is not available"); + return; } - if (exoPlayerSubtitleView != null) { + if (exoPlayerSubtitleView != null ) { + // Setting `false` will tell ExoPlayer to remove the styling + // and the font size of the cue. + // Separate ExoPlayer API to remove font size is `setApplyEmbeddedFontSizes`. + // In our API, for FE apps, default is `true` means override the styling + // Hence reverting the value coming in `subtitleStyleSettings.isOverrideCueStyling()` + exoPlayerSubtitleView.setApplyEmbeddedStyles(!subtitleStyleSettings.isOverrideCueStyling()); + exoPlayerSubtitleView.setStyle(subtitleStyleSettings.toCaptionStyle()); exoPlayerSubtitleView.setFractionalTextSize(SubtitleView.DEFAULT_TEXT_SIZE_FRACTION * subtitleStyleSettings.getTextSizeFraction()); } else { log.e("Subtitle View is not available"); + return; } + exoPlayerView.applySubtitlesChanges(); } @Override public void updateSubtitleStyle(SubtitleStyleSettings subtitleStyleSettings) { - if (playerSettings.getSubtitleStyleSettings() != null) { + if (playerSettings.getSubtitleStyleSettings() != null && subtitleStyleSettings != null) { playerSettings.setSubtitleStyle(subtitleStyleSettings); configureSubtitleView(); sendEvent(PlayerEvent.Type.SUBTITLE_STYLE_CHANGED); } } + @Override + public void updateABRSettings(ABRSettings abrSettings) { + playerSettings.setABRSettings(abrSettings); + HashMap> abrPrecedence = checkABRPriority(); + PKAbrFilter abrFilter = getPKAbrFilter(abrPrecedence); + overrideMediaDefaultABR(abrPrecedence.get(abrFilter).first, abrPrecedence.get(abrFilter).second, abrFilter); + sendDistinctEvent(PlayerEvent.Type.TRACKS_AVAILABLE); + } + + @Override + public void resetABRSettings() { + playerSettings.setABRSettings(ABRSettings.RESET); + overrideMediaDefaultABR(Long.MIN_VALUE, Long.MAX_VALUE, PKAbrFilter.NONE); + sendDistinctEvent(PlayerEvent.Type.TRACKS_AVAILABLE); + } + + @Nullable + @Override + public Object getCurrentMediaManifest() { + if (assertPlayerIsNotNull("getCurrentMediaManifest")) { + return player.getCurrentManifest(); + } + return null; + } + @Override public void updateSurfaceAspectRatioResizeMode(PKAspectRatioResizeMode resizeMode) { + if (resizeMode == null) { + log.e("Resize mode is invalid"); + return; + } playerSettings.setSurfaceAspectRatioResizeMode(resizeMode); - configureAspectRatioResizeMode(); + configureAspectRatioResizeMode(playerSettings.getAspectRatioResizeMode()); sendEvent(PlayerEvent.Type.ASPECT_RATIO_RESIZE_MODE_CHANGED); } - private void configureAspectRatioResizeMode() { - if(exoPlayerView != null){ - exoPlayerView.setSurfaceAspectRatioResizeMode(playerSettings.getAspectRatioResizeMode()); + @Override + public void updatePKLowLatencyConfig(PKLowLatencyConfig pkLowLatencyConfig) { + if (!isLiveMediaWithDvr() && !isLiveMediaWithoutDvr()) { + return; + } + + pkLowLatencyConfig = validatePKLowLatencyConfig(pkLowLatencyConfig); + playerSettings.setPKLowLatencyConfig(pkLowLatencyConfig); + + if (assertPlayerIsNotNull("updatePKLowLatencyConfig") && player.getCurrentMediaItem() != null) { + MediaItem.LiveConfiguration liveConfiguration = getLowLatencyConfigFromPlayerSettings(); + player.setMediaItem(player.getCurrentMediaItem().buildUpon() + .setLiveConfiguration(liveConfiguration) + .build()); + } + } + + private PKLowLatencyConfig validatePKLowLatencyConfig(PKLowLatencyConfig pkLowLatencyConfig) { + + PKLowLatencyConfig unsetPKLowLatencyConfig = PKLowLatencyConfig.UNSET; + if (pkLowLatencyConfig == null) { + pkLowLatencyConfig = unsetPKLowLatencyConfig; + } else { + if (pkLowLatencyConfig.getTargetOffsetMs() <= 0) { + pkLowLatencyConfig.setTargetOffsetMs(unsetPKLowLatencyConfig.getTargetOffsetMs()); + } + if (pkLowLatencyConfig.getMinOffsetMs() <= 0) { + pkLowLatencyConfig.setMinOffsetMs(unsetPKLowLatencyConfig.getMinOffsetMs()); + } + if (pkLowLatencyConfig.getMaxOffsetMs() <= 0) { + pkLowLatencyConfig.setMaxOffsetMs(unsetPKLowLatencyConfig.getMaxOffsetMs()); + } + if (pkLowLatencyConfig.getMinPlaybackSpeed() <= 0) { + pkLowLatencyConfig.setMinPlaybackSpeed(unsetPKLowLatencyConfig.getMinPlaybackSpeed()); + } + if (pkLowLatencyConfig.getMaxPlaybackSpeed() <= 0) { + pkLowLatencyConfig.setMaxPlaybackSpeed(unsetPKLowLatencyConfig.getMaxPlaybackSpeed()); + } + } + return pkLowLatencyConfig; + } + + private void configureAspectRatioResizeMode(PKAspectRatioResizeMode resizeMode) { + if (exoPlayerView != null) { + exoPlayerView.setSurfaceAspectRatioResizeMode(resizeMode); } } diff --git a/playkit/src/main/java/com/kaltura/playkit/player/ImageTrack.java b/playkit/src/main/java/com/kaltura/playkit/player/ImageTrack.java new file mode 100644 index 000000000..d42f74fe7 --- /dev/null +++ b/playkit/src/main/java/com/kaltura/playkit/player/ImageTrack.java @@ -0,0 +1,82 @@ +/* + * ============================================================================ + * Copyright (C) 2017 Kaltura Inc. + * + * Licensed under the AGPLv3 license, unless a different license for a + * particular library is specified in the applicable library path. + * + * You may obtain a copy of the License at + * https://www.gnu.org/licenses/agpl-3.0.html + * ============================================================================ + */ + +package com.kaltura.playkit.player; + +/** + * Image track data holder. + * + */ +public class ImageTrack extends BaseTrack { + + private String label; + private long bitrate; + private float width; + private float height; + private int cols; + private int rows; + private long duration; + private String url; + + ImageTrack(String uniqueId, + String label, + long bitrate, + float width, + float height, + int cols, + int rows, + long duration, + String url + ) { + super(uniqueId, 0, false); + this.label = label; + this.bitrate = bitrate; + this.width = width; + this.height = height; + this.cols = cols; + this.rows = rows; + this.duration = duration; + this.url = url; + } + + public String getLabel() { + return label; + } + + public long getBitrate() { + return bitrate; + } + + public int getCols() { + return cols; + } + + public int getRows() { + return rows; + } + + public float getWidth() { + return width; + } + + public float getHeight() { + return height; + } + + public long getDuration() { + return duration; + } + + public String getUrl() { + return url; + } +} diff --git a/playkit/src/main/java/com/kaltura/playkit/player/LoadControlBuffers.java b/playkit/src/main/java/com/kaltura/playkit/player/LoadControlBuffers.java index d851eeaa5..b243415fd 100644 --- a/playkit/src/main/java/com/kaltura/playkit/player/LoadControlBuffers.java +++ b/playkit/src/main/java/com/kaltura/playkit/player/LoadControlBuffers.java @@ -1,12 +1,14 @@ package com.kaltura.playkit.player; -import static com.kaltura.android.exoplayer2.DefaultLoadControl.DEFAULT_BACK_BUFFER_DURATION_MS; -import static com.kaltura.android.exoplayer2.DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS; -import static com.kaltura.android.exoplayer2.DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS; -import static com.kaltura.android.exoplayer2.DefaultLoadControl.DEFAULT_MAX_BUFFER_MS; -import static com.kaltura.android.exoplayer2.DefaultLoadControl.DEFAULT_MIN_BUFFER_MS; -import static com.kaltura.android.exoplayer2.DefaultLoadControl.DEFAULT_RETAIN_BACK_BUFFER_FROM_KEYFRAME; -import static com.kaltura.android.exoplayer2.DefaultRenderersFactory.DEFAULT_ALLOWED_VIDEO_JOINING_TIME_MS; +import static com.kaltura.androidx.media3.exoplayer.DefaultLoadControl.DEFAULT_BACK_BUFFER_DURATION_MS; +import static com.kaltura.androidx.media3.exoplayer.DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS; +import static com.kaltura.androidx.media3.exoplayer.DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS; +import static com.kaltura.androidx.media3.exoplayer.DefaultLoadControl.DEFAULT_MAX_BUFFER_MS; +import static com.kaltura.androidx.media3.exoplayer.DefaultLoadControl.DEFAULT_MIN_BUFFER_MS; +import static com.kaltura.androidx.media3.exoplayer.DefaultLoadControl.DEFAULT_PRIORITIZE_TIME_OVER_SIZE_THRESHOLDS; +import static com.kaltura.androidx.media3.exoplayer.DefaultLoadControl.DEFAULT_RETAIN_BACK_BUFFER_FROM_KEYFRAME; +import static com.kaltura.androidx.media3.exoplayer.DefaultLoadControl.DEFAULT_TARGET_BUFFER_BYTES; +import static com.kaltura.androidx.media3.exoplayer.DefaultRenderersFactory.DEFAULT_ALLOWED_VIDEO_JOINING_TIME_MS; public class LoadControlBuffers { @@ -17,8 +19,13 @@ public class LoadControlBuffers { private int backBufferDurationMs = DEFAULT_BACK_BUFFER_DURATION_MS; private boolean retainBackBufferFromKeyframe = DEFAULT_RETAIN_BACK_BUFFER_FROM_KEYFRAME; private long allowedVideoJoiningTimeMs = DEFAULT_ALLOWED_VIDEO_JOINING_TIME_MS; //Maximum duration for which a video renderer can attempt to seamlessly join an ongoing playback. Default is 5000ms + private int targetBufferBytes = DEFAULT_TARGET_BUFFER_BYTES; + private boolean prioritizeTimeOverSizeThresholds = DEFAULT_PRIORITIZE_TIME_OVER_SIZE_THRESHOLDS; public int getMinPlayerBufferMs() { + if (maxPlayerBufferMs < minPlayerBufferMs) { + return maxPlayerBufferMs; + } return minPlayerBufferMs; } @@ -90,6 +97,24 @@ public LoadControlBuffers setAllowedVideoJoiningTimeMs(long allowedVideoJoiningT return this; } + public int getTargetBufferBytes() { + return targetBufferBytes; + } + + public LoadControlBuffers setTargetBufferBytes(int targetBufferBytes) { + this.targetBufferBytes = targetBufferBytes; + return this; + } + + public boolean getPrioritizeTimeOverSizeThresholds() { + return prioritizeTimeOverSizeThresholds; + } + + public LoadControlBuffers setPrioritizeTimeOverSizeThresholds(boolean prioritizeTimeOverSizeThresholds) { + this.prioritizeTimeOverSizeThresholds = prioritizeTimeOverSizeThresholds; + return this; + } + public boolean isDefaultValuesModified() { return minPlayerBufferMs != DEFAULT_MIN_BUFFER_MS || maxPlayerBufferMs != DEFAULT_MAX_BUFFER_MS || @@ -97,6 +122,8 @@ public boolean isDefaultValuesModified() { minBufferAfterReBufferMs != DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS || backBufferDurationMs != DEFAULT_BACK_BUFFER_DURATION_MS || retainBackBufferFromKeyframe != DEFAULT_RETAIN_BACK_BUFFER_FROM_KEYFRAME || - allowedVideoJoiningTimeMs != DEFAULT_ALLOWED_VIDEO_JOINING_TIME_MS; + allowedVideoJoiningTimeMs != DEFAULT_ALLOWED_VIDEO_JOINING_TIME_MS || + targetBufferBytes != DEFAULT_TARGET_BUFFER_BYTES || + prioritizeTimeOverSizeThresholds != DEFAULT_PRIORITIZE_TIME_OVER_SIZE_THRESHOLDS; } } diff --git a/playkit/src/main/java/com/kaltura/playkit/player/MediaPlayerWrapper.java b/playkit/src/main/java/com/kaltura/playkit/player/MediaPlayerWrapper.java index 35c674362..490e97b20 100644 --- a/playkit/src/main/java/com/kaltura/playkit/player/MediaPlayerWrapper.java +++ b/playkit/src/main/java/com/kaltura/playkit/player/MediaPlayerWrapper.java @@ -19,11 +19,16 @@ import android.drm.DrmErrorEvent; import android.drm.DrmEvent; import android.media.MediaPlayer; +import android.media.PlaybackParams; import android.net.Uri; import android.os.Build; -import androidx.annotation.NonNull; import android.view.SurfaceHolder; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.kaltura.playkit.PKAbrFilter; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.EventStream; import com.kaltura.playkit.PKDrmParams; import com.kaltura.playkit.PKError; import com.kaltura.playkit.PKLog; @@ -80,6 +85,7 @@ class MediaPlayerWrapper implements PlayerEngine, SurfaceHolder.Callback, MediaP private boolean appInBackground; private boolean isFirstPlayback = true; private long currentBufferPercentage; + private float lastKnownPlaybackRate = Consts.DEFAULT_PLAYBACK_RATE_SPEED; MediaPlayerWrapper(Context context) { this.context = context; @@ -179,7 +185,6 @@ private void sendOnPreparedEvents() { sendDistinctEvent(PlayerEvent.Type.TRACKS_AVAILABLE); sendDistinctEvent(PlayerEvent.Type.PLAYBACK_INFO_UPDATED); sendDistinctEvent(PlayerEvent.Type.CAN_PLAY); - } private void handleContentCompleted() { @@ -274,6 +279,11 @@ public long getBufferedPosition() { return (long) Math.floor(playerDuration * (currentBufferPercentage / Consts.PERCENT_FACTOR)); } + @Override + public long getCurrentLiveOffset() { + return TIME_UNSET; + } + @Override public float getVolume() { return 0; @@ -281,8 +291,8 @@ public float getVolume() { @Override public PKTracks getPKTracks() { - return new PKTracks(new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), - 0, 0, 0); + return new PKTracks(new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), + 0, 0, 0, 0); } @Override @@ -296,7 +306,7 @@ public void overrideMediaVideoCodec() { } @Override - public void overrideMediaDefaultABR(long minVideoBitrate, long maxVideoBitrate) { + public void overrideMediaDefaultABR(long minVideoBitrate, long maxVideoBitrate, PKAbrFilter pkAbrFilter) { // Do Nothing } @@ -354,7 +364,17 @@ public void setStateChangedListener(StateChangedListener stateChangedTrigger) { @Override public void setAnalyticsListener(AnalyticsListener analyticsListener) { - // Not implemented + // Not implemented + } + + @Override + public void setInputFormatChangedListener(Boolean enableListener) { + // Not implemented + } + + @Override + public void setRedirectedManifestURL(String redirectedManifestURL) { + // Not implemented } @Override @@ -377,7 +397,7 @@ public void restore() { if (playerPosition != 0) { seekTo(playerPosition); shouldRestorePlayerToPreviousState = false; - + setPlaybackRate(lastKnownPlaybackRate); } pause(); } else { @@ -414,6 +434,7 @@ public PKError getCurrentError() { @Override public void stop() { + lastKnownPlaybackRate = Consts.DEFAULT_PLAYBACK_RATE_SPEED; if (player != null) { player.pause(); player.seekTo(0); @@ -592,6 +613,11 @@ public BaseTrack getLastSelectedTrack(int renderType) { return null; } + @Override + public List getEventStreams() { + return null; + } + @Override public boolean isLive() { return false; @@ -599,12 +625,33 @@ public boolean isLive() { @Override public void setPlaybackRate(float rate) { - log.w("setPlaybackRate is not supported since RequiresApi(api = Build.VERSION_CODES.M"); + log.v("setPlaybackRate"); + if (player == null) { + log.w("Attempt to invoke 'setPlaybackRate()' on null instance of mediaplayer"); + return; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + final PlaybackParams playbackParams = player.getPlaybackParams(); + if (playbackParams.getSpeed() == rate) { + return; + } + player.setPlaybackParams(playbackParams.setSpeed(rate)); + this.lastKnownPlaybackRate = rate; + sendEvent(PlayerEvent.Type.PLAYBACK_RATE_CHANGED); + } else { + log.w("setPlaybackRate is not supported since RequiresApi(api >= Build.VERSION_CODES.M"); + } } @Override public float getPlaybackRate() { - return Consts.DEFAULT_PLAYBACK_RATE_SPEED; + log.d("getPlaybackRate"); + if (player != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + return player.getPlaybackParams().getSpeed(); + } + + return lastKnownPlaybackRate; } @Override @@ -617,6 +664,13 @@ public void updateSubtitleStyle(SubtitleStyleSettings subtitleStyleSettings) { //Do nothing } + @Nullable + @Override + public Object getCurrentMediaManifest() { + // Do nothing + return null; + } + @NonNull private Map getHeadersMap() { Map headersMap = new HashMap<>(); diff --git a/playkit/src/main/java/com/kaltura/playkit/player/MediaSupport.java b/playkit/src/main/java/com/kaltura/playkit/player/MediaSupport.java index c4d8c817c..11f53d650 100644 --- a/playkit/src/main/java/com/kaltura/playkit/player/MediaSupport.java +++ b/playkit/src/main/java/com/kaltura/playkit/player/MediaSupport.java @@ -26,10 +26,13 @@ import android.util.Base64; import android.util.Log; +import com.kaltura.androidx.media3.exoplayer.drm.ExoMediaDrm; +import com.kaltura.androidx.media3.exoplayer.drm.FrameworkMediaDrm; import com.kaltura.playkit.PKDrmParams; import com.kaltura.playkit.PKLog; import com.kaltura.playkit.Utils; +import java.io.IOException; import java.lang.reflect.Method; import java.util.HashSet; import java.util.Set; @@ -44,11 +47,15 @@ public class MediaSupport { public static final UUID WIDEVINE_UUID = UUID.fromString("edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"); public static final UUID PLAYREADY_UUID = UUID.fromString("9a04f079-9840-4286-ab92-e65be0885f95"); private static final String WIDEVINE_SECURITY_LEVEL_1 = "L1"; + private static final String WIDEVINE_SECURITY_LEVEL_3 = "L3"; + private static final String SECURITY_LEVEL_PROPERTY = "securityLevel"; + private static boolean intitialzing; private static boolean initSucceeded; @Nullable private static Boolean widevineClassic; @Nullable private static Boolean widevineModular; + @Nullable private static Boolean playreadyCenc; @Nullable private static String securityLevel; public static final String DEVICE_CHIPSET = getDeviceChipset(); @@ -76,6 +83,10 @@ public static void checkDrm(Context context) throws DrmNotProvisionedException { if (widevineModular == null) { checkWidevineModular(); } + + if (playreadyCenc == null) { + checkPlayreadyCenc(); + } } /** @@ -86,7 +97,10 @@ public static void checkDrm(Context context) throws DrmNotProvisionedException { * @param drmInitCallback callback object that will get the result. See {@link DrmInitCallback}. */ public static void initializeDrm(Context context, final DrmInitCallback drmInitCallback) { - + if (intitialzing) { + return; + } + intitialzing = true; if (initSucceeded) { runCallback(drmInitCallback, hardwareDrm(), false, null); return; @@ -95,22 +109,22 @@ public static void initializeDrm(Context context, final DrmInitCallback drmInitC try { checkWidevineClassic(context); checkWidevineModular(); - + checkPlayreadyCenc(); initSucceeded = true; runCallback(drmInitCallback, hardwareDrm(), false, null); - } catch (DrmNotProvisionedException e) { + } catch (DrmNotProvisionedException drmNotProvisionedException) { log.d("Widevine Modular needs provisioning"); AsyncTask.execute(() -> { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { try { provisionWidevine(); runCallback(drmInitCallback, hardwareDrm(), true, null); - } catch (Exception e1) { + } catch (Exception exception) { // Send any exception to the callback - log.e("Widevine provisioning has failed", e1); - runCallback(drmInitCallback, hardwareDrm(), true, e1); + log.e("Widevine provisioning has failed", exception); + runCallback(drmInitCallback, hardwareDrm(), true, exception); } } }); @@ -123,8 +137,20 @@ private static void checkWidevineModular() throws DrmNotProvisionedException { } } - private static void runCallback(DrmInitCallback drmInitCallback, boolean isHardwareDrmSupported, boolean provisionPerformed, Exception provisionError) { + private static void checkPlayreadyCenc() throws DrmNotProvisionedException { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { + playreadyCenc = FrameworkMediaDrm.isCryptoSchemeSupported(PLAYREADY_UUID); + } + } + + public static void provisionWidevineL3() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { + WidevineModularUtil.provisionWidevineL3(); + } + } + private static void runCallback(DrmInitCallback drmInitCallback, boolean isHardwareDrmSupported, boolean provisionPerformed, Exception provisionError) { + intitialzing = false; final Set supportedDrmSchemes = supportedDrmSchemes(); if (drmInitCallback != null) { drmInitCallback.onDrmInitComplete(new PKDeviceCapabilitiesInfo(supportedDrmSchemes, isHardwareDrmSupported, provisionPerformed, @@ -149,6 +175,7 @@ public static Set supportedDrmSchemes(Context context) { checkWidevineClassic(context); try { checkWidevineModular(); + checkPlayreadyCenc(); } catch (DrmNotProvisionedException e) { log.e("Widevine Modular needs provisioning"); } @@ -168,7 +195,7 @@ private static Set supportedDrmSchemes() { schemes.add(PKDrmParams.Scheme.WidevineClassic); } - if (playReady()) { + if (playready()) { schemes.add(PKDrmParams.Scheme.PlayReadyCENC); } @@ -225,11 +252,17 @@ public static boolean hardwareDrm() { if (widevineModular()) { return WIDEVINE_SECURITY_LEVEL_1.equals(securityLevel); } + return false; } - public static boolean playReady() { - return Boolean.FALSE; // Not yet. + public static boolean playready() { + if (playreadyCenc == null) { + log.w("PlayreadyCenc DRM is not initialized; assuming not supported"); + return false; + } + + return playreadyCenc; } @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2) @@ -294,7 +327,7 @@ private static Boolean checkWidevineModular(Boolean widevineModular) throws Medi try { securityLevel = mediaDrm.getPropertyString(SECURITY_LEVEL_PROPERTY); } catch (RuntimeException e) { - securityLevel = null; + securityLevel = null; } } catch (NotProvisionedException e) { log.e("Widevine Modular not provisioned"); @@ -314,6 +347,45 @@ private static Boolean checkWidevineModular(Boolean widevineModular) throws Medi } return widevineModular; } + + public static void provisionWidevineL3() { + log.d("Running provisionWidevineL3"); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { + ExoMediaDrm.Provider exoMediaDrmProvider = FrameworkMediaDrm.DEFAULT_PROVIDER; + ExoMediaDrm exoMediaDrm = exoMediaDrmProvider.acquireExoMediaDrm(MediaSupport.WIDEVINE_UUID); + exoMediaDrm.setPropertyString(SECURITY_LEVEL_PROPERTY, WIDEVINE_SECURITY_LEVEL_3); + byte[] session = null; + try { + session = exoMediaDrm.openSession(); + } catch (NotProvisionedException notProvisionedException) { + log.d("provisionWidevineL3: Widevine provisioning NotProvisionedException"); + ExoMediaDrm.ProvisionRequest provisionRequest = exoMediaDrm.getProvisionRequest(); + String url = provisionRequest.getDefaultUrl() + "&signedRequest=" + new String(provisionRequest.getData()); + final byte[] response; + try { + response = Utils.executePost(url, null, null); + Log.i("RESULT", Base64.encodeToString(response, Base64.NO_WRAP)); + exoMediaDrm.provideProvisionResponse(response); + } catch (IOException ioException) { + log.e("provisionWidevineL3: ExoMediaDrm Widevine provisioning ioException", ioException); + } catch (Exception exception) { + log.e("provisionWidevineL3: ExoMediaDrm Widevine provisioning deniedByServerException", exception); + } + } catch (Exception exception) { + log.e("provisionWidevineL3 ExoMediaDrm Widevine provisioning MediaDrmException", exception); + } finally { + if (exoMediaDrm != null && session != null) { + log.e("provisionWidevineL3 Closing Session..."); + exoMediaDrm.closeSession(session); + } + if (exoMediaDrm != null) { + log.e("provisionWidevineL3 Releasing ExoMediaDrm..."); + exoMediaDrm.release(); + } + } + } + } } public static class DrmNotProvisionedException extends Exception { diff --git a/playkit/src/main/java/com/kaltura/playkit/player/MulticastSettings.java b/playkit/src/main/java/com/kaltura/playkit/player/MulticastSettings.java new file mode 100644 index 000000000..b59af8ad0 --- /dev/null +++ b/playkit/src/main/java/com/kaltura/playkit/player/MulticastSettings.java @@ -0,0 +1,142 @@ +package com.kaltura.playkit.player; + +public class MulticastSettings { + + // whether mulicast playback will use exo default config or app config + private boolean useExoDefaultSettings = true; + + // experimental value to control whether to seek to default position if we get negative position on udp media load time + private boolean experimentalSeekToDefaultPosition = false; + // maxPacketSize The maximum datagram packet size, in bytes. + private int maxPacketSize = 3000; + // socketTimeoutMillis The socket timeout in milliseconds. A timeout of zero is interpreted + private int socketTimeoutMillis = 10000; + //Modes for the extractor. One of MODE_MULTI_PMT, MODE_SINGLE_PMT, MODE_HLS + private ExtractorMode extractorMode = ExtractorMode.MODE_MULTI_PMT; + //The desired value of the first adjusted sample timestamp in microseconds - for no offset give MAX_LONG + private long firstSampleTimestampUs; + // experimental value to control whether to adjust speed if we get negative position on udp media load time + private boolean experimentalAdjustSpeedOnNegativePosition = false; + // experimental value to control whether adjusting speed (in case if gap) should happen only once on start + // or should be adjusted all the time + private boolean experimentalContinuousSpeedAdjustment = false; + // experimental value to control maximum playback speed used during speed adjustment + private float experimentalMaxSpeedFactor = 4.0f; + // experimental value to control speed adjustment step during speed adjustment + private float experimentalSpeedStep = 3.0f; + // experimental value to control maximum gap between presented audio and video buffers + // when the speed adjustment should not be used + private long experimentalAVGapForSpeedAdjustment = 600_000L; + + enum ExtractorMode { + MODE_MULTI_PMT(0), + MODE_SINGLE_PMT(1), + MODE_HLS(2); + + public final int mode; + ExtractorMode(int mode) { + this.mode = mode; + } + } + + public MulticastSettings() {} + + public boolean getUseExoDefaultSettings() { + return useExoDefaultSettings; + } + + public boolean getExperimentalSeekToDefaultPosition() { + return experimentalSeekToDefaultPosition; + } + + public MulticastSettings setExperimentalSeekToStartOnMediaLoad(boolean experimentalSeekToDefaultPosition) { + this.experimentalSeekToDefaultPosition = experimentalSeekToDefaultPosition; + return this; + } + + public int getMaxPacketSize() { + return maxPacketSize; + } + + public MulticastSettings setUseExoDefaultSettings(boolean useExoDefaultSettings) { + this.useExoDefaultSettings = useExoDefaultSettings; + return this; + } + + public MulticastSettings setMaxPacketSize(int maxPacketSize) { + this.maxPacketSize = maxPacketSize; + this.useExoDefaultSettings = false; + return this; + } + + public int getSocketTimeoutMillis() { + return socketTimeoutMillis; + } + + public MulticastSettings setSocketTimeoutMillis(int socketTimeoutMillis) { + this.socketTimeoutMillis = socketTimeoutMillis; + this.useExoDefaultSettings = false; + return this; + } + + public ExtractorMode getExtractorMode() { + return extractorMode; + } + + public MulticastSettings setExtractorMode(ExtractorMode extractorMode) { + if (extractorMode != null) { + this.extractorMode = extractorMode; + } + this.useExoDefaultSettings = false; + return this; + } + + public long getFirstSampleTimestampUs() { + return firstSampleTimestampUs; + } + + public MulticastSettings setFirstSampleTimestampUs(long firstSampleTimestampUs) { + this.firstSampleTimestampUs = firstSampleTimestampUs; + return this; + } + + public boolean getExperimentalAdjustSpeedOnNegativePosition() { + return experimentalAdjustSpeedOnNegativePosition; + } + + public boolean getExperimentalContinuousSpeedAdjustment() { + return experimentalContinuousSpeedAdjustment; + } + + public float getExperimentalMaxSpeedFactor() { + return experimentalMaxSpeedFactor; + } + + public float getExperimentalSpeedStep() { + return experimentalSpeedStep; + } + + public long getExperimentalAVGapForSpeedAdjustment() { + return experimentalAVGapForSpeedAdjustment; + } + + public void setExperimentalAdjustSpeedOnNegativePosition(boolean experimentalAdjustSpeedOnNegativePosition) { + this.experimentalAdjustSpeedOnNegativePosition = experimentalAdjustSpeedOnNegativePosition; + } + + public void setExperimentalContinuousSpeedAdjustment(boolean experimentalContinuousSpeedAdjustment) { + this.experimentalContinuousSpeedAdjustment = experimentalContinuousSpeedAdjustment; + } + + public void setExperimentalMaxSpeedFactor(float experimentalMaxSpeedFactor) { + this.experimentalMaxSpeedFactor = experimentalMaxSpeedFactor; + } + + public void setExperimentalSpeedStep(float experimentalSpeedStep) { + this.experimentalSpeedStep = experimentalSpeedStep; + } + + public void setExperimentalAVGapForSpeedAdjustment(long experimentalAVGapForSpeedAdjustment) { + this.experimentalAVGapForSpeedAdjustment = experimentalAVGapForSpeedAdjustment; + } +} diff --git a/playkit/src/main/java/com/kaltura/playkit/player/PKAspectRatioResizeMode.java b/playkit/src/main/java/com/kaltura/playkit/player/PKAspectRatioResizeMode.java index aad3b7a03..c07a79308 100644 --- a/playkit/src/main/java/com/kaltura/playkit/player/PKAspectRatioResizeMode.java +++ b/playkit/src/main/java/com/kaltura/playkit/player/PKAspectRatioResizeMode.java @@ -1,9 +1,34 @@ package com.kaltura.playkit.player; +import com.kaltura.androidx.media3.ui.AspectRatioFrameLayout; + public enum PKAspectRatioResizeMode { fit, fixedWidth, fixedHeight, fill, - zoom + zoom; + + public static @AspectRatioFrameLayout.ResizeMode int getExoPlayerAspectRatioResizeMode(PKAspectRatioResizeMode resizeMode) { + @AspectRatioFrameLayout.ResizeMode int exoPlayerAspectRatioResizeMode; + switch(resizeMode) { + case fixedWidth: + exoPlayerAspectRatioResizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH; + break; + case fixedHeight: + exoPlayerAspectRatioResizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT; + break; + case fill: + exoPlayerAspectRatioResizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL; + break; + case zoom: + exoPlayerAspectRatioResizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM; + break; + case fit: + default: + exoPlayerAspectRatioResizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT; + break; + } + return exoPlayerAspectRatioResizeMode; + } } diff --git a/playkit/src/main/java/com/kaltura/playkit/player/PKCodecSupport.java b/playkit/src/main/java/com/kaltura/playkit/player/PKCodecSupport.java index 64ec99a46..f1ef95017 100644 --- a/playkit/src/main/java/com/kaltura/playkit/player/PKCodecSupport.java +++ b/playkit/src/main/java/com/kaltura/playkit/player/PKCodecSupport.java @@ -8,7 +8,7 @@ import androidx.annotation.RequiresApi; -import com.kaltura.android.exoplayer2.util.MimeTypes; +import com.kaltura.androidx.media3.common.MimeTypes; import com.kaltura.playkit.PKLog; import java.util.ArrayList; diff --git a/playkit/src/main/java/com/kaltura/playkit/player/PKExternalSubtitle.java b/playkit/src/main/java/com/kaltura/playkit/player/PKExternalSubtitle.java index 264b002f3..7fec34b5d 100644 --- a/playkit/src/main/java/com/kaltura/playkit/player/PKExternalSubtitle.java +++ b/playkit/src/main/java/com/kaltura/playkit/player/PKExternalSubtitle.java @@ -4,17 +4,16 @@ import android.os.Parcelable; import androidx.annotation.NonNull; -import com.kaltura.android.exoplayer2.C; -import com.kaltura.android.exoplayer2.Format; +import com.kaltura.androidx.media3.common.C; +import com.kaltura.androidx.media3.common.Format; import com.kaltura.playkit.PKSubtitleFormat; -import com.kaltura.playkit.utils.Consts; public class PKExternalSubtitle implements Parcelable { private String url; private String id; private String mimeType; - private int selectionFlags = Consts.TRACK_UNSELECTED_FLAG; + private @C.SelectionFlags int selectionFlags = C.SELECTION_FLAG_AUTOSELECT; /** * Indicates the track contains subtitles. This flag may be set on video tracks to indicate the * presence of burned in subtitles. @@ -49,7 +48,7 @@ public String getUrl() { return url; } - public int getSelectionFlags() { + public @C.SelectionFlags int getSelectionFlags() { return selectionFlags; } @@ -93,11 +92,11 @@ public PKExternalSubtitle setLabel(@NonNull String label) { public PKExternalSubtitle setDefault() { this.isDefault = true; - setSelectionFlags(Consts.DEFAULT_TRACK_SELECTION_FLAG_HLS); + setSelectionFlags(C.SELECTION_FLAG_DEFAULT | C.SELECTION_FLAG_AUTOSELECT); return this; } - private void setSelectionFlags(int selectionFlags) { + private void setSelectionFlags(@C.SelectionFlags int selectionFlags) { this.selectionFlags = selectionFlags; } diff --git a/playkit/src/main/java/com/kaltura/playkit/player/PKHttpClientManager.java b/playkit/src/main/java/com/kaltura/playkit/player/PKHttpClientManager.java index 64bf1af3d..3cabc5733 100644 --- a/playkit/src/main/java/com/kaltura/playkit/player/PKHttpClientManager.java +++ b/playkit/src/main/java/com/kaltura/playkit/player/PKHttpClientManager.java @@ -1,6 +1,6 @@ package com.kaltura.playkit.player; -import com.kaltura.android.exoplayer2.upstream.DefaultHttpDataSource; +import com.kaltura.androidx.media3.datasource.DefaultHttpDataSource; import com.kaltura.playkit.PKLog; import com.kaltura.playkit.PlayKitManager; diff --git a/playkit/src/main/java/com/kaltura/playkit/player/PKLowLatencyConfig.java b/playkit/src/main/java/com/kaltura/playkit/player/PKLowLatencyConfig.java new file mode 100644 index 000000000..0e52d643b --- /dev/null +++ b/playkit/src/main/java/com/kaltura/playkit/player/PKLowLatencyConfig.java @@ -0,0 +1,128 @@ +package com.kaltura.playkit.player; + +import com.kaltura.playkit.utils.Consts; + +/** + * Low Latency configuration for the live medias. + *
+ *
+ * If this config is set then player will use `targetOffsetMs` passed in this configuration. + * Player takes an account of the bandwidth as well where it tries to avoid re-buffer while + * approaching to the `targetOffsetMs`. + *
+ *
+ * If app does not pass `PKLowLatencyConfig` + * then if the media manifest contains `suggestedPresentationDelayMs` tag OR `target` value in `Latency` tag + * then player will take those value as target offset. + *
+ *
+ * If nothing fulfills in the above conditions, the default live offset + * is {@link com.kaltura.androidx.media3.exoplayer.source.dash.DashMediaSource#DEFAULT_FALLBACK_TARGET_LIVE_OFFSET_MS}. + *
+ *
+ * It's a good practice to have `availabilityTimeOffset` in DASH manifest, it tells that how much + * earlier the segments are available. + *
+ *
+ * For HLS, `#EXT-X-SERVER-CONTROL` tag should be there. `#EXT-X-PART` should be there for individual + * segments. `#EXT-X-PRELOAD_HINT` is a good practice to have to indicate the next part. + */ +public class PKLowLatencyConfig { + + private long targetOffsetMs; + private long minOffsetMs = Consts.TIME_UNSET; + private long maxOffsetMs = Consts.TIME_UNSET; + private float minPlaybackSpeed = Consts.DEFAULT_FALLBACK_MIN_PLAYBACK_SPEED; + private float maxPlaybackSpeed = Consts.DEFAULT_FALLBACK_MAX_PLAYBACK_SPEED; + + public PKLowLatencyConfig(long targetOffsetMs) { + this.targetOffsetMs = targetOffsetMs; + } + + /** + * Reset the Low Latency Configuration + * Now Player will use the media-defined default values. + */ + public final static PKLowLatencyConfig UNSET = + new PKLowLatencyConfig(Consts.TIME_UNSET) + .setMaxOffsetMs(Consts.TIME_UNSET) + .setMinOffsetMs(Consts.TIME_UNSET) + .setMinPlaybackSpeed(Consts.DEFAULT_FALLBACK_MIN_PLAYBACK_SPEED) + .setMaxPlaybackSpeed(Consts.DEFAULT_FALLBACK_MAX_PLAYBACK_SPEED); + + public long getTargetOffsetMs() { + return targetOffsetMs; + } + + public long getMinOffsetMs() { + return minOffsetMs; + } + + public long getMaxOffsetMs() { + return maxOffsetMs; + } + + public float getMinPlaybackSpeed() { + this.minPlaybackSpeed = minPlaybackSpeed > 0 ? minPlaybackSpeed : Consts.DEFAULT_FALLBACK_MIN_PLAYBACK_SPEED; + return minPlaybackSpeed; + } + + public float getMaxPlaybackSpeed() { + this.maxPlaybackSpeed = maxPlaybackSpeed > 0 ? maxPlaybackSpeed : Consts.DEFAULT_FALLBACK_MAX_PLAYBACK_SPEED; + return maxPlaybackSpeed; + } + + /** + * Target live offset, in milliseconds, or {@link Consts#TIME_UNSET} to use the + * media-defined default. + * The player will attempt to get close to this live offset during playback if possible. + */ + public PKLowLatencyConfig setTargetOffsetMs(long targetOffsetMs) { + this.targetOffsetMs = targetOffsetMs; + return this; + } + + /** + * The minimum allowed live offset, in milliseconds, or {@link Consts#TIME_UNSET} + * to use the media-defined default. + * Even when adjusting the offset to current network conditions, + * the player will not attempt to get below this offset during playback. + */ + public PKLowLatencyConfig setMinOffsetMs(long minOffsetMs) { + this.minOffsetMs = minOffsetMs; + return this; + } + + /** + * The maximum allowed live offset, in milliseconds, or {@link Consts#TIME_UNSET} + * to use the media-defined default. + * Even when adjusting the offset to current network conditions, + * the player will not attempt to get above this offset during playback. + */ + public PKLowLatencyConfig setMaxOffsetMs(long maxOffsetMs) { + this.maxOffsetMs = maxOffsetMs; + return this; + } + + /** + * Minimum playback speed, or {@link Consts#RATE_UNSET} to use the + * media-defined default. + * The minimum playback speed the player can use to fall back + * when trying to reach the target live offset. + */ + public PKLowLatencyConfig setMinPlaybackSpeed(float minPlaybackSpeed) { + this.minPlaybackSpeed = minPlaybackSpeed > 0 ? minPlaybackSpeed : Consts.DEFAULT_FALLBACK_MIN_PLAYBACK_SPEED; + return this; + } + + /** + * Maximum playback speed, or {@link Consts#RATE_UNSET} to use the + * media-defined default. + * The maximum playback speed the player can use to catch up + * when trying to reach the target live offset. + */ + public PKLowLatencyConfig setMaxPlaybackSpeed(float maxPlaybackSpeed) { + this.maxPlaybackSpeed = maxPlaybackSpeed > 0 ? maxPlaybackSpeed : Consts.DEFAULT_FALLBACK_MAX_PLAYBACK_SPEED; + return this; + } +} diff --git a/playkit/src/main/java/com/kaltura/playkit/player/PKMaxVideoSize.java b/playkit/src/main/java/com/kaltura/playkit/player/PKMaxVideoSize.java index 67b85cd87..4521d4c4e 100644 --- a/playkit/src/main/java/com/kaltura/playkit/player/PKMaxVideoSize.java +++ b/playkit/src/main/java/com/kaltura/playkit/player/PKMaxVideoSize.java @@ -1,6 +1,13 @@ package com.kaltura.playkit.player; -// Sets the maximum allowed video width and height. +/** + * This class is deprecated. + * Please Use {@link com.kaltura.playkit.Player.Settings#setABRSettings(ABRSettings)} + * to achieve this functionality. + * + * Sets the maximum allowed video width and height. + */ +@Deprecated public class PKMaxVideoSize { private int maxVideoWidth = Integer.MAX_VALUE; diff --git a/playkit/src/main/java/com/kaltura/playkit/player/PKMediaSourceConfig.java b/playkit/src/main/java/com/kaltura/playkit/player/PKMediaSourceConfig.java index 10d781ecd..c3ca6a214 100644 --- a/playkit/src/main/java/com/kaltura/playkit/player/PKMediaSourceConfig.java +++ b/playkit/src/main/java/com/kaltura/playkit/player/PKMediaSourceConfig.java @@ -13,6 +13,9 @@ package com.kaltura.playkit.player; import android.net.Uri; +import android.text.TextUtils; + +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.kaltura.playkit.PKMediaConfig; @@ -33,27 +36,37 @@ public class PKMediaSourceConfig { PlayerSettings playerSettings; private VRSettings vrSettings; private List externalSubtitlesList; + private String externalVttThumbnailUrl; - public PKMediaSourceConfig(PKMediaSource source, PKMediaEntry.MediaEntryType mediaEntryType, List externalSubtitlesList, PlayerSettings playerSettings, VRSettings vrSettings) { + public PKMediaSourceConfig(PKMediaSource source, PKMediaEntry.MediaEntryType mediaEntryType, List externalSubtitlesList, String externalVttThumbnailUrl, PlayerSettings playerSettings, VRSettings vrSettings) { this.mediaSource = source; this.mediaEntryType = (mediaEntryType != null) ? mediaEntryType : PKMediaEntry.MediaEntryType.Unknown; this.playerSettings = playerSettings; this.vrSettings = vrSettings; this.externalSubtitlesList = externalSubtitlesList; + this.externalVttThumbnailUrl = externalVttThumbnailUrl; } public PKMediaSourceConfig(PKMediaConfig mediaConfig, PKMediaSource source, PlayerSettings playerSettings) { this.mediaSource = source; - this.mediaEntryType = (mediaConfig != null && mediaConfig.getMediaEntry() != null) ? mediaConfig.getMediaEntry().getMediaType() : PKMediaEntry.MediaEntryType.Unknown; this.playerSettings = playerSettings; - if (mediaConfig != null && mediaConfig.getMediaEntry() != null && mediaConfig.getMediaEntry().isVRMediaType()) { - this.vrSettings = playerSettings.getVRSettings() != null ? playerSettings.getVRSettings() : new VRSettings(); + + if (mediaConfig != null && mediaConfig.getMediaEntry() != null) { + PKMediaEntry mediaConfigEntry = mediaConfig.getMediaEntry(); + this.mediaEntryType = (mediaConfigEntry.getMediaType() != null) ? mediaConfigEntry.getMediaType() : PKMediaEntry.MediaEntryType.Unknown; + + if (mediaConfigEntry.isVRMediaType()) { + this.vrSettings = (playerSettings.getVRSettings() != null) ? playerSettings.getVRSettings() :new VRSettings(); + } + + this.externalSubtitlesList = (mediaConfigEntry.getExternalSubtitleList() != null) ? mediaConfigEntry.getExternalSubtitleList() : null; + + this.externalVttThumbnailUrl = (!TextUtils.isEmpty(mediaConfigEntry.getExternalVttThumbnailUrl())) ? mediaConfigEntry.getExternalVttThumbnailUrl() : null; } - this.externalSubtitlesList = (mediaConfig != null && mediaConfig.getMediaEntry() != null && mediaConfig.getMediaEntry().getExternalSubtitleList() != null) ? mediaConfig.getMediaEntry().getExternalSubtitleList() : null; } - public PKMediaSourceConfig(PKMediaSource source, PKMediaEntry.MediaEntryType mediaEntryType, List externalSubtitlesList, PlayerSettings playerSettings) { - this(source, mediaEntryType, externalSubtitlesList, playerSettings, null); + public PKMediaSourceConfig(PKMediaSource source, PKMediaEntry.MediaEntryType mediaEntryType, List externalSubtitlesList, String externalVttThumbnailUrl, PlayerSettings playerSettings) { + this(source, mediaEntryType, externalSubtitlesList, externalVttThumbnailUrl, playerSettings, null); } public PKRequestParams getRequestParams() { @@ -74,6 +87,15 @@ public List getExternalSubtitleList() { return externalSubtitlesList; } + public String getExternalVttThumbnailUrl() { + return externalVttThumbnailUrl; + } + + @NonNull + public PKMediaEntry.MediaEntryType getMediaEntryType() { + return mediaEntryType; + } + @Override public boolean equals(Object o) { if (this == o) diff --git a/playkit/src/main/java/com/kaltura/playkit/player/PKPlayerErrorType.java b/playkit/src/main/java/com/kaltura/playkit/player/PKPlayerErrorType.java index f196b7c34..dd420374d 100644 --- a/playkit/src/main/java/com/kaltura/playkit/player/PKPlayerErrorType.java +++ b/playkit/src/main/java/com/kaltura/playkit/player/PKPlayerErrorType.java @@ -28,7 +28,9 @@ public enum PKPlayerErrorType { LOAD_ERROR(7007), OUT_OF_MEMORY(7008), REMOTE_COMPONENT_ERROR(7009), - TIMEOUT(7010); + TIMEOUT(7010), + IO_ERROR(7011), + MISCELLANEOUS(7012); public final int errorCode; diff --git a/playkit/src/main/java/com/kaltura/playkit/player/PKSubtitlePosition.java b/playkit/src/main/java/com/kaltura/playkit/player/PKSubtitlePosition.java index e6dc38120..16c15f8fa 100644 --- a/playkit/src/main/java/com/kaltura/playkit/player/PKSubtitlePosition.java +++ b/playkit/src/main/java/com/kaltura/playkit/player/PKSubtitlePosition.java @@ -11,9 +11,9 @@ public class PKSubtitlePosition { private int LINE_TYPE_FRACTION = 0; - private float DIMEN_UNSET = -Float.MAX_VALUE; + private final float DIMEN_UNSET = -Float.MAX_VALUE; - private int TYPE_UNSET = Integer.MIN_VALUE; + private final int TYPE_UNSET = Integer.MIN_VALUE; // Override the current subtitle Positioning with the In-stream subtitle text track configuration private boolean overrideInlineCueConfig; @@ -61,39 +61,12 @@ public int getLineType() { return lineType; } - /** - * Allow the subtitle view to move left (ALIGN_NORMAL), middle (ALIGN_CENTER) and right (ALIGN_OPPOSITE) - * For RTL texts, Allow the subtitle view to move Right (ALIGN_NORMAL), middle (ALIGN_CENTER) and left (ALIGN_OPPOSITE) - * - * Set the horizontal(Left/Right viewport) positions, percentage starts from - * center(10 is for 10%) to Left/Right(100 is for 100%) - * - * @param horizontalPositionPercentage percentage to left/right viewport from center - * @param horizontalPosition subtitle view positioning - * @return PKSubtitlePosition - */ - private PKSubtitlePosition setHorizontalPositionLevel(float horizontalPositionPercentage, Layout.Alignment horizontalPosition) { - this.subtitleHorizontalPosition = horizontalPosition; - this.horizontalPositionPercentage = horizontalPositionPercentage; - return this; - } - - /** - * Set the vertical(Top to Bottom) positions, percentage starts from - * Top(10 is for 10%) to Bottom(100 is for 100%) - * - * @param verticalPositionPercentage percentage to vertical viewport from top to bottom - * @return PKSubtitlePosition - */ - private PKSubtitlePosition setVerticalPositionLevel(float verticalPositionPercentage) { - this.verticalPositionPercentage = verticalPositionPercentage; - return this; - } - /** * Set the subtitle position any where on the video frame. This method allows to move in X-Y coordinates * To set the subtitle only in vertical direction (Y - coordinate) use {@link PKSubtitlePosition#setVerticalPosition(int)} * + * If horizontal alignment and horizontal position is not valid then treat it as {@link PKSubtitlePosition#setVerticalPosition(int)} + * * @param horizontalPositionPercentage Set the horizontal(Left/Right viewport) positions, percentage starts from * center(10 is for 10%) to Left/Right(100 is for 100%) * @@ -139,15 +112,16 @@ public PKSubtitlePosition setVerticalPosition(int verticalPositionPercentage) { } /** - * If `overrideInlineCueConfig` is false that mean; app does not want to override the inline Cue configuration. + * If `overrideInlineCueConfig` is `false` that mean; app does not want to override the inline Cue configuration. * App wants to go with Cue configuration. - * BUT Beware that it will call {@link PKSubtitlePosition#setOverrideInlineCueConfig(boolean)} with false value + * + * BUT Beware that it will call {@link PKSubtitlePosition#setOverrideInlineCueConfig(boolean)} with `false` value * means after that in next call, app needs to {@link PKSubtitlePosition#setOverrideInlineCueConfig(boolean)} * with another value. * * OTHERWISE * - * If `overrideInlineCueConfig` is true then it will move subtitle to Bottom-Center which is a standard position for it + * If `overrideInlineCueConfig` is `true` then it will move subtitle to Bottom-Center which is a standard position for it * * @return PKSubtitlePosition */ @@ -177,6 +151,32 @@ public PKSubtitlePosition setOverrideInlineCueConfig(boolean overrideInlineCueCo return this; } + + /** + * Allow the subtitle view to move left (ALIGN_NORMAL), middle (ALIGN_CENTER) and right (ALIGN_OPPOSITE) + * For RTL texts, Allow the subtitle view to move Right (ALIGN_NORMAL), middle (ALIGN_CENTER) and left (ALIGN_OPPOSITE) + * + * Set the horizontal(Left/Right viewport) positions, percentage starts from + * center(10 is for 10%) to Left/Right(100 is for 100%) + * + * @param horizontalPositionPercentage percentage to left/right viewport from center + * @param horizontalPosition subtitle view positioning + */ + private void setHorizontalPositionLevel(float horizontalPositionPercentage, Layout.Alignment horizontalPosition) { + this.subtitleHorizontalPosition = horizontalPosition; + this.horizontalPositionPercentage = horizontalPositionPercentage; + } + + /** + * Set the vertical(Top to Bottom) positions, percentage starts from + * Top(10 is for 10%) to Bottom(100 is for 100%) + * + * @param verticalPositionPercentage percentage to vertical viewport from top to bottom + */ + private void setVerticalPositionLevel(float verticalPositionPercentage) { + this.verticalPositionPercentage = verticalPositionPercentage; + } + private float checkPositionPercentageLimit(int positionPercentage) { float position; if (positionPercentage < positionLowerLimit || positionPercentage > Consts.DEFAULT_MAX_SUBTITLE_POSITION) { diff --git a/playkit/src/main/java/com/kaltura/playkit/player/PKTracks.java b/playkit/src/main/java/com/kaltura/playkit/player/PKTracks.java index 1f97c1194..1ac6f8b10 100644 --- a/playkit/src/main/java/com/kaltura/playkit/player/PKTracks.java +++ b/playkit/src/main/java/com/kaltura/playkit/player/PKTracks.java @@ -26,21 +26,26 @@ public class PKTracks { protected int defaultVideoTrackIndex; protected int defaultAudioTrackIndex; protected int defaultTextTrackIndex; + protected int defaultImageTrackIndex; + private List videoTracks; private List audioTracks; private List textTracks; + private List imageTracks; - public PKTracks(List videoTracks, List audioTracks, List textTracks, - int defaultVideoTrackIndex, int defaultAudioTrackIndex, int defaultTextTrackIndex) { + public PKTracks(List videoTracks, List audioTracks, List textTracks, List imageTracks, + int defaultVideoTrackIndex, int defaultAudioTrackIndex, int defaultTextTrackIndex, int defaultImageTrackIndex) { this.audioTracks = audioTracks; this.videoTracks = videoTracks; this.textTracks = textTracks; + this.imageTracks = imageTracks; this.defaultVideoTrackIndex = defaultVideoTrackIndex; this.defaultAudioTrackIndex = defaultAudioTrackIndex; this.defaultTextTrackIndex = defaultTextTrackIndex; + this.defaultImageTrackIndex = defaultImageTrackIndex; } /** @@ -82,6 +87,19 @@ public List getTextTracks() { return textTracks; } + /** + * Getter for imageTracks list. + * Before use, the list entry's should be casted to {@link ImageTrack} in order to receive the + * full track info of that type. + * Can be empty, if no tracks available. + * + * @return - the list of all available Image tracks, that can be used for image preview feature. + */ + @NonNull + public List getImageTracks() { + return imageTracks; + } + /** * Getter for default video track index. * The one that will be selected by player based on the media manifest. @@ -114,5 +132,16 @@ public int getDefaultAudioTrackIndex() { public int getDefaultTextTrackIndex() { return defaultTextTrackIndex; } + + /** + * Getter for default image track index. + * The one that will be selected by player based on the media manifest. + * If no default available in the manifest, the index will be 0. + * + * @return - the index of the track that is set by default. + */ + public int getDefaultImageTrackIndex() { + return defaultImageTrackIndex; + } } diff --git a/playkit/src/main/java/com/kaltura/playkit/player/PlayerController.java b/playkit/src/main/java/com/kaltura/playkit/player/PlayerController.java index eeb072541..b07bbc79f 100644 --- a/playkit/src/main/java/com/kaltura/playkit/player/PlayerController.java +++ b/playkit/src/main/java/com/kaltura/playkit/player/PlayerController.java @@ -16,13 +16,17 @@ import android.os.Handler; import android.os.Looper; +import android.text.TextUtils; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.kaltura.androidx.media3.datasource.cache.Cache; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.EventStream; import com.kaltura.playkit.Assert; import com.kaltura.playkit.PKController; +import com.kaltura.playkit.PKDeviceCapabilities; import com.kaltura.playkit.PKError; import com.kaltura.playkit.PKEvent; import com.kaltura.playkit.PKLog; @@ -30,15 +34,21 @@ import com.kaltura.playkit.PKMediaEntry; import com.kaltura.playkit.PKMediaFormat; import com.kaltura.playkit.PKMediaSource; +import com.kaltura.playkit.PKTracksAvailableStatus; import com.kaltura.playkit.Player; import com.kaltura.playkit.PlayerEngineWrapper; import com.kaltura.playkit.PlayerEvent; +import com.kaltura.playkit.Utils; import com.kaltura.playkit.ads.AdController; import com.kaltura.playkit.ads.AdsPlayerEngineWrapper; +import com.kaltura.playkit.ads.AdvertisingConfig; +import com.kaltura.playkit.ads.PKAdvertisingController; import com.kaltura.playkit.player.metadata.URIConnectionAcquiredInfo; +import com.kaltura.playkit.player.thumbnail.ThumbnailInfo; import com.kaltura.playkit.utils.Consts; import java.io.IOException; +import java.util.List; import java.util.UUID; import static com.kaltura.playkit.utils.Consts.MILLISECONDS_MULTIPLIER; @@ -68,8 +78,11 @@ public class PlayerController implements Player { private UUID playerSessionId = UUID.randomUUID(); private long targetSeekPosition; + private boolean isVideoTracksUpdated; + private boolean isVideoTracksReset; private boolean isNewEntry = true; private boolean isPlayerStopped; + private String lastRedirectedUrl; private Handler handler = new Handler(Looper.getMainLooper()); @@ -80,7 +93,7 @@ public class PlayerController implements Player { private PlayerEngine.EventListener eventTrigger = initEventListener(); private PlayerEngine.StateChangedListener stateChangedTrigger = initStateChangeListener(); private PlayerEngineWrapper playerEngineWrapper; - + private Cache downloadCache; public PlayerController(Context context) { this.context = context; @@ -189,6 +202,11 @@ public void prepare(@NonNull PKMediaConfig mediaConfig) { } } + @Override + public void setAdvertising(@NonNull PKAdvertisingController pkAdvertisingController, @Nullable AdvertisingConfig advertisingConfig) { + Assert.shouldNeverHappen(); + } + /** * Responsible for preparing source configurations before loading it to actual player. * @@ -257,6 +275,10 @@ private void switchPlayersIfRequired(PlayerEngineType incomingPlayerType) { //Initialize new PlayerEngine. try { player = PlayerEngineFactory.initializePlayerEngine(context, incomingPlayerType, playerSettings, rootPlayerView); + if (downloadCache != null) { + player.setDownloadCache(downloadCache); + } + if (playerEngineWrapper != null) { playerEngineWrapper.setPlayerEngine(player); player = playerEngineWrapper; @@ -306,13 +328,15 @@ public void destroy() { mediaConfig = null; eventListener = null; currentPlayerType = PlayerEngineType.Unknown; + lastRedirectedUrl = null; } @Override public void stop() { log.v("stop"); if (eventListener != null && !isPlayerStopped) { - PlayerEvent event = new PlayerEvent.Generic(PlayerEvent.Type.STOPPED); + PlayerEvent event = new PlayerEvent.Stopped(PlayerEvent.Type.STOPPED, + getMediaSource() != null ? getMediaSource().getUrl() : null); cancelUpdateProgress(); log.d("stop() isForceSinglePlayerEngine = " + playerSettings.isForceSinglePlayerEngine()); @@ -325,6 +349,7 @@ public void stop() { if (assertPlayerIsNotNull("stop()")) { player.stop(); } + lastRedirectedUrl = null; } } @@ -396,6 +421,14 @@ public long getBufferedPosition() { return Consts.POSITION_UNSET; } + public long getCurrentLiveOffset() { + log.v("getCurrentLiveOffset"); + if (assertPlayerIsNotNull("getCurrentLiveOffset()")) { + return player.getCurrentLiveOffset(); + } + return Consts.POSITION_UNSET; + } + public void seekTo(long position) { log.v("seek to " + position); if (assertPlayerIsNotNull("seekTo()")) { @@ -404,6 +437,14 @@ public void seekTo(long position) { } } + @Override + public void seekToLiveDefaultPosition() { + log.v("seekToLiveDefaultPosition"); + if (assertPlayerIsNotNull("seekToLiveDefaultPosition()") && player.isLive()) { + player.seekToDefaultPosition(); + } + } + public void play() { log.v("play"); if (assertPlayerIsNotNull("play()")) { @@ -488,10 +529,43 @@ public void onDecoderDisabled(int skippedOutputBufferCount, int renderedOutputBu eventListener.onEvent(new PlayerEvent.OutputBufferCountUpdate(skippedOutputBufferCount, renderedOutputBufferCount)); } } + + @Override + public void onVideoDisabled() { + if (eventListener != null) { + eventListener.onEvent(new PlayerEvent(PlayerEvent.videoTracksDisabled)); + } + } + + @Override + public void onVideoEnabled() { + if (eventListener != null) { + eventListener.onEvent(new PlayerEvent(PlayerEvent.videoTracksEnabled)); + } + } + + @Override + public void onManifestRedirected(String redirectedUrl) { + boolean isNewRedirectUrl = !TextUtils.equals(lastRedirectedUrl, redirectedUrl); + + if (TextUtils.isEmpty(redirectedUrl) || !isNewRedirectUrl) { + log.v("onManifestRedirected event ignored"); + return; + } + + if (eventListener != null) { + eventListener.onEvent(new PlayerEvent.ManifestRedirected(redirectedUrl)); + } + + lastRedirectedUrl = redirectedUrl; + player.setRedirectedManifestURL(redirectedUrl); + } }); + player.setInputFormatChangedListener(true); } else { player.setEventListener(null); player.setStateChangedListener(null); + player.setInputFormatChangedListener(null); player.setAnalyticsListener(null); } } @@ -524,6 +598,13 @@ public void removeListener(@NonNull PKEvent.Listener listener) { Assert.shouldNeverHappen(); } + @NonNull + @Override + public List getLoadedPluginsByType(Class pluginClass) { + Assert.shouldNeverHappen(); + return null; + } + @Override public void updatePluginConfig(@NonNull String pluginName, @Nullable Object pluginConfig) { Assert.shouldNeverHappen(); @@ -534,7 +615,7 @@ public void onApplicationPaused() { log.d("onApplicationPaused"); profiler.onApplicationPaused(); - + lastRedirectedUrl = null; if (isPlayerStopped) { log.e("onApplicationPaused called during player state = STOPPED - return"); return; @@ -620,11 +701,23 @@ public PKMediaFormat getMediaFormat() { return null; } + @Override + public PKMediaSource getMediaSource() { + if (sourceConfig != null) { + return sourceConfig.mediaSource; + } + return null; + } + @Override public void setPlaybackRate(float rate) { log.v("setPlaybackRate"); - if (assertPlayerIsNotNull("setPlaybackRate()")) { - player.setPlaybackRate(rate); + if (rate > 0) { + if (assertPlayerIsNotNull("setPlaybackRate()")) { + player.setPlaybackRate(rate); + } + } else { + log.w("Playback rate should be greater than 0"); } } @@ -637,6 +730,34 @@ public float getPlaybackRate() { return Consts.PLAYBACK_SPEED_RATE_UNKNOWN; } + @Override + public void setDownloadCache(Cache downloadCache) { + log.v("setDownloadCache"); + + if (!PKDeviceCapabilities.isKalturaPlayerAvailable()) { + log.e("CacheDataSource is being used for Prefetch feature. This feature is not available in Playkit SDK. " + + "It is only being used by Kaltura Player SDK."); + return; + } + + if (assertPlayerIsNotNull("setDownloadCache()")) { + player.setDownloadCache(downloadCache); + } + this.downloadCache = downloadCache; + } + + @Override + public ThumbnailInfo getThumbnailInfo(long ... positionMS) { + log.v("getThumbnailInfo"); + if (assertPlayerIsNotNull("getThumbnailInfo()")) { + if (positionMS.length > 0) { + return player.getThumbnailInfo(positionMS[0]); + } else { + return player.getThumbnailInfo(player.getCurrentPosition()); + } + } + return null; + } @Override public void updateSubtitleStyle(SubtitleStyleSettings subtitleStyleSettings) { @@ -647,13 +768,103 @@ public void updateSubtitleStyle(SubtitleStyleSettings subtitleStyleSettings) { } @Override - public void updateSurfaceAspectRatioResizeMode(PKAspectRatioResizeMode resizeMode) { + public void updateSurfaceAspectRatioResizeMode(@NonNull PKAspectRatioResizeMode resizeMode) { log.v("updateSurfaceAspectRatioResizeMode"); if (assertPlayerIsNotNull("updateSurfaceAspectRatioResizeMode")) { player.updateSurfaceAspectRatioResizeMode(resizeMode); } } + @Override + public void updatePKLowLatencyConfig(PKLowLatencyConfig pkLowLatencyConfig) { + log.v("updatePKLowLatencyConfig"); + if (assertPlayerIsNotNull("updatePKLowLatencyConfig")) { + player.updatePKLowLatencyConfig(pkLowLatencyConfig); + } + } + + @Override + public void updateABRSettings(ABRSettings abrSettings) { + log.v("updateABRSettings"); + + if (!isVideoTrackPresent()) { + return; + } + + if (abrSettings == null || abrSettings.equals(ABRSettings.RESET)) { + resetABRSettings(); + return; + } + + if (abrSettings.equals(playerSettings.getAbrSettings())) { + log.w("Existing and Incoming ABR Settings are same"); + return; + } + + if (assertPlayerIsNotNull("updateABRSettings")) { + isVideoTracksUpdated = true; + player.updateABRSettings(abrSettings); + } + } + + @Override + public void updateLoadControlBuffers(LoadControlBuffers loadControlBuffers) { + log.v("updateLoadControlBuffers"); + if (assertPlayerIsNotNull("updateLoadControlBuffers")) { + if (loadControlBuffers != null) { + player.updateLoadControlBuffers(loadControlBuffers); + } + } + } + + @Override + public void disableVideoTracks(boolean isDisabled) { + log.v("disabledVideoTracks"); + if (assertPlayerIsNotNull("disabledVideoTracks")) { + player.disableVideoTracks(isDisabled); + } + } + + @Override + public void disableAudioTracks(boolean isDisabled) { + log.v("disabledAudioTracks"); + if (assertPlayerIsNotNull("disabledAudioTracks")) { + player.disableAudioTracks(isDisabled); + } + } + + @Override + public void disableTextTracks(boolean isDisabled) { + log.v("disableTextTracks"); + if (assertPlayerIsNotNull("disableTextTracks")) { + player.disableTextTracks(isDisabled); + } + } + + @Override + public void resetABRSettings() { + log.v("resetABRSettings"); + + if (!isVideoTrackPresent()) { + return; + } + + if (assertPlayerIsNotNull("resetABRSettings")) { + isVideoTracksReset = true; + player.resetABRSettings(); + } + } + + @Nullable + @Override + public Object getCurrentMediaManifest() { + log.v("getCurrentMediaManifest"); + if (assertPlayerIsNotNull("getCurrentMediaManifest")) { + return player.getCurrentMediaManifest(); + } + return null; + } + @Override public void addListener(Object groupId, Class type, PKEvent.Listener listener) { Assert.shouldNeverHappen(); @@ -669,6 +880,17 @@ public void removeListeners(@NonNull Object groupId) { Assert.shouldNeverHappen(); } + private boolean isVideoTrackPresent() { + if (player != null && + player.getPKTracks() != null && + player.getPKTracks().getVideoTracks() != null && + player.getPKTracks().getVideoTracks().size() == 0) { + log.w("No video track found for this media"); + return false; + } + return true; + } + private boolean assertPlayerIsNotNull(String methodName) { if (player != null) { return true; @@ -708,7 +930,12 @@ private void updateProgress() { if (!isAdDisplayed()) { log.v("updateProgress new position/duration = " + position + "/" + duration); - if (eventListener != null && position > 0 && duration > 0) { + if (position < 0 && Utils.isMulticastMedia(getMediaFormat()) && playerSettings.getMulticastSettings().getExperimentalSeekToDefaultPosition()) { + log.d("udp stream: seeking to 0, avoiding stuck issue on playback start"); + player.seekToDefaultPosition(); // WA for multicast (udp) streams may stuck on loading as Exo is not sending ready event if position is negative (seekToDefaultPosition/0) + } + + if (eventListener != null && position > 0 && (duration > 0 || Utils.isMulticastMedia(getMediaFormat()))) { eventListener.onEvent(new PlayerEvent.PlayheadUpdated(position, bufferPosition, duration)); } } @@ -744,7 +971,9 @@ private PlayerEngine.EventListener initEventListener() { PKEvent event; switch (eventType) { case PLAYING: - updateProgress(); + if (!Utils.isMulticastMedia(sourceConfig.mediaSource.getMediaFormat())) { + updateProgress(); + } event = new PlayerEvent.Generic(eventType); break; case PAUSE: @@ -754,7 +983,7 @@ private PlayerEngine.EventListener initEventListener() { break; case DURATION_CHANGE: event = new PlayerEvent.DurationChanged(getDuration()); - if (getDuration() != Consts.TIME_UNSET && isNewEntry) { + if ((getDuration() != Consts.TIME_UNSET || Utils.isMulticastMedia(getMediaFormat())) && isNewEntry) { if (mediaConfig.getStartPosition() != null) { if (mediaConfig.getStartPosition() * MILLISECONDS_MULTIPLIER > getDuration()) { mediaConfig.setStartPosition(getDuration() / MILLISECONDS_MULTIPLIER); @@ -767,13 +996,24 @@ private PlayerEngine.EventListener initEventListener() { mediaConfig.getStartPosition() > 0) { startPlaybackFrom(mediaConfig.getStartPosition() * MILLISECONDS_MULTIPLIER); } + } else if (isLiveMediaWithDvr() && mediaConfig.getStartPosition() == null) { + player.seekToDefaultPosition(); } isNewEntry = false; isPlayerStopped = false; } break; case TRACKS_AVAILABLE: - event = new PlayerEvent.TracksAvailable(player.getPKTracks()); + PKTracksAvailableStatus pkTracksAvailableStatus = isVideoTracksUpdated ? PKTracksAvailableStatus.UPDATED: PKTracksAvailableStatus.NEW; + if (isVideoTracksReset) { + pkTracksAvailableStatus = PKTracksAvailableStatus.RESET; + } + event = new PlayerEvent.TracksAvailable(player.getPKTracks(), pkTracksAvailableStatus); + isVideoTracksUpdated = false; + isVideoTracksReset = false; + if (Utils.isMulticastMedia(sourceConfig.mediaSource.getMediaFormat())) { + updateProgress(); + } break; case VOLUME_CHANGED: event = new PlayerEvent.VolumeChanged(player.getVolume()); @@ -791,7 +1031,7 @@ private PlayerEngine.EventListener initEventListener() { return; } event = new PlayerEvent.Error(player.getCurrentError()); - if (player.getCurrentError().isFatal()){ + if (player.getCurrentError().isFatal()) { cancelUpdateProgress(); } break; @@ -829,6 +1069,20 @@ private PlayerEngine.EventListener initEventListener() { } event = new PlayerEvent.TextTrackChanged(textTrack); break; + case EVENT_STREAM_CHANGED: + List eventStreamList = player.getEventStreams(); + if (eventStreamList == null || eventStreamList.isEmpty()) { + return; + } + event = new PlayerEvent.EventStreamChanged(eventStreamList); + break; + case IMAGE_TRACK_CHANGED: + ImageTrack imageTrack = (ImageTrack) player.getLastSelectedTrack(Consts.TRACK_TYPE_IMAGE); + if (imageTrack == null) { + return; + } + event = new PlayerEvent.ImageTrackChanged(imageTrack); + break; case PLAYBACK_RATE_CHANGED: event = new PlayerEvent.PlaybackRateChanged(player.getPlaybackRate()); break; diff --git a/playkit/src/main/java/com/kaltura/playkit/player/PlayerEngine.java b/playkit/src/main/java/com/kaltura/playkit/player/PlayerEngine.java index ad3680ee9..4ce5df7be 100644 --- a/playkit/src/main/java/com/kaltura/playkit/player/PlayerEngine.java +++ b/playkit/src/main/java/com/kaltura/playkit/player/PlayerEngine.java @@ -12,6 +12,11 @@ package com.kaltura.playkit.player; +import androidx.annotation.Nullable; + +import com.kaltura.androidx.media3.datasource.cache.Cache; +import com.kaltura.playkit.PKAbrFilter; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.EventStream; import com.kaltura.playkit.PKController; import com.kaltura.playkit.PKError; import com.kaltura.playkit.PlaybackInfo; @@ -19,12 +24,12 @@ import com.kaltura.playkit.PlayerState; import com.kaltura.playkit.player.metadata.PKMetadata; import com.kaltura.playkit.player.metadata.URIConnectionAcquiredInfo; +import com.kaltura.playkit.player.thumbnail.ThumbnailInfo; import com.kaltura.playkit.utils.Consts; import java.io.IOException; import java.util.List; - /** * Interface that connect between {@link PlayerController} and actual player engine * {@link ExoPlayerWrapper} or MediaPlayerWrapper. Depends on the type of media that @@ -102,6 +107,12 @@ public interface PlayerEngine { */ long getBufferedPosition(); + /** + * @return - The Current Live Offset of the media, + * or {@link Consts#TIME_UNSET} if the offset is unknown or player is null. + */ + long getCurrentLiveOffset(); + /** * @return - the volume of the current audio, * with 0 as total silence and 1 as maximum volume up. @@ -126,10 +137,10 @@ public interface PlayerEngine { /** * Override media for video tracks with ABR * - * @param minVideoBitrate - minVideoBitrate. - * @param maxVideoBitrate - maxVideoBitrate. + * @param minAbr - min ABR Value. + * @param maxAbr - max ABR Value. */ - void overrideMediaDefaultABR(long minVideoBitrate, long maxVideoBitrate); + void overrideMediaDefaultABR(long minAbr, long maxAbr, PKAbrFilter pkAbrFilter); /** * Override codec for video tracks when more than 1 codec is available. @@ -144,6 +155,12 @@ public interface PlayerEngine { */ void seekTo(long position); + /** + * Seek player to Live Default Position. + * + */ + default void seekToDefaultPosition() {} + /** * Start players playback from the specified position. * Note! The position is passed in seconds. @@ -187,6 +204,16 @@ public interface PlayerEngine { void setAnalyticsListener(AnalyticsListener analyticsListener); + /** + * Set the inputFormatChanged listener from AnalyticsListener + * This listener is being used to get Video and Audio format + * which is currently being played by the player. + * + * @param enableListener true to add / null to remove listener + */ + void setInputFormatChangedListener(Boolean enableListener); + + void setRedirectedManifestURL(String redirectedManifestURL); /** * Release the current player. * Note, that {@link ExoPlayerWrapper} and {@link TrackSelectionHelper} objects, will be destroyed. @@ -240,6 +267,8 @@ public interface PlayerEngine { BaseTrack getLastSelectedTrack(int renderType); + List getEventStreams(); + boolean isLive(); void setPlaybackRate(float rate); @@ -255,11 +284,40 @@ default void setProfiler(Profiler profiler) {} void updateSubtitleStyle(SubtitleStyleSettings subtitleStyleSettings); /** - * update view size - * @param resizeMode + * Update View Size + * @param resizeMode */ default void updateSurfaceAspectRatioResizeMode(PKAspectRatioResizeMode resizeMode) {} + /** + * Update Low Latency Config + * @param pkLowLatencyConfig + */ + default void updatePKLowLatencyConfig(PKLowLatencyConfig pkLowLatencyConfig) {} + + /** + * Update the ABR Settings + * @param abrSettings + */ + default void updateABRSettings(ABRSettings abrSettings) {} + + /** + * Reset the ABR Settings + */ + default void resetABRSettings() {} + + /** + * Update Load Control Buffers + * @param loadControlBuffers + */ + default void updateLoadControlBuffers(LoadControlBuffers loadControlBuffers) {} + + /** + * Get Current Media Manifest + */ + @Nullable + Object getCurrentMediaManifest(); + /** * Generic getters for playkit controllers. * @@ -274,6 +332,35 @@ default void updateSurfaceAspectRatioResizeMode(PKAspectRatioResizeMode resizeMo */ void onOrientationChanged(); + default void setDownloadCache(Cache downloadCache) {} + + /** + * Get the thumbnail info for playback position + * + * @param positionMS - position in ms. + * @return - the {@link ThumbnailInfo} instance of specified ThumbnailInfo, + * otherwise return null. + */ + default ThumbnailInfo getThumbnailInfo(long positionMS) { return null; } + + /** + * Disable/Enable Video Tracks + * @param isDisabled + */ + default void disableVideoTracks(boolean isDisabled) {} + + /** + * Disable/Enable Audio Tracks + * @param isDisabled + */ + default void disableAudioTracks(boolean isDisabled) {} + + /** + * Disable/Enable Text Tracks + * @param isDisabled + */ + default void disableTextTracks(boolean isDisabled) {} + interface EventListener { void onEvent(PlayerEvent.Type event); } @@ -288,5 +375,8 @@ interface AnalyticsListener { void onConnectionAcquired(URIConnectionAcquiredInfo uriConnectionAcquiredInfo); void onLoadError(IOException error, boolean wasCanceled); void onDecoderDisabled(int skippedOutputBufferCount, int renderedOutputBufferCount); + void onVideoDisabled(); + void onVideoEnabled(); + void onManifestRedirected(String redirectedManifestUrl); } } diff --git a/playkit/src/main/java/com/kaltura/playkit/player/PlayerEngineFactory.java b/playkit/src/main/java/com/kaltura/playkit/player/PlayerEngineFactory.java index 6223b7f31..75ad17499 100644 --- a/playkit/src/main/java/com/kaltura/playkit/player/PlayerEngineFactory.java +++ b/playkit/src/main/java/com/kaltura/playkit/player/PlayerEngineFactory.java @@ -47,7 +47,7 @@ static PlayerEngine initializePlayerEngine(Context context, PlayerEngineType eng //Initialize ExoplayerWrapper for video playback which will use VRView for render purpose. ExoPlayerWrapper exoWrapper = new ExoPlayerWrapper(context, vrPlayerFactory.newVRViewInstance(context), playerSettings, rootPlayerView); - return vrPlayerFactory.newInstance(context, exoWrapper); + return vrPlayerFactory.newInstance(context, exoWrapper, playerSettings.getVRSettings() != null ? playerSettings.getVRSettings() : null); default: return new ExoPlayerWrapper(context, playerSettings, rootPlayerView); diff --git a/playkit/src/main/java/com/kaltura/playkit/player/PlayerSettings.java b/playkit/src/main/java/com/kaltura/playkit/player/PlayerSettings.java index ec74c1912..30f71b8e9 100644 --- a/playkit/src/main/java/com/kaltura/playkit/player/PlayerSettings.java +++ b/playkit/src/main/java/com/kaltura/playkit/player/PlayerSettings.java @@ -12,7 +12,11 @@ package com.kaltura.playkit.player; +import androidx.annotation.NonNull; + +import com.kaltura.playkit.PKDrmParams; import com.kaltura.playkit.PKMediaFormat; +import com.kaltura.playkit.PKRequestConfig; import com.kaltura.playkit.PKRequestParams; import com.kaltura.playkit.PKSubtitlePreference; import com.kaltura.playkit.PKTrackConfig; @@ -26,42 +30,50 @@ public class PlayerSettings implements Player.Settings { private boolean isSurfaceSecured; private boolean cea608CaptionsEnabled; private boolean mpgaAudioFormatEnabled; - private boolean crossProtocolRedirectEnabled; private boolean enableDecoderFallback; - private boolean allowClearLead = true; private boolean adAutoPlayOnResume = true; private boolean vrPlayerEnabled = true; private boolean isVideoViewHidden; - private VideoCodecSettings preferredVideoCodecSettings = new VideoCodecSettings(); - private AudioCodecSettings preferredAudioCodecSettings = new AudioCodecSettings(); private boolean isTunneledAudioPlayback; private boolean handleAudioBecomingNoisyEnabled; - private PKWakeMode wakeMode = PKWakeMode.NONE; private boolean handleAudioFocus; + private boolean shutterStaysOnRenderedFirstFrame; + private boolean muteWhenShutterVisible; + private boolean canReuseCodec = true; + + // Flag helping to check if client app wants to use a single player instance at a time + // Only if IMA plugin is there then only this flag is set to true. + private boolean forceSinglePlayerEngine = false; + private boolean allowChunklessPreparation = true; + // When enabled, the `DefaultTrackSelector` will prefer audio tracks whose channel + // count does not exceed the device output capabilities. + private boolean constrainAudioChannelCountToDeviceCapabilities = false; + + private PKWakeMode wakeMode = PKWakeMode.NONE; + private VideoCodecSettings preferredVideoCodecSettings = new VideoCodecSettings(); + private AudioCodecSettings preferredAudioCodecSettings = new AudioCodecSettings(); private PKSubtitlePreference subtitlePreference = PKSubtitlePreference.INTERNAL; - private Integer maxVideoBitrate; private Integer maxAudioBitrate; private int maxAudioChannelCount = -1; + private MulticastSettings multicastSettings = new MulticastSettings(); private LoadControlBuffers loadControlBuffers = new LoadControlBuffers(); private SubtitleStyleSettings subtitleStyleSettings; private PKAspectRatioResizeMode resizeMode = PKAspectRatioResizeMode.fit; private ABRSettings abrSettings = new ABRSettings(); private VRSettings vrSettings; - /** - * Flag helping to check if client app wants to use a single player instance at a time - * Only if IMA plugin is there then only this flag is set to true. - */ - private boolean forceSinglePlayerEngine = false; + private PKLowLatencyConfig pkLowLatencyConfig; + private PKRequestConfig pkRequestConfig = new PKRequestConfig(); + private DRMSettings drmSettings = new DRMSettings(PKDrmParams.Scheme.WidevineCENC); private PKTrackConfig preferredTextTrackConfig; private PKTrackConfig preferredAudioTrackConfig; private PKMediaFormat preferredMediaFormat = PKMediaFormat.dash; private PKRequestParams.Adapter contentRequestAdapter; private PKRequestParams.Adapter licenseRequestAdapter; private Object customLoadControlStrategy = null; - private PKMaxVideoSize maxVideoSize; - + private int codecFailureRetryCount = -1; + private int codecFailureRetryTimeout = -1; public PKRequestParams.Adapter getContentRequestAdapter() { return contentRequestAdapter; @@ -75,12 +87,8 @@ public boolean useTextureView() { return useTextureView; } - public boolean crossProtocolRedirectEnabled() { - return crossProtocolRedirectEnabled; - } - public boolean allowClearLead() { - return allowClearLead; + return (drmSettings != null) ? drmSettings.getAllowClearlead() : true; } public boolean enableDecoderFallback() { @@ -119,6 +127,10 @@ public PKTrackConfig getPreferredAudioTrackConfig() { return preferredAudioTrackConfig; } + public MulticastSettings getMulticastSettings() { + return multicastSettings; + } + public PKMediaFormat getPreferredMediaFormat() { return preferredMediaFormat; } @@ -151,6 +163,14 @@ public boolean isForceSinglePlayerEngine() { return forceSinglePlayerEngine; } + public boolean isAllowChunklessPreparation() { + return allowChunklessPreparation; + } + + public boolean isConstrainAudioChannelCountToDeviceCapabilities() { + return constrainAudioChannelCountToDeviceCapabilities; + } + public VideoCodecSettings getPreferredVideoCodecSettings() { return preferredVideoCodecSettings; } @@ -163,6 +183,14 @@ public Object getCustomLoadControlStrategy() { return customLoadControlStrategy; } + public int getCodecFailureRetryCount() { + return codecFailureRetryCount; + } + + public int getCodecFailureRetryTimeout() { + return codecFailureRetryTimeout; + } + public boolean isTunneledAudioPlayback() { return isTunneledAudioPlayback; } @@ -179,16 +207,22 @@ public boolean isHandleAudioFocus() { return handleAudioFocus; } - public PKSubtitlePreference getSubtitlePreference() { - return subtitlePreference; + public boolean isShutterStaysOnRenderedFirstFrame() { + return shutterStaysOnRenderedFirstFrame; } - public PKMaxVideoSize getMaxVideoSize() { return maxVideoSize; } + public boolean isMuteWhenShutterVisible() { + return muteWhenShutterVisible; + } - public Integer getMaxVideoBitrate() { - return maxVideoBitrate; + public boolean canReuseCodec() { + return canReuseCodec; } + public PKSubtitlePreference getSubtitlePreference() { + return subtitlePreference; + } + public Integer getMaxAudioBitrate() { return maxAudioBitrate; } @@ -197,6 +231,25 @@ public int getMaxAudioChannelCount() { return maxAudioChannelCount; } + public boolean isForceWidevineL3Playback() { + return (drmSettings != null) ? drmSettings.getIsForceWidevineL3Playback() : false; + } + + public DRMSettings getDRMSettings() { + return drmSettings; + } + + public PKLowLatencyConfig getPKLowLatencyConfig() { + return pkLowLatencyConfig; + } + + public PKRequestConfig getPKRequestConfig() { + if (pkRequestConfig == null) { + pkRequestConfig = new PKRequestConfig(); + } + return pkRequestConfig; + } + @Override public Player.Settings setVRPlayerEnabled(boolean vrPlayerEnabled) { this.vrPlayerEnabled = vrPlayerEnabled; @@ -262,17 +315,18 @@ public Player.Settings setPreferredMediaFormat(PKMediaFormat preferredMediaForma this.preferredMediaFormat = preferredMediaFormat; return this; } - - + @Override public Player.Settings setAllowCrossProtocolRedirect(boolean crossProtocolRedirectEnabled) { - this.crossProtocolRedirectEnabled = crossProtocolRedirectEnabled; + getPKRequestConfig().setCrossProtocolRedirectEnabled(crossProtocolRedirectEnabled); return this; } @Override public Player.Settings allowClearLead(boolean allowClearLead) { - this.allowClearLead = allowClearLead; + if (drmSettings != null) { + drmSettings.setIsAllowClearlead(allowClearLead); + } return this; } @@ -301,7 +355,7 @@ public Player.Settings setABRSettings(ABRSettings abrSettings) { } @Override - public Player.Settings setSurfaceAspectRatioResizeMode(PKAspectRatioResizeMode resizeMode) { + public Player.Settings setSurfaceAspectRatioResizeMode(@NonNull PKAspectRatioResizeMode resizeMode) { this.resizeMode = resizeMode; return this; } @@ -312,6 +366,18 @@ public Player.Settings forceSinglePlayerEngine(boolean forceSinglePlayerEngine) return this; } + @Override + public Player.Settings allowChunklessPreparation(boolean allowChunklessPreparation) { + this.allowChunklessPreparation = allowChunklessPreparation; + return this; + } + + @Override + public Player.Settings constrainAudioChannelCountToDeviceCapabilities(boolean enabled) { + this.constrainAudioChannelCountToDeviceCapabilities = enabled; + return this; + } + @Override public Player.Settings setHideVideoViews(boolean hide) { isVideoViewHidden = hide; @@ -349,6 +415,18 @@ public Player.Settings setCustomLoadControlStrategy(Object customLoadControlStra return this; } + @Override + public Player.Settings setCodecFailureRetryCount(int codecFailureRetryCount) { + this.codecFailureRetryCount = codecFailureRetryCount; + return this; + } + + @Override + public Player.Settings setCodecFailureRetryTimeout(int codecFailureRetryTimeout) { + this.codecFailureRetryTimeout = codecFailureRetryTimeout; + return this; + } + @Override public Player.Settings setTunneledAudioPlayback(boolean isTunneledAudioPlayback) { this.isTunneledAudioPlayback = isTunneledAudioPlayback; @@ -375,6 +453,21 @@ public Player.Settings setHandleAudioFocus(boolean handleAudioFocus) { return this; } + public Player.Settings setShutterStaysOnRenderedFirstFrame(boolean shutterStaysOnRenderedFirstFrame) { + this.shutterStaysOnRenderedFirstFrame = shutterStaysOnRenderedFirstFrame; + return this; + } + + public Player.Settings setMuteWhenShutterVisible(boolean muteWhenShutterVisible) { + this.muteWhenShutterVisible = muteWhenShutterVisible; + return this; + } + + public Player.Settings setCanReuseCodec(boolean canReuseCodec) { + this.canReuseCodec = canReuseCodec; + return this; + } + @Override public Player.Settings setSubtitlePreference(PKSubtitlePreference subtitlePreference) { if (subtitlePreference == null) { @@ -386,27 +479,74 @@ public Player.Settings setSubtitlePreference(PKSubtitlePreference subtitlePrefer } @Override - public Player.Settings setMaxVideoSize(PKMaxVideoSize maxVideoSize) { - this.maxVideoSize = maxVideoSize; + public Player.Settings setMaxVideoSize(@NonNull PKMaxVideoSize maxVideoSize) { + ABRSettings abrSettings = getAbrSettings(); + if (maxVideoSize != null && + (abrSettings.getMaxVideoHeight() == Long.MAX_VALUE || abrSettings.getMaxVideoWidth() == Long.MAX_VALUE)) { + abrSettings.setMaxVideoHeight(maxVideoSize.getMaxVideoHeight()); + abrSettings.setMaxVideoWidth(maxVideoSize.getMaxVideoWidth()); + } return this; } @Override - public Player.Settings setMaxVideoBitrate(Integer maxVideoBitrate) { - this.maxVideoBitrate = maxVideoBitrate; + public Player.Settings setMaxVideoBitrate(@NonNull Integer maxVideoBitrate) { + ABRSettings abrSettings = getAbrSettings(); + if (maxVideoBitrate > 0 && abrSettings.getMaxVideoBitrate() == Long.MAX_VALUE) { + abrSettings.setMaxVideoBitrate(maxVideoBitrate); + } return this; } @Override - public Player.Settings setMaxAudioBitrate(Integer maxAudioBitrate) { + public Player.Settings setMaxAudioBitrate(@NonNull Integer maxAudioBitrate) { this.maxAudioBitrate = maxAudioBitrate; return this; } + @Override public Player.Settings setMaxAudioChannelCount(int maxAudioChannelCount) { this.maxAudioChannelCount = maxAudioChannelCount; return this; } + + @Override + public Player.Settings setMulticastSettings(MulticastSettings multicastSettings) { + if (multicastSettings != null) { + this.multicastSettings = multicastSettings; + } + return this; + } + + @Override + public Player.Settings forceWidevineL3Playback(boolean forceWidevineL3Playback) { + if (drmSettings != null) { + drmSettings.setIsForceWidevineL3Playback(forceWidevineL3Playback); + } + return this; + } + + @Override + public Player.Settings setDRMSettings(DRMSettings drmSettings) { + this.drmSettings = drmSettings; + return this; + } + + @Override + public Player.Settings setPKLowLatencyConfig(PKLowLatencyConfig pkLowLatencyConfig) { + if (pkLowLatencyConfig != null) { + this.pkLowLatencyConfig = pkLowLatencyConfig; + } + return this; + } + + @Override + public Player.Settings setPKRequestConfig(PKRequestConfig pkRequestConfig) { + if (pkRequestConfig != null) { + this.pkRequestConfig = pkRequestConfig; + } + return this; + } } diff --git a/playkit/src/main/java/com/kaltura/playkit/player/Profiler.java b/playkit/src/main/java/com/kaltura/playkit/player/Profiler.java index ab8510d74..43ddb504c 100644 --- a/playkit/src/main/java/com/kaltura/playkit/player/Profiler.java +++ b/playkit/src/main/java/com/kaltura/playkit/player/Profiler.java @@ -2,7 +2,7 @@ import androidx.annotation.NonNull; -import com.kaltura.android.exoplayer2.analytics.AnalyticsListener; +import com.kaltura.androidx.media3.exoplayer.analytics.AnalyticsListener; import com.kaltura.playkit.PKMediaConfig; import okhttp3.EventListener; diff --git a/playkit/src/main/java/com/kaltura/playkit/player/SubtitleStyleSettings.java b/playkit/src/main/java/com/kaltura/playkit/player/SubtitleStyleSettings.java index 632f21a48..ed28597ac 100644 --- a/playkit/src/main/java/com/kaltura/playkit/player/SubtitleStyleSettings.java +++ b/playkit/src/main/java/com/kaltura/playkit/player/SubtitleStyleSettings.java @@ -18,7 +18,7 @@ import androidx.annotation.NonNull; -import com.kaltura.android.exoplayer2.text.CaptionStyleCompat; +import com.kaltura.androidx.media3.ui.CaptionStyleCompat; public class SubtitleStyleSettings { @@ -27,30 +27,36 @@ public enum SubtitleStyleEdgeType { } public enum SubtitleTextSizeFraction { - SUBTITLE_FRACTION_50, SUBTITLE_FRACTION_75, SUBTITLE_FRACTION_100, SUBTITLE_FRACTION_125, SUBTITLE_FRACTION_150, SUBTITLE_FRACTION_200 + SUBTITLE_FRACTION_50, SUBTITLE_FRACTION_75, SUBTITLE_FRACTION_100, SUBTITLE_FRACTION_125, SUBTITLE_FRACTION_150, SUBTITLE_FRACTION_175, SUBTITLE_FRACTION_200 } public enum SubtitleStyleTypeface { DEFAULT, DEFAULT_BOLD, MONOSPACE, SERIF, SANS_SERIF } + public enum SubtitleTypefaceStyle { + NORMAL, BOLD, ITALIC, BOLD_ITALIC + } + private static final float fraction50 = 0.50f; private static final float fraction75 = 0.75f; private static final float fraction100 = 1.0f; private static final float fraction125 = 1.25f; private static final float fraction150 = 1.50f; + private static final float fraction175 = 1.75f; private static final float fraction200 = 2.0f; private int subtitleTextColor = Color.WHITE; private int subtitleBackgroundColor = Color.BLACK; // Recommended fraction values is 1f < subtitleTextSizeFraction < 2.5f with 0.25f Multiplier - // Subtitle TextSize fraction, Default is 1.0f ; {@link com.kaltura.android.exoplayer2.ui.SubtitleView} + // Subtitle TextSize fraction, Default is 1.0f ; {@link com.kaltura.androidx.media3.exoplayer.ui.SubtitleView} private float subtitleTextSizeFraction = fraction100; private int subtitleWindowColor = Color.TRANSPARENT; private int subtitleEdgeType = CaptionStyleCompat.EDGE_TYPE_NONE; private int subtitleEdgeColor = Color.WHITE; private Typeface subtitleTypeface = Typeface.DEFAULT; private String subtitleStyleName; + private boolean overrideCueStyling = true; private PKSubtitlePosition subtitlePosition; public SubtitleStyleSettings(String subtitleStyleName) { @@ -93,6 +99,10 @@ public String getStyleName() { return subtitleStyleName; } + public boolean isOverrideCueStyling() { + return overrideCueStyling; + } + public PKSubtitlePosition getSubtitlePosition() { return subtitlePosition; } @@ -152,6 +162,9 @@ public SubtitleStyleSettings setTextSizeFraction(@NonNull SubtitleTextSizeFracti case SUBTITLE_FRACTION_150: this.subtitleTextSizeFraction = fraction150; break; + case SUBTITLE_FRACTION_175: + this.subtitleTextSizeFraction = fraction175; + break; case SUBTITLE_FRACTION_200: this.subtitleTextSizeFraction = fraction200; break; @@ -164,9 +177,6 @@ public SubtitleStyleSettings setTextSizeFraction(@NonNull SubtitleTextSizeFracti public SubtitleStyleSettings setTypeface(@NonNull SubtitleStyleTypeface subtitleStyleTypeface) { switch (subtitleStyleTypeface) { - case DEFAULT: - subtitleTypeface = Typeface.DEFAULT; - break; case DEFAULT_BOLD: subtitleTypeface = Typeface.DEFAULT_BOLD; break; @@ -179,6 +189,7 @@ public SubtitleStyleSettings setTypeface(@NonNull SubtitleStyleTypeface subtitle case SANS_SERIF: subtitleTypeface = Typeface.SANS_SERIF; break; + case DEFAULT: default: subtitleTypeface = Typeface.DEFAULT; break; @@ -186,6 +197,46 @@ public SubtitleStyleSettings setTypeface(@NonNull SubtitleStyleTypeface subtitle return this; } + public SubtitleStyleSettings setSystemTypeface(String fontFamilyName, SubtitleTypefaceStyle style) { + if (fontFamilyName == null || style == null) { + subtitleTypeface = Typeface.DEFAULT; + } else { + subtitleTypeface = Typeface.create(fontFamilyName, style.ordinal()); + } + return this; + } + + public SubtitleStyleSettings setAssetTypeface(Typeface asstTypeface) { + if (asstTypeface == null) { + subtitleTypeface = Typeface.DEFAULT; + } else { + subtitleTypeface = asstTypeface; + } + return this; + } + + /** + * If the text track cues have the styling inside then passing + * `overrideCueStyling` `false` will disable it and + * styling inside the cue will be used. + * + * If the styling does not exist inside the cue then passed + * styling will be applied. Passing `false` will be having no + * impact in that case because there is no styling inside the cue. + * + * It will override the font size as well. + * + * Default is `true` means enabled, We will override the cue styling + * anyways. + * + * @param overrideCueStyling pass `false` to take cue's styling + * @return SubtitleStyleSettings + */ + public SubtitleStyleSettings overrideCueStyling(boolean overrideCueStyling) { + this.overrideCueStyling = overrideCueStyling; + return this; + } + public SubtitleStyleSettings setSubtitlePosition(PKSubtitlePosition subtitlePosition) { this.subtitlePosition = subtitlePosition; return this; diff --git a/playkit/src/main/java/com/kaltura/playkit/player/TrackSelectionHelper.java b/playkit/src/main/java/com/kaltura/playkit/player/TrackSelectionHelper.java index 816b0d180..e699d3ae5 100644 --- a/playkit/src/main/java/com/kaltura/playkit/player/TrackSelectionHelper.java +++ b/playkit/src/main/java/com/kaltura/playkit/player/TrackSelectionHelper.java @@ -12,29 +12,43 @@ package com.kaltura.playkit.player; - import android.content.Context; +import android.net.Uri; import android.text.TextUtils; +import android.util.Pair; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.kaltura.android.exoplayer2.C; -import com.kaltura.android.exoplayer2.Format; -import com.kaltura.android.exoplayer2.RendererCapabilities; -import com.kaltura.android.exoplayer2.source.TrackGroup; -import com.kaltura.android.exoplayer2.source.TrackGroupArray; -import com.kaltura.android.exoplayer2.trackselection.DefaultTrackSelector; -import com.kaltura.android.exoplayer2.trackselection.DefaultTrackSelector.SelectionOverride; -import com.kaltura.android.exoplayer2.trackselection.MappingTrackSelector; -import com.kaltura.android.exoplayer2.trackselection.TrackSelection; -import com.kaltura.android.exoplayer2.trackselection.TrackSelectionArray; +import com.kaltura.androidx.media3.common.C; +import com.kaltura.androidx.media3.common.Format; +import com.kaltura.androidx.media3.exoplayer.RendererCapabilities; +import com.kaltura.androidx.media3.common.Tracks; +import com.kaltura.androidx.media3.exoplayer.dashmanifestparser.CustomAdaptationSet; +import com.kaltura.androidx.media3.exoplayer.dashmanifestparser.CustomDashManifest; +import com.kaltura.androidx.media3.exoplayer.dashmanifestparser.CustomFormat; +import com.kaltura.androidx.media3.exoplayer.dashmanifestparser.CustomRepresentation; +import com.kaltura.androidx.media3.common.TrackGroup; +import com.kaltura.androidx.media3.exoplayer.source.TrackGroupArray; +import com.kaltura.androidx.media3.exoplayer.dash.manifest.EventStream; +import com.kaltura.androidx.media3.extractor.text.Subtitle; +import com.kaltura.androidx.media3.extractor.text.webvtt.WebvttCueInfo; +import com.kaltura.androidx.media3.exoplayer.trackselection.DefaultTrackSelector; +import com.kaltura.androidx.media3.exoplayer.trackselection.DefaultTrackSelector.SelectionOverride; +import com.kaltura.androidx.media3.exoplayer.trackselection.MappingTrackSelector; +import com.kaltura.androidx.media3.common.TrackSelectionOverride; +import com.kaltura.androidx.media3.common.TrackSelectionParameters; +import com.kaltura.androidx.media3.common.util.Util; +import com.kaltura.playkit.PKAbrFilter; import com.kaltura.playkit.PKAudioCodec; import com.kaltura.playkit.PKError; import com.kaltura.playkit.PKLog; import com.kaltura.playkit.PKSubtitlePreference; import com.kaltura.playkit.PKTrackConfig; import com.kaltura.playkit.PKVideoCodec; +import com.kaltura.playkit.player.thumbnail.PKWebvttSubtitle; +import com.kaltura.playkit.player.thumbnail.ThumbnailInfo; +import com.kaltura.playkit.player.thumbnail.VttThumbnailDownloader; import com.kaltura.playkit.utils.Consts; import java.util.ArrayList; @@ -42,17 +56,22 @@ import java.util.Collections; import java.util.HashMap; import java.util.Iterator; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.MissingResourceException; - -import static com.kaltura.android.exoplayer2.util.MimeTypes.AUDIO_AAC; -import static com.kaltura.android.exoplayer2.util.MimeTypes.VIDEO_AV1; -import static com.kaltura.android.exoplayer2.util.MimeTypes.VIDEO_H265; -import static com.kaltura.android.exoplayer2.util.MimeTypes.VIDEO_VP8; -import static com.kaltura.android.exoplayer2.util.MimeTypes.VIDEO_VP9; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import static com.kaltura.androidx.media3.common.MimeTypes.AUDIO_AAC; +import static com.kaltura.androidx.media3.common.MimeTypes.VIDEO_AV1; +import static com.kaltura.androidx.media3.common.MimeTypes.VIDEO_H265; +import static com.kaltura.androidx.media3.common.MimeTypes.VIDEO_VP8; +import static com.kaltura.androidx.media3.common.MimeTypes.VIDEO_VP9; import static com.kaltura.playkit.utils.Consts.TRACK_TYPE_AUDIO; +import static com.kaltura.playkit.utils.Consts.TRACK_TYPE_IMAGE; import static com.kaltura.playkit.utils.Consts.TRACK_TYPE_TEXT; import static com.kaltura.playkit.utils.Consts.TRACK_TYPE_UNKNOWN; import static com.kaltura.playkit.utils.Consts.TRACK_TYPE_VIDEO; @@ -62,7 +81,7 @@ * Created by anton.afanasiev on 22/11/2016. */ -class TrackSelectionHelper { +public class TrackSelectionHelper { private static final PKLog log = PKLog.get("TrackSelectionHelper"); @@ -75,29 +94,41 @@ class TrackSelectionHelper { private static final int TRACK_RENDERERS_AMOUNT = 3; static final String NONE = "none"; + static final String DISABLED = "disabled"; private static final String ADAPTIVE = "adaptive"; private static final String VIDEO_PREFIX = "Video:"; private static final String AUDIO_PREFIX = "Audio:"; private static final String TEXT_PREFIX = "Text:"; + private static final String IMAGE_PREFIX = "Image:"; private static final String CEA_608 = "application/cea-608"; private static final String LANGUAGE_UNKNOWN = "Unknown"; private final Context context; private final DefaultTrackSelector selector; - private TrackSelectionArray trackSelectionArray; + private Tracks tracksInfo; private MappingTrackSelector.MappedTrackInfo mappedTrackInfo; + private TrackSelectionParameters trackSelectionParameters; + private TrackSelectionParameters.Builder trackSelectionParametersBuilder; private List videoTracks = new ArrayList<>(); - private List audioTracks = new ArrayList<>(); - private List textTracks = new ArrayList<>(); + private List originalVideoTracks; + private final List audioTracks = new ArrayList<>(); + private final List textTracks = new ArrayList<>(); + private final List imageTracks = new ArrayList<>(); - private Map>> subtitleListMap = new HashMap<>(); - private Map> videoTracksCodecsMap = new HashMap<>(); - private Map> audioTracksCodecsMap = new HashMap<>(); + private @Nullable Format currentVideoFormat; + private @Nullable Format currentAudioFormat; + + private Map, ThumbnailInfo> externalVttThumbnailRangesInfo; + private final Map>> subtitleListMap = new HashMap<>(); + private final Map> videoTracksCodecsMap = new HashMap<>(); + private final Map> audioTracksCodecsMap = new HashMap<>(); private String[] lastSelectedTrackIds; + + private String[] lastDisabledTrackIds; private String[] requestedChangeTrackIds; // To know if application passed the external subtitles @@ -111,25 +142,32 @@ class TrackSelectionHelper { private TracksErrorListener tracksErrorListener; private PlayerSettings playerSettings; + private String mediaItemManifestURL; + private String redirectPlaybackURL; interface TracksInfoListener { void onTracksInfoReady(PKTracks PKTracks); - void onRelease(String[] selectedTracks); + void onRelease(String[] selectedTracks, String[] disbledTrackIds); void onVideoTrackChanged(); void onAudioTrackChanged(); void onTextTrackChanged(); + + void onImageTrackChanged(); + + void onEventStreamsChanged(List eventStreamList); } interface TracksErrorListener { - void onTracksOverrideABRError(PKError pkError); void onUnsupportedVideoTracksError(PKError pkError); - + void onUnsupportedAudioTracksError(PKError pkError); + void onUnsupportedAudioVideoTracksError(PKError pkError); + void onUnsupportedTracksAvailableError(PKError pkError); } enum TrackType { @@ -140,12 +178,38 @@ enum TrackType { * @param selector The track selector. * @param lastSelectedTrackIds - last selected track id`s. */ - TrackSelectionHelper(Context context, DefaultTrackSelector selector, - String[] lastSelectedTrackIds) { + public TrackSelectionHelper(Context context, DefaultTrackSelector selector, + String[] lastSelectedTrackIds, String[] lastDisabledTrackIds) { this.context = context; this.selector = selector; this.lastSelectedTrackIds = lastSelectedTrackIds; + this.lastDisabledTrackIds = lastDisabledTrackIds; this.requestedChangeTrackIds = Arrays.copyOf(lastSelectedTrackIds, lastSelectedTrackIds.length); + trackSelectionParameters = TrackSelectionParameters.getDefaults(context); + trackSelectionParametersBuilder = trackSelectionParameters.buildUpon(); + } + + public void setMappedTrackInfo(MappingTrackSelector.MappedTrackInfo mappedTrackInfo) { + this.mappedTrackInfo = mappedTrackInfo; + if (playerSettings == null) { + this.playerSettings = new PlayerSettings(); + } + } + + public void setRedirectedManifestURL(String mediaItemManifestURL, String redirectPlaybackURL) { + this.mediaItemManifestURL = mediaItemManifestURL; + this.redirectPlaybackURL = redirectPlaybackURL; + } + + public void clearPreviousMediaOverrides() { + if (trackSelectionParametersBuilder != null && selector != null) { + // We are specially doing it for Text Track because we disable the tracks only for Text tracks. + trackSelectionParametersBuilder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false); + // Clearing all the overrides + trackSelectionParametersBuilder.clearOverrides(); + // Updating the default track selector of exoplayer + selector.setParameters(trackSelectionParametersBuilder.build()); + } } /** @@ -156,18 +220,63 @@ enum TrackType { * * @return - true if tracks data created successful, if mappingTrackInfo not ready return false. */ - boolean prepareTracks(TrackSelectionArray trackSelections) { - trackSelectionArray = trackSelections; + boolean prepareTracks(Tracks trackSelections, String externalThumbnailWebVttUrl, CustomDashManifest customDashManifest) { + if (assertTrackSelectorIsNull("prepareTracks")) { + return false; + } + + tracksInfo = trackSelections; mappedTrackInfo = selector.getCurrentMappedTrackInfo(); if (mappedTrackInfo == null) { log.w("Trying to get current MappedTrackInfo returns null"); return false; } - warnAboutUnsupportedRenderTypes(); - PKTracks tracksInfo = buildTracks(); + + if (checkTracksUnavailability(mappedTrackInfo)) { + String errorMessage = "No audio, video and text track found"; + PKError currentError = new PKError(PKPlayerErrorType.UNEXPECTED, PKError.Severity.Fatal, errorMessage, new IllegalStateException(errorMessage)); + tracksErrorListener.onUnsupportedTracksAvailableError(currentError); + return false; + } + + warnAboutUnsupportedRendererTypes(); + + List rawImageTracks = new ArrayList<>(); + List eventStreamList = new ArrayList<>(); + + if (customDashManifest != null && customDashManifest.getPeriodCount() > 0) { + for (int periodIndex = 0; periodIndex < customDashManifest.getPeriodCount(); periodIndex++) { + + eventStreamList.addAll(customDashManifest.getPeriod(periodIndex).eventStreams); + List adaptationSets = customDashManifest.getPeriod(periodIndex).adaptationSets; + + for (int adaptationSetIndex = 0 ; adaptationSetIndex < adaptationSets.size() ; adaptationSetIndex++) { + if (adaptationSets.get(adaptationSetIndex).type != C.TRACK_TYPE_IMAGE) { + continue; + } + List representations = adaptationSets.get(adaptationSetIndex).representations; + for (CustomRepresentation representation : representations) { + if (representation.format == null || representation.format.formatThumbnailInfo == null) { + continue; + } + + rawImageTracks.add(representation.format); + } + } + } + } + + PKTracks tracksInfo = buildTracks(externalThumbnailWebVttUrl, rawImageTracks); if (tracksInfoListener != null) { tracksInfoListener.onTracksInfoReady(tracksInfo); + if (!tracksInfo.getImageTracks().isEmpty()) { + tracksInfoListener.onImageTrackChanged(); + } + + if (!eventStreamList.isEmpty()) { + tracksInfoListener.onEventStreamsChanged(eventStreamList); + } } return true; @@ -177,7 +286,7 @@ boolean prepareTracks(TrackSelectionArray trackSelections) { * Actually build {@link PKTracks} object, based on the loaded manifest into Exoplayer. * This method knows how to filter unsupported/unknown formats, and create adaptive option when this is possible. */ - private PKTracks buildTracks() { + public PKTracks buildTracks(String externalThumbnailWebVttUrl, List rawImageTracks) { clearTracksLists(); @@ -217,6 +326,12 @@ private PKTracks buildTracks() { continue; } + if (format.roleFlags == C.ROLE_FLAG_TRICK_PLAY) { + // ROLE_FLAG_TRICK_PLAY is not a video track + // in future handle that in hls case for thumbnailInfo + continue; + } + if (!videoTracksAvailable) { videoTracksAvailable = true; } @@ -235,8 +350,8 @@ private PKTracks buildTracks() { PKAudioCodec currentAudioTrackCodec = null; AudioTrack currentAudioTrack = null; - if (format.language == null && format.codecs == null) { - if (playerSettings != null && playerSettings.mpgaAudioFormatEnabled() && format.id != null && format.id.matches("\\d+/\\d+")) { + if (format.language == null && format.id != null && format.id.matches("\\d+/\\d+")) { + if (playerSettings != null && playerSettings.mpgaAudioFormatEnabled()) { currentAudioTrackCodec = PKAudioCodec.AAC; currentAudioTrack = new AudioTrack(uniqueId, format.id, format.label, format.bitrate, format.channelCount, format.selectionFlags, false, currentAudioTrackCodec, AUDIO_AAC); audioTracks.add(currentAudioTrack); @@ -257,8 +372,22 @@ private PKTracks buildTracks() { } break; case TRACK_TYPE_TEXT: - if (format.language != null && hasExternalSubtitles && discardTextTrackOnPreference(format)) { - continue; + if (format.language != null && hasExternalSubtitles) { + int selectionFlag = format.selectionFlags; + if (selector != null && (selectionFlag == Consts.DEFAULT_TRACK_SELECTION_FLAG_HLS || selectionFlag == Consts.DEFAULT_TRACK_SELECTION_FLAG_DASH)) { + DefaultTrackSelector.Parameters.Builder parametersBuilder = selector.getParameters().buildUpon(); + + if (discardTextTrackOnPreference(format)) { + trackSelectionParametersBuilder.clearOverride(trackGroup); + parametersBuilder.setRendererDisabled(TRACK_TYPE_TEXT, true); + continue; + } else { + TrackSelectionOverride trackSelectionOverride = new TrackSelectionOverride(trackGroup, Collections.singletonList(trackIndex)); + parametersBuilder.addOverride(trackSelectionOverride); + } + + selector.setParameters(parametersBuilder); + } } if (CEA_608.equals(format.sampleMimeType)) { @@ -277,17 +406,272 @@ private PKTracks buildTracks() { } } + if (!TextUtils.isEmpty(externalThumbnailWebVttUrl)) { + createVttThumbnailImageTrack(externalThumbnailWebVttUrl); + } else if (rawImageTracks != null && !rawImageTracks.isEmpty()) { + createDashThumbnailImageTracks(rawImageTracks); + } + //add disable option to the text tracks. maybeAddDisabledTextTrack(); + + // We intend to sort the video and audio tracks after filtration so that + // while taking the default track index. Indexing of track will not get any impact. videoTracks = filterVideoTracks(); - //Leave only adaptive audio tracks for user selection. + Collections.sort(videoTracks); + + //Leave only adaptive audio tracks for user selection if no extra config is set. ArrayList filteredAudioTracks = filterAdaptiveAudioTracks(); + Collections.sort(filteredAudioTracks); int defaultVideoTrackIndex = getDefaultTrackIndex(videoTracks, lastSelectedTrackIds[TRACK_TYPE_VIDEO]); int defaultAudioTrackIndex = getDefaultTrackIndex(filteredAudioTracks, lastSelectedTrackIds[TRACK_TYPE_AUDIO]); int defaultTextTrackIndex = getDefaultTrackIndex(textTracks, lastSelectedTrackIds[TRACK_TYPE_TEXT]); - Collections.sort(videoTracks); - return new PKTracks(videoTracks, filteredAudioTracks, textTracks, defaultVideoTrackIndex, defaultAudioTrackIndex, defaultTextTrackIndex); + int defaultImageTrackIndex = getDefaultTrackIndex(imageTracks, lastSelectedTrackIds[TRACK_TYPE_IMAGE]); + + return new PKTracks(videoTracks, filteredAudioTracks, textTracks, imageTracks, defaultVideoTrackIndex, defaultAudioTrackIndex, defaultTextTrackIndex, defaultImageTrackIndex); + } + + private void createDashThumbnailImageTracks(List rawImageTracks) { + for (int trackIndex = 0; trackIndex < rawImageTracks.size(); trackIndex++) { + CustomFormat imageFormat = rawImageTracks.get(trackIndex); + CustomFormat.FormatThumbnailInfo formatThumbnailInfo = imageFormat.formatThumbnailInfo; + if (formatThumbnailInfo == null) { + continue; + } + + String uniqueId = getUniqueId(TRACK_TYPE_IMAGE, TRACK_TYPE_IMAGE, trackIndex); + String fixedImageTrackURL = formatThumbnailInfo.imageTemplateUrl; + if (!TextUtils.isEmpty(mediaItemManifestURL) && + !TextUtils.isEmpty(redirectPlaybackURL) && + !TextUtils.equals(mediaItemManifestURL, redirectPlaybackURL)) { + fixedImageTrackURL = rebuildImageTrackURL(formatThumbnailInfo.imageTemplateUrl); + } + + imageTracks.add(trackIndex, new DashImageTrack(uniqueId, + imageFormat.id, + imageFormat.bitrate, + imageFormat.width, + imageFormat.height, + formatThumbnailInfo.tilesHorizontal, + formatThumbnailInfo.tilesVertical, + formatThumbnailInfo.segmentDuration * Consts.MILLISECONDS_MULTIPLIER, + fixedImageTrackURL, + formatThumbnailInfo.presentationTimeOffset, + formatThumbnailInfo.timeScale, + formatThumbnailInfo.startNumber, + formatThumbnailInfo.endNumber + )); + } + + if (!imageTracks.isEmpty()) { + updateLastSelectedImageTrackIds(); + } + } + + @NonNull + private String rebuildImageTrackURL(String fullImageUrl) { + if (mediaItemManifestURL.contains("?")) { + mediaItemManifestURL = mediaItemManifestURL.split("\\?")[0]; + } + int index = mediaItemManifestURL.lastIndexOf('/'); + if (index > 0) { + mediaItemManifestURL = mediaItemManifestURL.substring(0, index); + } + + if (redirectPlaybackURL.contains("?")) { + redirectPlaybackURL = redirectPlaybackURL.split("\\?")[0]; + } + index = redirectPlaybackURL.lastIndexOf('/'); + if (index > 0) { + redirectPlaybackURL = redirectPlaybackURL.substring(0, index); + } + String fullImageUrlSuffix = fullImageUrl.replaceAll(mediaItemManifestURL, ""); + fullImageUrl = redirectPlaybackURL + fullImageUrlSuffix; + return fullImageUrl; + } + + private void createVttThumbnailImageTrack(String externalThumbnailWebVttUrl) { + externalVttThumbnailRangesInfo = new LinkedHashMap<>(); + ExecutorService executorService = Executors.newSingleThreadExecutor(); + try { + Future webVttThumbnails = executorService.submit(new VttThumbnailDownloader(externalThumbnailWebVttUrl)); + if (webVttThumbnails != null) { + PKWebvttSubtitle vttSubtitle = (PKWebvttSubtitle) webVttThumbnails.get(); + if (vttSubtitle != null) { + List webvttCueInfos = vttSubtitle.getCueInfos(); + if (webvttCueInfos != null && !webvttCueInfos.isEmpty()) { + + long firstThumbStartTime = webvttCueInfos.get(0).startTimeUs / Consts.MILLISECONDS_MULTIPLIER; + long firstThumbEndTime = webvttCueInfos.get(0).endTimeUs / Consts.MILLISECONDS_MULTIPLIER; + + Uri uri = Uri.parse(externalThumbnailWebVttUrl); + String baseUrl = uri.toString().replace(uri.getLastPathSegment(), ""); + + for (int i = 0; i < webvttCueInfos.size(); i++) { + Pair, ThumbnailInfo> thumbnailInfoPair = getExternalVttThumbnailInfo(baseUrl, webvttCueInfos.get(i)); + if (thumbnailInfoPair == null) { + continue; + } + + externalVttThumbnailRangesInfo.put(new Pair<>(thumbnailInfoPair.first.first, thumbnailInfoPair.first.second), thumbnailInfoPair.second); + } + + long imageDuration = firstThumbEndTime - firstThumbStartTime; + List> externalVttThumbnailRangesInfoKeySetList = new ArrayList<>(externalVttThumbnailRangesInfo.keySet()); + if (!externalVttThumbnailRangesInfoKeySetList.isEmpty()) { + ThumbnailInfo firstThumbnailInfo = externalVttThumbnailRangesInfo.get(externalVttThumbnailRangesInfoKeySetList.get(0)); + if (firstThumbnailInfo != null) { + + int cols = getThumbnailColsCount(webvttCueInfos, baseUrl); + cols = (cols > 0) ? cols : 1; + + int rows = getThumbnailRowsCount(webvttCueInfos, baseUrl); + rows = (rows > 0) ? rows : 1; + + float width = (firstThumbnailInfo.getWidth() * (cols - 1)) <= 0 ? -1 : firstThumbnailInfo.getWidth() * (cols - 1); + if (cols == 1) { + width = firstThumbnailInfo.getWidth(); + } + + float height = (firstThumbnailInfo.getHeight() * (rows - 1) <= 0) ? -1 : firstThumbnailInfo.getHeight() * (rows - 1); + if (rows == 1) { + height = firstThumbnailInfo.getHeight(); + } + + imageDuration = (imageDuration * cols * rows); + + String uniqueId = getUniqueId(TRACK_TYPE_IMAGE, TRACK_TYPE_IMAGE, 0); + imageTracks.add(0, new ImageTrack(uniqueId, "externalVttThumbnail", -1, width, height, cols, rows, imageDuration, baseUrl)); + + updateLastSelectedImageTrackIds(); + } + } + } + } + } + } catch(Exception exception){ + log.e("error " + exception.getMessage()); + } finally{ + executorService.shutdown(); + } + } + + private int getThumbnailColsCount(List webvttCueInfos, String baseUrl) { + int cols = 0; + for (WebvttCueInfo webvttCueInfo : webvttCueInfos) { + Pair, ThumbnailInfo> thumbnailInfoPair = getExternalVttThumbnailInfo(baseUrl, webvttCueInfo); + + if (thumbnailInfoPair == null || thumbnailInfoPair.second == null) { + continue; + } + + final ThumbnailInfo thumbnailInfoSecond = thumbnailInfoPair.second; + + if (thumbnailInfoSecond.getHeight() == -1 || thumbnailInfoSecond.getWidth() == -1) { + break; + } + + if (thumbnailInfoSecond.getX() == 0 && thumbnailInfoSecond.getY() == 0) { + cols++; + } else if (thumbnailInfoSecond.getX() > 0) { + cols++; + } else if (thumbnailInfoSecond.getX() == 0) { + break; + } + } + return cols; + } + + private int getThumbnailRowsCount(List webvttCueInfos, String baseUrl) { + int rows = 0; + boolean rowsFirst = true; + for (WebvttCueInfo webvttCueInfo : webvttCueInfos) { + Pair, ThumbnailInfo> thumbnailInfoPair = getExternalVttThumbnailInfo(baseUrl, webvttCueInfo); + + if (thumbnailInfoPair == null || thumbnailInfoPair.second == null) { + continue; + } + + final ThumbnailInfo thumbnailInfoSecond = thumbnailInfoPair.second; + if (thumbnailInfoSecond.getHeight() == -1 || thumbnailInfoSecond.getWidth() == -1) { + break; + } + + if (thumbnailInfoSecond.getX() == 0 && thumbnailInfoSecond.getY() == 0) { + if (rowsFirst) { + rows++; + rowsFirst = false; + } else { + break; + } + } else if (thumbnailInfoSecond.getX() == 0) { + rows++; + } + } + return rows; + } + + private void updateLastSelectedImageTrackIds() { + if (NONE.equals(requestedChangeTrackIds[TRACK_TYPE_IMAGE])) { + log.d("Image track changed to: " + requestedChangeTrackIds[TRACK_TYPE_IMAGE]); + lastSelectedTrackIds[TRACK_TYPE_IMAGE] = imageTracks.get(0).getUniqueId(); + } + } + + private Pair, ThumbnailInfo> getExternalVttThumbnailInfo(String baseUrl, WebvttCueInfo cueInfo) { + if (cueInfo == null || TextUtils.isEmpty(cueInfo.cue.text)) { + return null; + } + + String cueText = String.valueOf(cueInfo.cue.text); + String imageUrl = ""; + long x = -1; + long y = -1; + long w = -1; + long h = -1; + + if (!TextUtils.isEmpty(cueText)) { + String[] cueParts = cueText.split("#"); + if (cueParts.length > 0) { + imageUrl = cueParts[0]; + if (cueParts.length == 2 && cueParts[1].startsWith("xywh") && cueParts[1].contains("=")) { + String[] xywh = cueParts[1].split("=")[1].split(","); + if (xywh.length == 4) { + x = Long.parseLong(xywh[0]); + y = Long.parseLong(xywh[1]); + w = Long.parseLong(xywh[2]); + h = Long.parseLong(xywh[3]); + } + } + } + } + long startTime = cueInfo.startTimeUs / Consts.MILLISECONDS_MULTIPLIER; + long endTime = cueInfo.endTimeUs / Consts.MILLISECONDS_MULTIPLIER; + + if (imageUrl.startsWith("/")) { + imageUrl = imageUrl.replaceFirst("/", ""); + } + String[] imageUrlSlices = imageUrl.split("/"); + + StringBuilder maybeRemoveString = new StringBuilder(); + if (imageUrlSlices.length > 1) { + for (int i = 0 ; i < imageUrlSlices.length - 1 ; i++) { + maybeRemoveString.append("/").append(imageUrlSlices[i]).append("/"); + } + } + if (!TextUtils.isEmpty(maybeRemoveString.toString()) && baseUrl.endsWith(maybeRemoveString.toString())) { + baseUrl = baseUrl.replace(maybeRemoveString.toString(), ""); + baseUrl += "/"; + } + ThumbnailInfo thumbnailInfo = new ThumbnailInfo(baseUrl + imageUrl, x, y, w, h); + return new Pair<>(new Pair<>(startTime, endTime), thumbnailInfo); + } + + private boolean checkTracksUnavailability(MappingTrackSelector.MappedTrackInfo mappedTrackInfo) { + return mappedTrackInfo.getTrackGroups(TRACK_TYPE_VIDEO).length == 0 && + mappedTrackInfo.getTrackGroups(TRACK_TYPE_AUDIO).length == 0 && + mappedTrackInfo.getTrackGroups(TRACK_TYPE_TEXT).length == 0; } @NonNull @@ -310,23 +694,102 @@ private TrackType getTrackType(int rendererIndex) { return trackType; } + /** + * Get the language from format (Audio/Video) + * Convert the language to ISO3 language if required + * + * @param format Track's format + * @return String language + */ private String getLanguageFromFormat(Format format) { - if (format.language == null) { + String language = format.language; + if (TextUtils.isEmpty(format.language)) { return LANGUAGE_UNKNOWN; } - return format.language; + + if ((!C.LANGUAGE_UNDETERMINED.equals(language) && + !isExternalSubtitle(format.language, format.sampleMimeType) && + language.length() > 2) || + isIso3LanguageRequired(format)) { + + Locale locale = Util.SDK_INT >= 21 ? Locale.forLanguageTag(language) : new Locale(language); + + try { + String iso3Language = locale.getISO3Language(); + if (isExternalSubtitle(format.language, format.sampleMimeType)) { + // Append the mimetype to iso3language again with '-' + String mimeType = getExternalSubtitleMimeType(format); + if (!TextUtils.isEmpty(mimeType)) { + iso3Language = iso3Language.concat(mimeType); + } + } + return iso3Language; + } catch (MissingResourceException | NullPointerException ex) { + log.e(ex.getMessage()); + } + } + + return language; } + /** + * Check if the text track is external subtitle + * + * @param language track's langauge + * @param sampleMimeType track's mimeType + * + * @return boolean + */ private boolean isExternalSubtitle(String language, String sampleMimeType) { return language != null && (language.contains("-" + sampleMimeType) || language.contains("-" + "Unknown")); } + /** + * Only used for External Subtitles + * Get the external subtitle language + * + * @param format External Subtitle's format + * @return String language + */ + @Nullable private String getExternalSubtitleLanguage(Format format) { if (format.language != null) { return format.language.substring(0, format.language.indexOf("-")); - } else { - return null; } + return null; + } + + /** + * Only used for External Subtitles + * If language length is greater than 2 then ISO3 code is required + * + * @return boolean if required + */ + private boolean isIso3LanguageRequired(Format format) { + if (isExternalSubtitle(format.language, format.sampleMimeType)) { + String language = getExternalSubtitleLanguage(format); + return !C.LANGUAGE_UNDETERMINED.equals(language) && !TextUtils.isEmpty(language) && language.length() > 2; + } + return false; + } + + /** + * Only used for External Subtitles + * + * We can not rely on Format's sample mimeType because + * for external subtitles we can concatenating 'unknown' as well + * in case if the mimeType is empty + * + * It includes '-' with the mimeType + * + * @return String subtitle mimeType + */ + @Nullable + private String getExternalSubtitleMimeType(Format format) { + if (format.language != null) { + return format.language.substring(format.language.indexOf("-")); + } + return null; } private boolean discardTextTrackOnPreference(Format format) { @@ -349,6 +812,7 @@ private boolean discardTextTrackOnPreference(Format format) { } if (subtitleListMap.containsKey(languageName) && subtitleListMap.get(languageName).size() > 1) { + if ((subtitlePreference == PKSubtitlePreference.INTERNAL && isExternalSubtitle) || (subtitlePreference == PKSubtitlePreference.EXTERNAL && !isExternalSubtitle)) { return true; @@ -431,6 +895,8 @@ private void populateAllCodecTracks(boolean atleastOneCodecSupportedInHardware) } else if ((!atleastOneCodecSupportedInHardware || playerSettings.getPreferredVideoCodecSettings().isAllowSoftwareDecoder()) && (codecVideoTrack.getCodecName() != null && isCodecSupported(codecVideoTrack.getCodecName(), TrackType.VIDEO, true))) { videoTracks.add(codecVideoTrack); + } else if (codecVideoTrack.getCodecName() == null && codecVideoTrack.getCodecType() == PKVideoCodec.AVC && codecVideoTrack.getBitrate() >= 0) { + videoTracks.add(codecVideoTrack); } } } @@ -445,33 +911,65 @@ private void populateAllCodecTracks(boolean atleastOneCodecSupportedInHardware) private ArrayList filterAdaptiveAudioTracks() { ArrayList filteredAudioTracks = new ArrayList<>(); - AudioTrack audioTrack; int[] parsedUniqueId; int currentGroup = -1; - for (int i = 0; i < audioTracks.size(); i++) { - audioTrack = audioTracks.get(i); + + boolean allowMixedCodecs = playerSettings.getPreferredAudioCodecSettings().getAllowMixedCodecs(); + HashMap currentCodecMap = new HashMap<>(); + for (AudioTrack audioTrack : audioTracks) { parsedUniqueId = parseUniqueId(audioTrack.getUniqueId()); - if (parsedUniqueId[TRACK_INDEX] == TRACK_ADAPTIVE) { + if (parsedUniqueId[TRACK_INDEX] == TRACK_ADAPTIVE || (allowMixedCodecs && !currentCodecMap.containsKey(audioTrack.getCodecName()))) { filteredAudioTracks.add(audioTrack); currentGroup = parsedUniqueId[GROUP_INDEX]; + if (allowMixedCodecs) { + currentCodecMap.put(audioTrack.getCodecName(), parsedUniqueId[TRACK_INDEX] == TRACK_ADAPTIVE); + } } else if (parsedUniqueId[GROUP_INDEX] != currentGroup) { filteredAudioTracks.add(audioTrack); currentGroup = -1; + } else { + if (allowMixedCodecs) { + Boolean isAdaptive = currentCodecMap.get(audioTrack.getCodecName()); + if (isAdaptive != null && !isAdaptive) { + // check if same codec track is not adaptive + filteredAudioTracks.add(audioTrack); + currentGroup = -1; + } + } } } + currentCodecMap.clear(); AudioCodecSettings preferredAudioCodecSettings = playerSettings.getPreferredAudioCodecSettings(); - if (preferredAudioCodecSettings.getAllowMixedCodecs()) { - return filteredAudioTracks; + if (preferredAudioCodecSettings.getAllowMixedCodecs() && preferredAudioCodecSettings.getAllowMixedBitrates()) { + return new ArrayList<>(audioTracks); } - List audioCodecList = new ArrayList<>(Arrays.asList(PKAudioCodec.E_AC3, PKAudioCodec.AC3, PKAudioCodec.OPUS, PKAudioCodec.AAC)); + if (preferredAudioCodecSettings.getAllowMixedCodecs() && !preferredAudioCodecSettings.getAllowMixedBitrates()) { + return filteredAudioTracks; + } - for (PKAudioCodec pkAudioCodec : audioCodecList) { - if (audioTracksCodecsMap.containsKey(pkAudioCodec)) { - return new ArrayList<>(audioTracksCodecsMap.get(pkAudioCodec)); + if (audioTracksCodecsMap.size() > 1) { + for (PKAudioCodec pkAudioCodec : preferredAudioCodecSettings.getCodecPriorityList()) { + if (audioTracksCodecsMap.containsKey(pkAudioCodec) && audioTracksCodecsMap.get(pkAudioCodec) != null) { + if (preferredAudioCodecSettings.getAllowMixedBitrates()) { + ArrayList preferredCodecMixedBitrates = new ArrayList<>(); + for (AudioTrack audioTrack : audioTracks) { + if (audioTrack.getCodecType() == pkAudioCodec) { + preferredCodecMixedBitrates.add(audioTrack); + } + } + return new ArrayList<>(preferredCodecMixedBitrates); + } else { + for (AudioTrack filteredTrack : filteredAudioTracks) { + if (filteredTrack.getCodecType() == pkAudioCodec) { + return new ArrayList<>(Collections.singletonList(filteredTrack)); + } + } + } + } } } @@ -488,7 +986,7 @@ private void maybeAddDisabledTextTrack() { if (textTracks.isEmpty()) { return; } - + String uniqueId = getUniqueId(TRACK_TYPE_TEXT, 0, TRACK_DISABLED); textTracks.add(0, new TextTrack(uniqueId, NONE, NONE, NONE, -1)); } @@ -509,32 +1007,51 @@ private int getDefaultTrackIndex(List trackList, String las return defaultTrackIndex; } + if (trackList.get(0) instanceof ImageTrack) { + return restoreLastSelectedTrack(trackList, lastSelectedTrackId, getUpdatedDefaultTrackIndex(trackList, defaultTrackIndex)); + } + for (int i = 0; i < trackList.size(); i++) { if (trackList.get(i) != null) { int selectionFlag = trackList.get(i).getSelectionFlag(); if (selectionFlag == Consts.DEFAULT_TRACK_SELECTION_FLAG_HLS || selectionFlag == Consts.DEFAULT_TRACK_SELECTION_FLAG_DASH) { if (trackList.get(i) instanceof TextTrack && hasExternalSubtitlesInTracks && playerSettings.getSubtitlePreference() != PKSubtitlePreference.OFF) { PKSubtitlePreference pkSubtitlePreference = playerSettings.getSubtitlePreference(); - TrackSelection trackSelection = trackSelectionArray.get(TRACK_TYPE_TEXT); + Format selectedTextFormat = getSelectedTextTrackFormat(); + + if (selectedTextFormat == null) { + continue; + } // TrackSelection is giving the default tracks for video, audio and text. // If trackSelection contains a text which is an external text track, it means that either internal text track // does not contain any track or there is no default track in internal text track. If there is no internal text track then // forcing the preference to be External. - if (trackSelection != null && trackSelection.getSelectedFormat() != null && - isExternalSubtitle(trackSelection.getSelectedFormat().language, trackSelection.getSelectedFormat().sampleMimeType)) { - pkSubtitlePreference = PKSubtitlePreference.EXTERNAL; + + String languageName = null; + if (selectedTextFormat.language != null) { + languageName = selectedTextFormat.language; + + if (!TextUtils.isEmpty(languageName) && + selectedTextFormat.sampleMimeType != null && + isExternalSubtitle(languageName, selectedTextFormat.sampleMimeType)) { + pkSubtitlePreference = PKSubtitlePreference.EXTERNAL; + } } TextTrack textTrack = (TextTrack) trackList.get(i); boolean isExternalSubtitle = isExternalSubtitle(textTrack.getLanguage(), textTrack.getMimeType()); - if (isExternalSubtitle && pkSubtitlePreference == PKSubtitlePreference.EXTERNAL) { + + if (subtitleListMap.containsKey(languageName) && subtitleListMap.get(languageName).size() == 1) { + defaultTrackIndex = i; + break; + } else if (isExternalSubtitle && pkSubtitlePreference == PKSubtitlePreference.EXTERNAL) { defaultTrackIndex = i; break; } else if (!isExternalSubtitle && pkSubtitlePreference == PKSubtitlePreference.INTERNAL) { defaultTrackIndex = i; break; - } + } } else { defaultTrackIndex = i; break; @@ -560,7 +1077,12 @@ private void extractTextTracksToMap(TrackGroupArray trackGroupArray) { Format format = trackGroup.getFormat(trackIndex); String languageName = format.language; - if (isExternalSubtitle(format.language, format.sampleMimeType)) { + if (TextUtils.isEmpty(languageName)) { + continue; + } + + boolean isExternalSubtitle = isExternalSubtitle(languageName, format.sampleMimeType); + if (isExternalSubtitle) { languageName = getExternalSubtitleLanguage(format); hasExternalSubtitlesInTracks = true; } @@ -596,11 +1118,8 @@ private int getUpdatedDefaultTrackIndex(List trackList, int trackType = TRACK_TYPE_TEXT; } - if (trackType == TRACK_TYPE_AUDIO && trackSelectionArray != null && trackType < trackSelectionArray.length) { - TrackSelection trackSelection = trackSelectionArray.get(trackType); - if (trackSelection != null && trackSelection.getSelectedFormat() != null) { - defaultTrackIndex = findDefaultTrackIndex(trackSelection.getSelectedFormat().language, trackList, defaultTrackIndex); - } + if (trackType == TRACK_TYPE_AUDIO && currentAudioFormat != null && tracksInfo != null) { + defaultTrackIndex = findDefaultTrackIndex(currentAudioFormat.language, trackList, defaultTrackIndex); } } @@ -631,14 +1150,36 @@ private int findDefaultTrackIndex(String selectedFormatLanguage, List trackList, String lastSelectedTrackId, int defaultTrackIndex) { + boolean skipRestoreTrack = false; + + if (trackList.get(0) instanceof VideoTrack && DISABLED.equals(lastDisabledTrackIds[TRACK_TYPE_VIDEO])) { + disableVideoTracks(true); + skipRestoreTrack = true; + } + if (trackList.get(0) instanceof AudioTrack && DISABLED.equals(lastDisabledTrackIds[TRACK_TYPE_VIDEO])) { + disableAudioTracks(true); + skipRestoreTrack = true; + } + if (trackList.get(0) instanceof TextTrack && DISABLED.equals(lastDisabledTrackIds[TRACK_TYPE_VIDEO])) { + disableTextTracks(true); + skipRestoreTrack = true; + } + //If track was previously selected and selection is differed from the default selection apply it. String defaultUniqueId = trackList.get(defaultTrackIndex).getUniqueId(); if (!NONE.equals(lastSelectedTrackId) && !lastSelectedTrackId.equals(defaultUniqueId)) { - changeTrack(lastSelectedTrackId); - for (int i = 0; i < trackList.size(); i++) { - if (lastSelectedTrackId.equals(trackList.get(i).getUniqueId())) { - return i; + try { + if (!skipRestoreTrack) { + changeTrack(lastSelectedTrackId); + } + for (int i = 0; i < trackList.size(); i++) { + if (lastSelectedTrackId.equals(trackList.get(i).getUniqueId())) { + return i; + } } + } catch (IllegalArgumentException ex) { + PKError currentError = new PKError(PKPlayerErrorType.UNEXPECTED, PKError.Severity.Fatal, ex.getMessage(), ex); + tracksErrorListener.onUnsupportedTracksAvailableError(currentError); } } @@ -698,6 +1239,8 @@ private String getUniqueIdPrefix(int rendererIndex) { return AUDIO_PREFIX; case TRACK_TYPE_TEXT: return TEXT_PREFIX; + case TRACK_TYPE_IMAGE: + return IMAGE_PREFIX; default: return ""; } @@ -721,7 +1264,11 @@ private String getUniqueIdPostfix(int rendererIndex, int trackIndex) { * @param uniqueId - unique identifier of the track to apply. */ - protected void changeTrack(String uniqueId) { + protected void changeTrack(String uniqueId) throws IllegalArgumentException { + if (assertTrackSelectorIsNull("changeTrack")) { + return; + } + log.i("Request change track to uniqueID -> " + uniqueId); mappedTrackInfo = selector.getCurrentMappedTrackInfo(); if (mappedTrackInfo == null) { @@ -731,22 +1278,34 @@ protected void changeTrack(String uniqueId) { int[] uniqueTrackId = validateUniqueId(uniqueId); int rendererIndex = uniqueTrackId[RENDERER_INDEX]; + int groupIndex = uniqueTrackId[GROUP_INDEX]; requestedChangeTrackIds[rendererIndex] = uniqueId; - DefaultTrackSelector.ParametersBuilder parametersBuilder = selector.getParameters().buildUpon(); - if (rendererIndex == TRACK_TYPE_TEXT) { - //Disable text track renderer if needed. - parametersBuilder.setRendererDisabled(TRACK_TYPE_TEXT, uniqueTrackId[TRACK_INDEX] == TRACK_DISABLED); + if (uniqueId.contains(IMAGE_PREFIX)) { + log.d("Image track changed to: " + requestedChangeTrackIds[TRACK_TYPE_IMAGE]); + lastSelectedTrackIds[TRACK_TYPE_IMAGE] = requestedChangeTrackIds[TRACK_TYPE_IMAGE]; + tracksInfoListener.onImageTrackChanged(); + return; } - - SelectionOverride override = retrieveOverrideSelection(uniqueTrackId); - overrideTrack(rendererIndex, override, parametersBuilder); + List selectedTrackIndices = retrieveOverrideSelection(uniqueTrackId); + overrideTrack(rendererIndex, groupIndex, selectedTrackIndices); } protected void overrideMediaVideoCodec() { + if (videoTracksCodecsMap.size() == 1) { + // No need to execute further as override track ignores the parameters given to the trackselector + // so elemenating this behaviour + // We will execute this only in case when video tracks have more than one type of video codec available + return; + } + + if (assertTrackSelectorIsNull("overrideMediaVideoCodec")) { + return; + } + List uniqueIds = getVideoTracksUniqueIds(); mappedTrackInfo = selector.getCurrentMappedTrackInfo(); @@ -756,19 +1315,20 @@ protected void overrideMediaVideoCodec() { int[] uniqueTrackId = validateUniqueId(uniqueIds.get(0)); int rendererIndex = uniqueTrackId[RENDERER_INDEX]; + int groupIndex = uniqueTrackId[GROUP_INDEX]; requestedChangeTrackIds[rendererIndex] = uniqueIds.get(0); - DefaultTrackSelector.ParametersBuilder parametersBuilder = selector.getParameters().buildUpon(); - - - SelectionOverride override = retrieveOverrideSelectionList(validateAndBuildUniqueIds(uniqueIds)); - overrideTrack(rendererIndex, override, parametersBuilder); + List selectedTrackIndices = retrieveOverrideSelectionList(validateAndBuildUniqueIds(uniqueIds)); + overrideTrack(rendererIndex, groupIndex, selectedTrackIndices); } - protected void overrideMediaDefaultABR(long minVideoBitrate, long maxVideoBitrate) { + protected void overrideMediaDefaultABR(long minAbr, long maxAbr, PKAbrFilter pkAbrFilter) { + if (assertTrackSelectorIsNull("overrideMediaDefaultABR")) { + return; + } - List uniqueIds = getCodecUniqueIdsWithABR(minVideoBitrate, maxVideoBitrate); + List uniqueIds = getCodecUniqueIdsWithABR(minAbr, maxAbr, pkAbrFilter); mappedTrackInfo = selector.getCurrentMappedTrackInfo(); if (mappedTrackInfo == null || uniqueIds.isEmpty()) { @@ -777,14 +1337,13 @@ protected void overrideMediaDefaultABR(long minVideoBitrate, long maxVideoBitrat int[] uniqueTrackId = validateUniqueId(uniqueIds.get(0)); int rendererIndex = uniqueTrackId[RENDERER_INDEX]; + int groupIndex = uniqueTrackId[GROUP_INDEX]; - requestedChangeTrackIds[rendererIndex] = uniqueIds.get(0); - - DefaultTrackSelector.ParametersBuilder parametersBuilder = selector.getParameters().buildUpon(); - + requestedChangeTrackIds[rendererIndex] = (NONE.equals(lastSelectedTrackIds[rendererIndex])) ? + uniqueIds.get(0) : lastSelectedTrackIds[rendererIndex]; - SelectionOverride override = retrieveOverrideSelectionList(validateAndBuildUniqueIds(uniqueIds)); - overrideTrack(rendererIndex, override, parametersBuilder); + List selectedTrackIndices = retrieveOverrideSelectionList(validateAndBuildUniqueIds(uniqueIds)); + overrideTrack(rendererIndex,groupIndex, selectedTrackIndices); } private List getVideoTracksUniqueIds() { @@ -798,31 +1357,95 @@ private List getVideoTracksUniqueIds() { return uniqueIds; } - private List getCodecUniqueIdsWithABR(long minVideoBitrate, long maxVideoBitrate) { + private List getCodecUniqueIdsWithABR(long minAbr, long maxAbr, PKAbrFilter pkAbrFilter) { List uniqueIds = new ArrayList<>(); boolean isValidABRRange = true; if (videoTracks != null && !videoTracks.isEmpty()) { + if (originalVideoTracks == null) { + originalVideoTracks = new ArrayList<>(videoTracks); + } else if (originalVideoTracks.isEmpty()) { + originalVideoTracks.addAll(videoTracks); + } else { + videoTracks.clear(); + videoTracks.addAll(originalVideoTracks); + } + Collections.sort(videoTracks); if (videoTracks.size() >= 2) { + long minValueInStream; + long maxValueInStream; + + switch (pkAbrFilter) { + case HEIGHT: + Collections.sort(videoTracks, new VideoTrack.HeightComparator()); + minValueInStream = videoTracks.get(1).getHeight(); + maxValueInStream = videoTracks.get(videoTracks.size() - 1).getHeight(); + break; + case WIDTH: + Collections.sort(videoTracks, new VideoTrack.WidthComparator()); + minValueInStream = videoTracks.get(1).getWidth(); + maxValueInStream = videoTracks.get(videoTracks.size() - 1).getWidth(); + break; + case PIXEL: + Collections.sort(videoTracks, new VideoTrack.PixelComparator()); + minValueInStream = videoTracks.get(1).getWidth() * videoTracks.get(1).getHeight(); + maxValueInStream = videoTracks.get(videoTracks.size() - 1).getWidth() * videoTracks.get(videoTracks.size() - 1).getHeight(); + break; + case NONE: + default: + minValueInStream = videoTracks.get(1).getBitrate(); + maxValueInStream = videoTracks.get(videoTracks.size() - 1).getBitrate(); + break; + } - long minBitrateInStream = videoTracks.get(1).getBitrate(); - long maxBitrateInStream = videoTracks.get(videoTracks.size() - 1).getBitrate(); - - if ((minVideoBitrate > maxBitrateInStream) || (maxVideoBitrate < minBitrateInStream)) { + if ((minAbr > maxValueInStream) || (maxAbr < minValueInStream)) { isValidABRRange = false; - String errorMessage = "given minVideoBitrate or maxVideoBitrate is invalid"; + minAbr = Long.MIN_VALUE; + maxAbr = Long.MAX_VALUE; + pkAbrFilter = PKAbrFilter.NONE; + String errorMessage = "given minVideo ABR or maxVideo ABR is invalid"; PKError currentError = new PKError(PKPlayerErrorType.UNEXPECTED, PKError.Severity.Recoverable, errorMessage, new IllegalArgumentException(errorMessage)); tracksErrorListener.onTracksOverrideABRError(currentError); } } Iterator videoTrackIterator = videoTracks.iterator(); + int currentIndex = 0; + int originalVideoTrackSize = originalVideoTracks.size(); while (videoTrackIterator.hasNext()) { VideoTrack currentVideoTrack = videoTrackIterator.next(); - if ((currentVideoTrack.getBitrate() >= minVideoBitrate && currentVideoTrack.getBitrate() <= maxVideoBitrate)) { + long currentAbrSelectedValue; + long nextAbrSelectedValue; + // Get the next track from the originalVideoTrack list + int nextIndex = (currentIndex == originalVideoTrackSize - 1) ? -1 : currentIndex + 1; + + switch (pkAbrFilter) { + case HEIGHT: + currentAbrSelectedValue = currentVideoTrack.getHeight(); + nextAbrSelectedValue = nextIndex != -1 ? originalVideoTracks.get(nextIndex).getHeight() : -1; + break; + case WIDTH: + currentAbrSelectedValue = currentVideoTrack.getWidth(); + nextAbrSelectedValue = nextIndex != -1 ? originalVideoTracks.get(nextIndex).getWidth() : -1; + break; + case PIXEL: + currentAbrSelectedValue = currentVideoTrack.getWidth() * currentVideoTrack.getHeight(); + nextAbrSelectedValue = nextIndex != -1 ? + originalVideoTracks.get(nextIndex).getWidth() * originalVideoTracks.get(nextIndex).getHeight() : + -1; + break; + case NONE: + default: + currentAbrSelectedValue = currentVideoTrack.getBitrate(); + nextAbrSelectedValue = nextIndex != -1 ? originalVideoTracks.get(nextIndex).getBitrate() : -1; + break; + } + + if ((currentAbrSelectedValue >= minAbr && currentAbrSelectedValue <= maxAbr) || + (nextAbrSelectedValue != -1 && minAbr > currentAbrSelectedValue && maxAbr < nextAbrSelectedValue)) { uniqueIds.add(currentVideoTrack.getUniqueId()); } else { if (currentVideoTrack.isAdaptive() || !isValidABRRange) { @@ -831,18 +1454,21 @@ private List getCodecUniqueIdsWithABR(long minVideoBitrate, long maxVide videoTrackIterator.remove(); } } + + currentIndex++; } } return uniqueIds; } - private SelectionOverride retrieveOverrideSelectionList(int[][] uniqueIds) { + private List retrieveOverrideSelectionList(int[][] uniqueIds) { if (uniqueIds == null || uniqueIds[0] == null) { throw new IllegalArgumentException("Track selection with uniqueId = null"); } // Only for video tracks : RENDERER_INDEX is always 0 means video - SelectionOverride override; + List selectedIndices = new ArrayList<>(); + int rendererIndex = uniqueIds[0][RENDERER_INDEX]; int groupIndex = uniqueIds[0][GROUP_INDEX]; int trackIndex = uniqueIds[0][TRACK_INDEX]; @@ -850,19 +1476,17 @@ private SelectionOverride retrieveOverrideSelectionList(int[][] uniqueIds) { boolean isAdaptive = trackIndex == TRACK_ADAPTIVE; if (uniqueIds.length == 1 && isAdaptive) { - override = overrideAutoABRTracks(rendererIndex, groupIndex); + selectedIndices.addAll(overrideAutoABRTracks(rendererIndex, groupIndex)); } else if (uniqueIds.length > 1) { - override = overrideMediaDefaultABR(uniqueIds, rendererIndex, groupIndex); + selectedIndices.addAll(overrideMediaDefaultABR(uniqueIds, rendererIndex, groupIndex)); } else { - override = new SelectionOverride(groupIndex, trackIndex); + selectedIndices.add(trackIndex); } - return override; + return selectedIndices; } @NonNull - private SelectionOverride overrideMediaDefaultABR(int[][] uniqueIds, int rendererIndex, int groupIndex) { - SelectionOverride override; - int[] adaptiveTrackIndexes; + private List overrideMediaDefaultABR(int[][] uniqueIds, int rendererIndex, int groupIndex) { List adaptiveTrackIndexesList = new ArrayList<>(); switch (rendererIndex) { @@ -871,9 +1495,7 @@ private SelectionOverride overrideMediaDefaultABR(int[][] uniqueIds, int rendere createAdaptiveTrackIndexList(uniqueIds, groupIndex, adaptiveTrackIndexesList); break; } - adaptiveTrackIndexes = convertAdaptiveListToArray(adaptiveTrackIndexesList); - override = new SelectionOverride(groupIndex, adaptiveTrackIndexes); - return override; + return adaptiveTrackIndexesList; } private void createAdaptiveTrackIndexList(int[][] uniqueIds, int groupIndex, List adaptiveTrackIndexesList) { @@ -935,9 +1557,8 @@ private int[] parseUniqueId(String uniqueId) { * @param uniqueId - the unique id of the track that will override the existing one. * @return - the {@link SelectionOverride} which will override the existing selection. */ - private SelectionOverride retrieveOverrideSelection(int[] uniqueId) { - - SelectionOverride override; + private List retrieveOverrideSelection(int[] uniqueId) { + List selectedIndices = new ArrayList<>(); int rendererIndex = uniqueId[RENDERER_INDEX]; int groupIndex = uniqueId[GROUP_INDEX]; @@ -948,7 +1569,6 @@ private SelectionOverride retrieveOverrideSelection(int[] uniqueId) { if (isAdaptive) { List adaptiveTrackIndexesList = new ArrayList<>(); - int[] adaptiveTrackIndexes; switch (rendererIndex) { case TRACK_TYPE_VIDEO: @@ -995,19 +1615,17 @@ private SelectionOverride retrieveOverrideSelection(int[] uniqueId) { break; } - adaptiveTrackIndexes = convertAdaptiveListToArray(adaptiveTrackIndexesList); - override = new SelectionOverride(groupIndex, adaptiveTrackIndexes); + selectedIndices.addAll(adaptiveTrackIndexesList); } else { - override = new SelectionOverride(groupIndex, trackIndex); + selectedIndices.add(trackIndex); } - return override; + return selectedIndices; } @NonNull - private SelectionOverride overrideAutoABRTracks(int rendererIndex, int groupIndex) { - SelectionOverride override;List adaptiveTrackIndexesList = new ArrayList<>(); - int[] adaptiveTrackIndexes; + private List overrideAutoABRTracks(int rendererIndex, int groupIndex) { + List adaptiveTrackIndexesList = new ArrayList<>(); switch (rendererIndex) { case TRACK_TYPE_VIDEO: @@ -1046,42 +1664,49 @@ private SelectionOverride overrideAutoABRTracks(int rendererIndex, int groupInde break; } - adaptiveTrackIndexes = convertAdaptiveListToArray(adaptiveTrackIndexesList); - override = new SelectionOverride(groupIndex, adaptiveTrackIndexes); - return override; + return adaptiveTrackIndexesList; } /** * Actually doing the override action on the track. * * @param rendererIndex - renderer index on which we want to apply the change. - * @param override - the new selection with which we want to override the currently active track. + * @param groupIndex - Group index of the track + * @param selectedIndices - Indexes of the tracks which are actually needs to be overrides. + * This index is of the selected `Format` inside the `mappedTrackInfo` + * Generally all the Formats come in one `Trackgroup` but for TEXT, + * it comes individual `TrackGroup` for each TEXT `Format` */ - private void overrideTrack(int rendererIndex, SelectionOverride override, DefaultTrackSelector.ParametersBuilder parametersBuilder) { - if (override != null) { - //actually change track. - TrackGroupArray trackGroups = mappedTrackInfo.getTrackGroups(rendererIndex); - parametersBuilder.setSelectionOverride(rendererIndex, trackGroups, override); + private void overrideTrack(int rendererIndex, int groupIndex, List selectedIndices) { + if (assertTrackSelectorIsNull("overrideTrack")) { + return; + } + + //actually change track. + TrackGroup trackGroup = mappedTrackInfo.getTrackGroups(rendererIndex).get(groupIndex); + if (!selectedIndices.isEmpty()) { + if (rendererIndex == TRACK_TYPE_TEXT && selectedIndices.get(0) == TRACK_DISABLED) { + // Just clear the selected indices, + // it will let exoplayer know that no tracks from trackGroup should be played + selectedIndices.clear(); + } + TrackSelectionOverride trackSelectionOverride = new TrackSelectionOverride(trackGroup, selectedIndices); + trackSelectionParametersBuilder.setOverrideForType(trackSelectionOverride); } else { - //clear all the selections if the override is null. - parametersBuilder.clearSelectionOverrides(rendererIndex); + //clear all the selections if selectedIndices are empty. + trackSelectionParametersBuilder.clearOverride(trackGroup); } - selector.setParameters(parametersBuilder); + + selector.setParameters(trackSelectionParametersBuilder.build()); } - public void updateTrackSelectorParameter(PlayerSettings playerSettings, DefaultTrackSelector.ParametersBuilder parametersBuilder) { + public void updateTrackSelectorParameter(PlayerSettings playerSettings, DefaultTrackSelector.Parameters.Builder parametersBuilder) { if (playerSettings == null) { return; } if (playerSettings.isTunneledAudioPlayback() && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) { - parametersBuilder.setTunnelingAudioSessionId(C.generateAudioSessionIdV21(context)); - } - if (playerSettings.getMaxVideoSize() != null) { - parametersBuilder.setMaxVideoSize(playerSettings.getMaxVideoSize().getMaxVideoWidth(), playerSettings.getMaxVideoSize().getMaxVideoHeight()); - } - if (playerSettings.getMaxVideoBitrate() != null) { - parametersBuilder.setMaxVideoBitrate(playerSettings.getMaxVideoBitrate()); + parametersBuilder.setTunnelingEnabled(playerSettings.isTunneledAudioPlayback()); } if (playerSettings.getMaxAudioBitrate() != null) { parametersBuilder.setMaxAudioBitrate(playerSettings.getMaxAudioBitrate()); @@ -1092,6 +1717,55 @@ public void updateTrackSelectorParameter(PlayerSettings playerSettings, DefaultT if (playerSettings.getPreferredVideoCodecSettings().getAllowMixedCodecAdaptiveness()) { parametersBuilder.setAllowVideoMixedMimeTypeAdaptiveness(true); } + parametersBuilder.setConstrainAudioChannelCountToDeviceCapabilities(playerSettings.isConstrainAudioChannelCountToDeviceCapabilities()); + } + + public ThumbnailInfo getThumbnailInfo(long positionMS) { + + if (imageTracks.isEmpty()) { + return null; + } + + ImageTrack imageTrack = null; + for (int index = 0; index < imageTracks.size() ; index++) { + if (imageTracks.get(index).getUniqueId().equals(lastSelectedTrackIds[TRACK_TYPE_IMAGE])) { + imageTrack = imageTracks.get(index); + break; + } + } + + if (imageTrack == null) { + return null; + } + + if (externalVttThumbnailRangesInfo == null) { + + long seq = (long) Math.floor(positionMS * 1.0 / imageTrack.getDuration()); + double offset = positionMS % imageTrack.getDuration(); + int thumbIndex = (int) Math.floor((offset * imageTrack.getCols() * imageTrack.getRows()) / imageTrack.getDuration()); + long seqIdx = seq + ((DashImageTrack) imageTrack).getStartNumber(); + float imageWidth = imageTrack.getWidth() / imageTrack.getCols(); + float imageHeight = imageTrack.getHeight() / imageTrack.getRows(); + float imageX = (float) Math.floor(thumbIndex % imageTrack.getCols()) * imageWidth; + float imageY = (float) Math.floor(thumbIndex / imageTrack.getCols()) * imageHeight; + + long imageRealUrlTime = ((seqIdx - 1) * imageTrack.getDuration()); + String realImageUrl = imageTrack.getUrl().replace("$Number$", String.valueOf(seqIdx)).replace("$Time$", String.valueOf(imageRealUrlTime)); + return new ThumbnailInfo(realImageUrl, imageX, imageY, imageWidth, imageHeight); + } else if (externalVttThumbnailRangesInfo.size() > 0) { + + List> keySetList = new ArrayList<>(externalVttThumbnailRangesInfo.keySet()); + + long thumbIndex = findImageIndex(keySetList, externalVttThumbnailRangesInfo.size(), positionMS); + + if (thumbIndex > externalVttThumbnailRangesInfo.size() - 1) { + thumbIndex = externalVttThumbnailRangesInfo.size() - 1; + } else if (thumbIndex < 0) { + thumbIndex = 0; + } + return externalVttThumbnailRangesInfo.get(keySetList.get((int) thumbIndex)); + } + return null; } /** @@ -1155,7 +1829,7 @@ private int[] convertAdaptiveListToArray(List adaptiveTrackIndexesList) private boolean isFormatSupported(int rendererCount, int groupIndex, int trackIndex) { return mappedTrackInfo.getTrackSupport(rendererCount, groupIndex, trackIndex) - == RendererCapabilities.FORMAT_HANDLED; + == C.FORMAT_HANDLED; } private boolean isAdaptive(int rendererIndex, int groupIndex) { @@ -1181,18 +1855,22 @@ private int[] validateUniqueId(String uniqueId) throws IllegalArgumentException if (uniqueId.contains(VIDEO_PREFIX) || uniqueId.contains(AUDIO_PREFIX) || uniqueId.contains(TEXT_PREFIX) + || uniqueId.contains(IMAGE_PREFIX) && uniqueId.contains(",")) { - + int trackTypeId = getTrackTypeId(uniqueId); int[] parsedUniqueId = parseUniqueId(uniqueId); if (!isRendererTypeValid(parsedUniqueId[RENDERER_INDEX])) { + lastSelectedTrackIds[trackTypeId] = NONE; throw new IllegalArgumentException("Track selection with uniqueId = " + uniqueId + " failed. Due to invalid renderer index. " + parsedUniqueId[RENDERER_INDEX]); } if (!isGroupIndexValid(parsedUniqueId)) { + lastSelectedTrackIds[trackTypeId] = NONE; throw new IllegalArgumentException("Track selection with uniqueId = " + uniqueId + " failed. Due to invalid group index. " + parsedUniqueId[GROUP_INDEX]); } if (!isTrackIndexValid(parsedUniqueId)) { + lastSelectedTrackIds[trackTypeId] = NONE; throw new IllegalArgumentException("Track selection with uniqueId = " + uniqueId + " failed. Due to invalid track index. " + parsedUniqueId[TRACK_INDEX]); } return parsedUniqueId; @@ -1200,11 +1878,31 @@ private int[] validateUniqueId(String uniqueId) throws IllegalArgumentException throw new IllegalArgumentException("Invalid structure of uniqueId " + uniqueId); } + public int getTrackTypeId(String uniqueId) { + if (uniqueId.contains(VIDEO_PREFIX)) { + return TRACK_TYPE_VIDEO; + } + if (uniqueId.contains(AUDIO_PREFIX)) { + return TRACK_TYPE_AUDIO; + } + if (uniqueId.contains(TEXT_PREFIX)) { + return TRACK_TYPE_TEXT; + } + if (uniqueId.contains(IMAGE_PREFIX)) { + return TRACK_TYPE_IMAGE; + } + return -1; + } + private boolean isTrackIndexValid(int[] parsedUniqueId) { int rendererIndex = parsedUniqueId[RENDERER_INDEX]; int groupIndex = parsedUniqueId[GROUP_INDEX]; int trackIndex = parsedUniqueId[TRACK_INDEX]; + if (rendererIndex == TRACK_TYPE_IMAGE) { + return trackIndex >= TRACK_ADAPTIVE; + } + if (rendererIndex == TRACK_TYPE_TEXT) { return trackIndex != TRACK_ADAPTIVE && trackIndex >= TRACK_DISABLED @@ -1216,28 +1914,51 @@ private boolean isTrackIndexValid(int[] parsedUniqueId) { } private boolean isGroupIndexValid(int[] parsedUniqueId) { + if (parsedUniqueId[GROUP_INDEX] == TRACK_TYPE_IMAGE) { + return true; + } + return parsedUniqueId[GROUP_INDEX] >= 0 && parsedUniqueId[GROUP_INDEX] < mappedTrackInfo.getTrackGroups(parsedUniqueId[RENDERER_INDEX]).length; } private boolean isRendererTypeValid(int rendererIndex) { - return rendererIndex >= TRACK_TYPE_VIDEO && rendererIndex <= TRACK_TYPE_TEXT; + return rendererIndex >= TRACK_TYPE_VIDEO && rendererIndex <= TRACK_TYPE_IMAGE; } /** * Notify to log, that video/audio renderer has only unsupported tracks. */ - private void warnAboutUnsupportedRenderTypes() { + private void warnAboutUnsupportedRendererTypes() { + boolean videoTrackUnsupported = false; + boolean audioTrackUnsupported = false; + String errorMessage; + if (mappedTrackInfo.getTypeSupport(TRACK_TYPE_VIDEO) == MappingTrackSelector.MappedTrackInfo.RENDERER_SUPPORT_UNSUPPORTED_TRACKS) { - log.w("Warning! All the video tracks are unsupported by this device."); + videoTrackUnsupported = true; } if (mappedTrackInfo.getTypeSupport(TRACK_TYPE_AUDIO) == MappingTrackSelector.MappedTrackInfo.RENDERER_SUPPORT_UNSUPPORTED_TRACKS) { - log.w("Warning! All the audio tracks are unsupported by this device."); + audioTrackUnsupported = true; + } + + if (videoTrackUnsupported && audioTrackUnsupported) { + errorMessage = "Warning! All the video and audio tracks are unsupported by this device."; + tracksErrorListener.onUnsupportedAudioVideoTracksError(getUnsupportedTrackError(errorMessage)); + } else if (videoTrackUnsupported) { + errorMessage = "Warning! All the video tracks are unsupported by this device."; + tracksErrorListener.onUnsupportedVideoTracksError(getUnsupportedTrackError(errorMessage)); + } else if (audioTrackUnsupported) { + errorMessage = "Warning! All the audio tracks are unsupported by this device."; + tracksErrorListener.onUnsupportedAudioTracksError(getUnsupportedTrackError(errorMessage)); } } + private PKError getUnsupportedTrackError(String errorMessage) { + return new PKError(PKPlayerErrorType.UNEXPECTED, PKError.Severity.Recoverable, errorMessage, new IllegalStateException(errorMessage)); + } + protected void setTracksInfoListener(TracksInfoListener tracksInfoListener) { this.tracksInfoListener = tracksInfoListener; } @@ -1250,71 +1971,107 @@ private void clearTracksLists() { videoTracks.clear(); audioTracks.clear(); textTracks.clear(); + imageTracks.clear(); + currentVideoFormat = null; + currentAudioFormat = null; for (Map.Entry> videoTrackEntry : videoTracksCodecsMap.entrySet()) { videoTrackEntry.getValue().clear(); } videoTracksCodecsMap.clear(); audioTracksCodecsMap.clear(); subtitleListMap.clear(); + if (originalVideoTracks != null) { + originalVideoTracks.clear(); + } + if (externalVttThumbnailRangesInfo != null) { + externalVttThumbnailRangesInfo.clear(); + externalVttThumbnailRangesInfo = null; + } } protected void release() { - tracksInfoListener.onRelease(lastSelectedTrackIds); + if (tracksInfoListener != null) { + tracksInfoListener.onRelease(lastSelectedTrackIds, lastDisabledTrackIds); + } + tracksInfoListener = null; + trackSelectionParameters = null; + trackSelectionParametersBuilder = null; + + if (selector != null) { + selector.release(); + } + clearTracksLists(); } protected boolean isAudioOnlyStream() { - if (trackSelectionArray != null) { - TrackSelection trackSelection = trackSelectionArray.get(TRACK_TYPE_VIDEO); - return trackSelection == null; + if (tracksInfo != null) { + return !tracksInfo.containsType(C.TRACK_TYPE_VIDEO); } return false; } protected long getCurrentVideoBitrate() { - if (trackSelectionArray != null) { - TrackSelection trackSelection = trackSelectionArray.get(TRACK_TYPE_VIDEO); - if (trackSelection != null) { - return trackSelection.getSelectedFormat().bitrate; - } + if (currentVideoFormat != null) { + return currentVideoFormat.bitrate; } return -1; } - protected long getCurrentAudioBitrate() { - if (trackSelectionArray != null) { - TrackSelection trackSelection = trackSelectionArray.get(TRACK_TYPE_AUDIO); - if (trackSelection != null) { - return trackSelection.getSelectedFormat().bitrate; - } + protected long getCurrentVideoWidth() { + if (currentVideoFormat != null) { + return currentVideoFormat.width; } return -1; } - protected long getCurrentVideoWidth() { - if (trackSelectionArray != null) { - TrackSelection trackSelection = trackSelectionArray.get(TRACK_TYPE_VIDEO); - if (trackSelection != null) { - return trackSelection.getSelectedFormat().width; - } + protected long getCurrentVideoHeight() { + if (currentVideoFormat != null) { + return currentVideoFormat.height; } return -1; } - protected long getCurrentVideoHeight() { - if (trackSelectionArray != null) { - TrackSelection trackSelection = trackSelectionArray.get(TRACK_TYPE_VIDEO); - if (trackSelection != null) { - return trackSelection.getSelectedFormat().height; - } + protected long getCurrentAudioBitrate() { + if (currentAudioFormat != null) { + return currentAudioFormat.bitrate; } return -1; } - protected void notifyAboutTrackChange(TrackSelectionArray trackSelections) { + protected void setCurrentVideoFormat(@NonNull Format videoFormat) { + currentVideoFormat = videoFormat; + } + + protected void setCurrentAudioFormat(@NonNull Format audioFormat) { + currentAudioFormat = audioFormat; + } - this.trackSelectionArray = trackSelections; + @Nullable + private Format getSelectedTextTrackFormat() { + if (tracksInfo != null && !tracksInfo.getGroups().isEmpty()) { + for (Tracks.Group trackGroupInfo : tracksInfo.getGroups()) { + + if (trackGroupInfo.getType() == C.TRACK_TYPE_TEXT && + trackGroupInfo.isSelected() && + trackGroupInfo.getMediaTrackGroup().length > 0) { + for (int groupIndex = 0; groupIndex < trackGroupInfo.length; groupIndex++) { + + if (trackGroupInfo.isTrackSelected(groupIndex)) { + return trackGroupInfo.getTrackFormat(groupIndex); + } + } + } + } + } + + return null; + } + + protected void notifyAboutTrackChange(Tracks trackSelections) { + + this.tracksInfo = trackSelections; if (tracksInfoListener == null) { return; } @@ -1366,6 +2123,13 @@ BaseTrack getLastSelectedTrack(int renderType) { } } break; + case TRACK_TYPE_IMAGE: + for (ImageTrack track : imageTracks) { + if (track.getUniqueId().equals(lastSelectedTrackIds[renderType])) { + return track; + } + } + break; } log.w("For some reason we could not found lastSelectedTrack of the specified render type = " + renderType); @@ -1374,13 +2138,23 @@ BaseTrack getLastSelectedTrack(int renderType) { // clean previous selection protected void stop() { - lastSelectedTrackIds = new String[]{NONE, NONE, NONE}; - requestedChangeTrackIds = new String[]{NONE, NONE, NONE}; - trackSelectionArray = null; + lastSelectedTrackIds = new String[]{NONE, NONE, NONE, NONE}; + lastDisabledTrackIds = new String[]{NONE, NONE, NONE, NONE}; + requestedChangeTrackIds = new String[]{NONE, NONE, NONE, NONE}; + tracksInfo = null; mappedTrackInfo = null; videoTracks.clear(); audioTracks.clear(); textTracks.clear(); + imageTracks.clear(); + currentVideoFormat = null; + currentAudioFormat = null; + mediaItemManifestURL = null; + redirectPlaybackURL = null; + + if (originalVideoTracks != null) { + originalVideoTracks.clear(); + } } /** @@ -1573,8 +2347,19 @@ private boolean isValidPreferredTextConfig() { (preferredTextTrackConfig.getPreferredMode() == PKTrackConfig.Mode.SELECTION && preferredTextTrackConfig.getTrackLanguage() == null)); } - protected void applyPlayerSettings(PlayerSettings settings) { - this.playerSettings = settings; + private boolean assertTrackSelectorIsNull(String methodName) { + if (selector == null) { + String nullTrackSelectorMsgFormat = "Attempt to invoke '%s' on null instance of the track selector"; + log.w(String.format(nullTrackSelectorMsgFormat, methodName)); + return true; + } + return false; + } + + public void applyPlayerSettings(PlayerSettings settings) { + if (settings != null) { + this.playerSettings = settings; + } } protected void hasExternalSubtitles(boolean hasExternalSubtitles) { @@ -1608,4 +2393,116 @@ public static boolean isCodecSupported(@NonNull String codecs, @Nullable TrackTy return PKCodecSupport.hasDecoder(codecs, false, allowSoftware); } } + + private int findImageIndex(List> sortedRangesList, int listSize, long positionMS) { + int low = 0, high = listSize - 1; + + // Binary search + while (low <= high) + { + // Find the mid element + int mid = (low + high) >> 1; + // If element is found + if (positionMS >= sortedRangesList.get(mid).first && + positionMS <= sortedRangesList.get(mid).second) + return mid; + + // Check in first half + else if (positionMS < sortedRangesList.get(mid).first) + high = mid - 1; + // Check in second half + else + low = mid + 1; + } + // Not found + return -1; + } + + private int getExoTrackType(int rendererIndex) { + int exoTrackType = -1; + switch(rendererIndex) { + case TRACK_TYPE_VIDEO: + exoTrackType = C.TRACK_TYPE_VIDEO; + break; + case TRACK_TYPE_AUDIO: + exoTrackType = C.TRACK_TYPE_AUDIO; + break; + case TRACK_TYPE_TEXT: + exoTrackType = C.TRACK_TYPE_TEXT; + break; + case TRACK_TYPE_IMAGE: + exoTrackType = C.TRACK_TYPE_IMAGE; + break; + } + return exoTrackType; + } + + private void disableTrack(int rendererIndex) { + switch (rendererIndex) { + case TRACK_TYPE_VIDEO: + disableVideoTracks(true); + break; + case TRACK_TYPE_AUDIO: + disableAudioTracks(true); + break; + case TRACK_TYPE_TEXT: + disableTextTracks(true); + break; + default: + break; + } + } + + public void disableVideoTracks(boolean isDisabled) { + log.d("disableVideoTracks isDisabled:" + isDisabled); + disableTracks(TRACK_TYPE_VIDEO, isDisabled); + } + + public void disableAudioTracks(boolean isDisabled) { + log.d("disableAudioTracks isDisabled:" + isDisabled); + disableTracks(TRACK_TYPE_AUDIO, isDisabled); + } + + public void disableTextTracks(boolean isDisabled) { + log.d("disableTextTracks isDisabled:" + isDisabled); + disableTracks(TRACK_TYPE_TEXT, isDisabled); + } + + private void disableTracks(int trackType, boolean isDisabled) { + + if (trackType == TRACK_TYPE_VIDEO && videoTracks.isEmpty()) { + return; + } + + if (trackType == TRACK_TYPE_AUDIO && audioTracks.isEmpty()) { + return; + } + + if (trackType == TRACK_TYPE_TEXT && textTracks.isEmpty()) { + return; + } + + if (isDisabled) { + trackSelectionParametersBuilder.setTrackTypeDisabled(getExoTrackType(trackType), true); + trackSelectionParametersBuilder.clearOverridesOfType(getExoTrackType(trackType)); + lastDisabledTrackIds[trackType] = DISABLED; + } else { + String uniqueTrackId = lastSelectedTrackIds[trackType]; + log.d("uniqueTrackId :" + uniqueTrackId); + if (!NONE.equals(uniqueTrackId)) { + int[] parsedUniqueId = parseUniqueId(uniqueTrackId); + int rendererIndex = parsedUniqueId[RENDERER_INDEX]; + int groupIndex = parsedUniqueId[GROUP_INDEX]; + TrackGroup trackGroup = mappedTrackInfo.getTrackGroups(rendererIndex).get(groupIndex); + + List selectedTrackIndices = retrieveOverrideSelection(parsedUniqueId); + + TrackSelectionOverride trackSelectionOverride = new TrackSelectionOverride(trackGroup, selectedTrackIndices); + trackSelectionParametersBuilder.setOverrideForType(trackSelectionOverride); + lastDisabledTrackIds[trackType] = NONE; + } + trackSelectionParametersBuilder.setTrackTypeDisabled(getExoTrackType(trackType), false); + } + selector.setParameters(trackSelectionParametersBuilder.build()); + } } diff --git a/playkit/src/main/java/com/kaltura/playkit/player/VideoCodecSettings.java b/playkit/src/main/java/com/kaltura/playkit/player/VideoCodecSettings.java index baac01b16..0a0ded9d7 100644 --- a/playkit/src/main/java/com/kaltura/playkit/player/VideoCodecSettings.java +++ b/playkit/src/main/java/com/kaltura/playkit/player/VideoCodecSettings.java @@ -15,7 +15,7 @@ public VideoCodecSettings() { } public VideoCodecSettings(List codecPriorityList, boolean allowSoftwareDecoder, boolean allowMixedCodecAdaptiveness) { - if (codecPriorityList != null || codecPriorityList.isEmpty()) { + if (codecPriorityList != null && !codecPriorityList.isEmpty()) { this.codecPriorityList = codecPriorityList; } else { getDefaultCodecsPriorityList(); diff --git a/playkit/src/main/java/com/kaltura/playkit/player/VideoTrack.java b/playkit/src/main/java/com/kaltura/playkit/player/VideoTrack.java index 96653957b..9ee2b79ff 100644 --- a/playkit/src/main/java/com/kaltura/playkit/player/VideoTrack.java +++ b/playkit/src/main/java/com/kaltura/playkit/player/VideoTrack.java @@ -17,6 +17,8 @@ import com.kaltura.playkit.PKVideoCodec; +import java.util.Comparator; + /** * Video track data holder. @@ -92,6 +94,33 @@ public int getHeight() { @Override public int compareTo(@NonNull VideoTrack track) { - return Integer.compare((int)this.getBitrate(), (int)track.getBitrate()); + return Long.compare((long)this.getBitrate(), (long)track.getBitrate()); + } + + public static class HeightComparator implements Comparator { + @Override + public int compare(VideoTrack videoTrack1, VideoTrack videoTrack2) { + Integer track1 = videoTrack1.getHeight(); + Integer track2 = videoTrack2.getHeight(); + return track1.compareTo(track2); + } + } + + public static class WidthComparator implements Comparator { + @Override + public int compare(VideoTrack videoTrack1, VideoTrack videoTrack2) { + Integer track1 = videoTrack1.getWidth(); + Integer track2 = videoTrack2.getWidth(); + return track1.compareTo(track2); + } + } + + public static class PixelComparator implements Comparator { + @Override + public int compare(VideoTrack videoTrack1, VideoTrack videoTrack2) { + Integer track1 = videoTrack1.getWidth() * videoTrack1.getHeight(); + Integer track2 = videoTrack2.getWidth() * videoTrack2.getHeight(); + return track1.compareTo(track2); + } } } diff --git a/playkit/src/main/java/com/kaltura/playkit/player/metadata/MetadataConverter.java b/playkit/src/main/java/com/kaltura/playkit/player/metadata/MetadataConverter.java index 9600f6afe..156f16d4f 100644 --- a/playkit/src/main/java/com/kaltura/playkit/player/metadata/MetadataConverter.java +++ b/playkit/src/main/java/com/kaltura/playkit/player/metadata/MetadataConverter.java @@ -12,17 +12,17 @@ package com.kaltura.playkit.player.metadata; -import com.kaltura.android.exoplayer2.metadata.Metadata; -import com.kaltura.android.exoplayer2.metadata.emsg.EventMessage; -import com.kaltura.android.exoplayer2.metadata.id3.ApicFrame; -import com.kaltura.android.exoplayer2.metadata.id3.BinaryFrame; -import com.kaltura.android.exoplayer2.metadata.id3.ChapterFrame; -import com.kaltura.android.exoplayer2.metadata.id3.ChapterTocFrame; -import com.kaltura.android.exoplayer2.metadata.id3.CommentFrame; -import com.kaltura.android.exoplayer2.metadata.id3.GeobFrame; -import com.kaltura.android.exoplayer2.metadata.id3.PrivFrame; -import com.kaltura.android.exoplayer2.metadata.id3.TextInformationFrame; -import com.kaltura.android.exoplayer2.metadata.id3.UrlLinkFrame; +import com.kaltura.androidx.media3.common.Metadata; +import com.kaltura.androidx.media3.extractor.metadata.emsg.EventMessage; +import com.kaltura.androidx.media3.extractor.metadata.id3.ApicFrame; +import com.kaltura.androidx.media3.extractor.metadata.id3.BinaryFrame; +import com.kaltura.androidx.media3.extractor.metadata.id3.ChapterFrame; +import com.kaltura.androidx.media3.extractor.metadata.id3.ChapterTocFrame; +import com.kaltura.androidx.media3.extractor.metadata.id3.CommentFrame; +import com.kaltura.androidx.media3.extractor.metadata.id3.GeobFrame; +import com.kaltura.androidx.media3.extractor.metadata.id3.PrivFrame; +import com.kaltura.androidx.media3.extractor.metadata.id3.TextInformationFrame; +import com.kaltura.androidx.media3.extractor.metadata.id3.UrlLinkFrame; import java.util.ArrayList; import java.util.Arrays; diff --git a/playkit/src/main/java/com/kaltura/playkit/player/thumbnail/ImageRangeInfo.java b/playkit/src/main/java/com/kaltura/playkit/player/thumbnail/ImageRangeInfo.java new file mode 100644 index 000000000..fd4811068 --- /dev/null +++ b/playkit/src/main/java/com/kaltura/playkit/player/thumbnail/ImageRangeInfo.java @@ -0,0 +1,20 @@ +package com.kaltura.playkit.player.thumbnail; + +public class ImageRangeInfo { + private final long startPosition; + private final long endPosition; + + public ImageRangeInfo(long startPosition, long endPosition) { + this.startPosition = startPosition; + this.endPosition = endPosition; + } + + public long getStartPosition() { + return startPosition; + } + + public long getEndPosition() { + return endPosition; + } +} + diff --git a/playkit/src/main/java/com/kaltura/playkit/player/thumbnail/PKThumbnailsWebVttDecoder.java b/playkit/src/main/java/com/kaltura/playkit/player/thumbnail/PKThumbnailsWebVttDecoder.java new file mode 100644 index 000000000..5494dc685 --- /dev/null +++ b/playkit/src/main/java/com/kaltura/playkit/player/thumbnail/PKThumbnailsWebVttDecoder.java @@ -0,0 +1,102 @@ +package com.kaltura.playkit.player.thumbnail; + +import android.text.TextUtils; + +import androidx.annotation.Nullable; + +import com.kaltura.androidx.media3.common.ParserException; +import com.kaltura.androidx.media3.extractor.text.SimpleSubtitleDecoder; +import com.kaltura.androidx.media3.extractor.text.Subtitle; +import com.kaltura.androidx.media3.extractor.text.SubtitleDecoderException; +import com.kaltura.androidx.media3.extractor.text.webvtt.WebvttCssStyle; +import com.kaltura.androidx.media3.extractor.text.webvtt.WebvttCueInfo; +import com.kaltura.androidx.media3.extractor.text.webvtt.WebvttCueParser; +import com.kaltura.androidx.media3.extractor.text.webvtt.WebvttParserUtil; +import com.kaltura.androidx.media3.common.util.ParsableByteArray; + +import java.util.ArrayList; +import java.util.List; + +public final class PKThumbnailsWebVttDecoder extends SimpleSubtitleDecoder { + + private static final int EVENT_NONE = -1; + private static final int EVENT_END_OF_FILE = 0; + private static final int EVENT_COMMENT = 1; + private static final int EVENT_STYLE_BLOCK = 2; + private static final int EVENT_CUE = 3; + + private static final String COMMENT_START = "NOTE"; + private static final String STYLE_START = "STYLE"; + + private final ParsableByteArray parsableWebvttData; + + public PKThumbnailsWebVttDecoder() { + super("WebvttDecoder"); + parsableWebvttData = new ParsableByteArray(); + } + + @Override + public Subtitle decode(byte[] bytes, int length, boolean reset) + throws SubtitleDecoderException { + parsableWebvttData.reset(bytes, length); + List definedStyles = new ArrayList<>(); + + // Validate the first line of the header, and skip the remainder. + try { + WebvttParserUtil.validateWebvttHeaderLine(parsableWebvttData); + } catch (ParserException e) { + throw new SubtitleDecoderException(e); + } + while (!TextUtils.isEmpty(parsableWebvttData.readLine())) {} + + int event; + List cueInfos = new ArrayList<>(); + while ((event = getNextEvent(parsableWebvttData)) != EVENT_END_OF_FILE) { + if (event == EVENT_COMMENT) { + skipComment(parsableWebvttData); + } else if (event == EVENT_STYLE_BLOCK) { + if (!cueInfos.isEmpty()) { + throw new SubtitleDecoderException("A style block was found after the first cue."); + } + parsableWebvttData.readLine(); // Consume the "STYLE" header. + } else if (event == EVENT_CUE) { + @Nullable + WebvttCueInfo cueInfo = WebvttCueParser.parseCue(parsableWebvttData, definedStyles); + if (cueInfo != null) { + cueInfos.add(cueInfo); + } + } + } + return new PKWebvttSubtitle(cueInfos); + } + + /** + * Positions the input right before the next event, and returns the kind of event found. Does not + * consume any data from such event, if any. + * + * @return The kind of event found. + */ + private static int getNextEvent(ParsableByteArray parsableWebvttData) { + int foundEvent = EVENT_NONE; + int currentInputPosition = 0; + while (foundEvent == EVENT_NONE) { + currentInputPosition = parsableWebvttData.getPosition(); + String line = parsableWebvttData.readLine(); + if (line == null) { + foundEvent = EVENT_END_OF_FILE; + } else if (STYLE_START.equals(line)) { + foundEvent = EVENT_STYLE_BLOCK; + } else if (line.startsWith(COMMENT_START)) { + foundEvent = EVENT_COMMENT; + } else { + foundEvent = EVENT_CUE; + } + } + parsableWebvttData.setPosition(currentInputPosition); + return foundEvent; + } + + private static void skipComment(ParsableByteArray parsableWebvttData) { + while (!TextUtils.isEmpty(parsableWebvttData.readLine())) {} + } +} diff --git a/playkit/src/main/java/com/kaltura/playkit/player/thumbnail/PKWebvttSubtitle.java b/playkit/src/main/java/com/kaltura/playkit/player/thumbnail/PKWebvttSubtitle.java new file mode 100644 index 000000000..7baa65647 --- /dev/null +++ b/playkit/src/main/java/com/kaltura/playkit/player/thumbnail/PKWebvttSubtitle.java @@ -0,0 +1,80 @@ +package com.kaltura.playkit.player.thumbnail; + +import com.kaltura.androidx.media3.common.C; +import com.kaltura.androidx.media3.common.text.Cue; +import com.kaltura.androidx.media3.extractor.text.Subtitle; +import com.kaltura.androidx.media3.extractor.text.webvtt.WebvttCueInfo; +import com.kaltura.androidx.media3.common.util.Assertions; +import com.kaltura.androidx.media3.common.util.Util; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +public final class PKWebvttSubtitle implements Subtitle { + + private final List cueInfos; + private final long[] cueTimesUs; + private final long[] sortedCueTimesUs; + + /** Constructs a new WebvttSubtitle from a list of {@link WebvttCueInfo}s. */ + public PKWebvttSubtitle(List cueInfos) { + this.cueInfos = Collections.unmodifiableList(new ArrayList<>(cueInfos)); + cueTimesUs = new long[2 * cueInfos.size()]; + for (int cueIndex = 0; cueIndex < cueInfos.size(); cueIndex++) { + WebvttCueInfo cueInfo = cueInfos.get(cueIndex); + int arrayIndex = cueIndex * 2; + cueTimesUs[arrayIndex] = cueInfo.startTimeUs; + cueTimesUs[arrayIndex + 1] = cueInfo.endTimeUs; + } + sortedCueTimesUs = Arrays.copyOf(cueTimesUs, cueTimesUs.length); + Arrays.sort(sortedCueTimesUs); + } + + @Override + public int getNextEventTimeIndex(long timeUs) { + int index = Util.binarySearchCeil(sortedCueTimesUs, timeUs, false, false); + return index < sortedCueTimesUs.length ? index : C.INDEX_UNSET; + } + + @Override + public int getEventTimeCount() { + return sortedCueTimesUs.length; + } + + @Override + public long getEventTime(int index) { + Assertions.checkArgument(index >= 0); + Assertions.checkArgument(index < sortedCueTimesUs.length); + return sortedCueTimesUs[index]; + } + + @Override + public List getCues(long timeUs) { + List currentCues = new ArrayList<>(); + List cuesWithUnsetLine = new ArrayList<>(); + for (int i = 0; i < cueInfos.size(); i++) { + if ((cueTimesUs[i * 2] <= timeUs) && (timeUs < cueTimesUs[i * 2 + 1])) { + WebvttCueInfo cueInfo = cueInfos.get(i); + if (cueInfo.cue.line == Cue.DIMEN_UNSET) { + cuesWithUnsetLine.add(cueInfo); + } else { + currentCues.add(cueInfo.cue); + } + } + } + // Steps 4 - 10 of https://www.w3.org/TR/webvtt1/#cue-computed-line + // (steps 1 - 3 are handled by WebvttCueParser#computeLine(float, int)) + Collections.sort(cuesWithUnsetLine, (c1, c2) -> Long.compare(c1.startTimeUs, c2.startTimeUs)); + for (int i = 0; i < cuesWithUnsetLine.size(); i++) { + Cue cue = cuesWithUnsetLine.get(i).cue; + currentCues.add(cue.buildUpon().setLine((float) (-1 - i), Cue.LINE_TYPE_NUMBER).build()); + } + return currentCues; + } + + public List getCueInfos() { + return cueInfos; + } +} diff --git a/playkit/src/main/java/com/kaltura/playkit/player/thumbnail/ThumbnailDimensions.java b/playkit/src/main/java/com/kaltura/playkit/player/thumbnail/ThumbnailDimensions.java new file mode 100644 index 000000000..883453887 --- /dev/null +++ b/playkit/src/main/java/com/kaltura/playkit/player/thumbnail/ThumbnailDimensions.java @@ -0,0 +1,19 @@ +package com.kaltura.playkit.player.thumbnail; + +public class ThumbnailDimensions { + private final int width; + private final int height; + + public ThumbnailDimensions(int width, int height) { + this.width = width; + this.height = height; + } + + public int getWidth() { + return width; + } + + public int getHeight() { + return height; + } +} diff --git a/playkit/src/main/java/com/kaltura/playkit/player/thumbnail/ThumbnailInfo.java b/playkit/src/main/java/com/kaltura/playkit/player/thumbnail/ThumbnailInfo.java new file mode 100644 index 000000000..efa306efc --- /dev/null +++ b/playkit/src/main/java/com/kaltura/playkit/player/thumbnail/ThumbnailInfo.java @@ -0,0 +1,37 @@ +package com.kaltura.playkit.player.thumbnail; + +public class ThumbnailInfo { + private final String url; // url of the image that contains the thumbnail slice + private final float x; // x position of the thumbnail + private final float y; // y position of the thumbnail + private final float width; // width of the thumbnail + private final float height; // height of the thumbnail + + public ThumbnailInfo(String url, float x, float y, float width, float height) { + this.url = url; + this.x = x; + this.y = y; + this.width = width; + this.height = height; + } + + public String getUrl() { + return url; + } + + public float getX() { + return x; + } + + public float getY() { + return y; + } + + public float getWidth() { + return width; + } + + public float getHeight() { + return height; + } +} diff --git a/playkit/src/main/java/com/kaltura/playkit/player/thumbnail/ThumbnailVodInfo.java b/playkit/src/main/java/com/kaltura/playkit/player/thumbnail/ThumbnailVodInfo.java new file mode 100644 index 000000000..afc1043da --- /dev/null +++ b/playkit/src/main/java/com/kaltura/playkit/player/thumbnail/ThumbnailVodInfo.java @@ -0,0 +1,60 @@ +package com.kaltura.playkit.player.thumbnail; + +import com.kaltura.playkit.player.DashImageTrack; +import com.kaltura.playkit.player.ImageTrack; + +import java.util.LinkedHashMap; +import java.util.Map; + + +class ThumbnailVodInfo { + + Map imageRangeThumbnailtMap; + + public Map getImageRangeThumbnailMap() { + return imageRangeThumbnailtMap; + } + + public ThumbnailVodInfo(Map imageRangeThumbnailMap) { + this.imageRangeThumbnailtMap = imageRangeThumbnailMap; + } + + public ThumbnailVodInfo(long imageUrlIndex, ImageTrack imageTrack, long mediaDurationMS, long startNumber, boolean isCatchup) { + + long imageMultiplier = imageUrlIndex <= 0 ? 0 : imageUrlIndex - 1; + long imageRealUrlTime = (imageMultiplier * imageTrack.getDuration()); + + if (isCatchup) { + imageMultiplier = ((DashImageTrack)imageTrack).getStartNumber() - imageUrlIndex <= 0 ? 0 : startNumber - imageUrlIndex - 1; + imageRealUrlTime = startNumber + (imageMultiplier * imageTrack.getDuration()); + } + String realImageUrl = imageTrack.getUrl().replace("$Number$", String.valueOf(imageUrlIndex)).replace("$Time$", String.valueOf(imageRealUrlTime)); + + long singleImageDuration = (long) Math.ceil(imageTrack.getDuration() / (imageTrack.getCols() * imageTrack.getRows())); + + + imageRangeThumbnailtMap = new LinkedHashMap<>(); + long rangeStart = startNumber == 1 ? 0 : startNumber; + long rangeEnd = (((imageMultiplier * imageTrack.getDuration()) + singleImageDuration) - 1); + long diff; + if (rangeStart > rangeEnd) { + rangeEnd = rangeStart + rangeEnd; + } + diff = rangeEnd - rangeStart; + float widthPerTile = imageTrack.getWidth() / imageTrack.getCols(); + float heightPerTile = imageTrack.getHeight() / imageTrack.getRows(); + for (int rowIndex = 0; rowIndex < imageTrack.getRows(); rowIndex++) { + for (int colIndex = 0; colIndex < imageTrack.getCols(); colIndex++) { + ImageRangeInfo imageRangeInfo = new ImageRangeInfo(rangeStart, rangeEnd); + ThumbnailInfo thumbnailInfo = new ThumbnailInfo(realImageUrl, colIndex * widthPerTile, rowIndex * heightPerTile, widthPerTile, heightPerTile); + + if (rangeEnd - diff > mediaDurationMS + ((DashImageTrack)imageTrack).getStartNumber()) { + continue; + } + imageRangeThumbnailtMap.put(imageRangeInfo, thumbnailInfo); + rangeStart += singleImageDuration; + rangeEnd = rangeStart + diff; + } + } + } +} diff --git a/playkit/src/main/java/com/kaltura/playkit/player/thumbnail/VttThumbnailDownloader.java b/playkit/src/main/java/com/kaltura/playkit/player/thumbnail/VttThumbnailDownloader.java new file mode 100644 index 000000000..d1fa778cb --- /dev/null +++ b/playkit/src/main/java/com/kaltura/playkit/player/thumbnail/VttThumbnailDownloader.java @@ -0,0 +1,23 @@ +package com.kaltura.playkit.player.thumbnail; + +import com.kaltura.androidx.media3.extractor.text.Subtitle; +import com.kaltura.androidx.media3.extractor.text.SubtitleDecoderException; +import com.kaltura.playkit.Utils; + +import java.io.IOException; +import java.util.concurrent.Callable; + +public class VttThumbnailDownloader implements Callable { + private final String vttThumbnailUrl; + + public VttThumbnailDownloader(String vttThumbnailUrl) { + this.vttThumbnailUrl = vttThumbnailUrl; + } + + public Subtitle call() throws IOException, SubtitleDecoderException { + + byte[] bytes = Utils.executeGet(vttThumbnailUrl, null); + PKThumbnailsWebVttDecoder pkThumbnailsWebVttDecoder = new PKThumbnailsWebVttDecoder(); + return pkThumbnailsWebVttDecoder.decode(bytes, bytes.length, true); + } +} \ No newline at end of file diff --git a/playkit/src/main/java/com/kaltura/playkit/player/vr/VRDistortionConfig.kt b/playkit/src/main/java/com/kaltura/playkit/player/vr/VRDistortionConfig.kt new file mode 100644 index 000000000..683464f4d --- /dev/null +++ b/playkit/src/main/java/com/kaltura/playkit/player/vr/VRDistortionConfig.kt @@ -0,0 +1,39 @@ +package com.kaltura.playkit.player.vr + +/** + * Class gives you the ability to perform distortion on the VR lens + * Barrel/PinCushion distortion both can be achieved by modifying the config params + * + * You may correct pincushion and barrel distortions simultaneously in the same image: + * if the outer regions exhibit barrel distortion, and the inner parts pincushion, + * you should use negative a and positive b values. + * + * Addition of paramA, paramB and paramC should be 1 in case if you don't want any + * scale in the frame. + * + * paramA, paramB and paramC describe distortion of the frame. + * + * @param paramA Affects only the outermost pixels of the image. Range for is between -1.0 <-> 1.0 + * @param paramB Most cases only require b optimization. Range for is between -1.0 <-> 1.0 + * @param paramC Most uniform correction. Range for is between -1.0 <-> 1.0 + * + * @param scale range is 0.10 <-> 1.0 + * @param defaultEnabled default is `false` + * + * More information about Distortion can be found here + * https://mipav.cit.nih.gov/pubwiki/index.php/Barrel_Distortion_Correction + */ +data class VRDistortionConfig(var paramA: Double = DEFAULT_PARAM_A, + var paramB: Double = DEFAULT_PARAM_B, + var paramC: Double = DEFAULT_PARAM_C, + var scale: Float = DEFAULT_BARREL_DISTORTION_SCALE, + var defaultEnabled: Boolean = false) { + + companion object { + const val DEFAULT_PARAM_A = -0.068 + const val DEFAULT_PARAM_B = 0.320000 + const val DEFAULT_PARAM_C = -0.2 + const val DEFAULT_BARREL_DISTORTION_SCALE = 0.95f + } +} + diff --git a/playkit/src/main/java/com/kaltura/playkit/player/vr/VRPlayerFactory.java b/playkit/src/main/java/com/kaltura/playkit/player/vr/VRPlayerFactory.java index e3753f954..3835d8f50 100644 --- a/playkit/src/main/java/com/kaltura/playkit/player/vr/VRPlayerFactory.java +++ b/playkit/src/main/java/com/kaltura/playkit/player/vr/VRPlayerFactory.java @@ -2,6 +2,8 @@ import android.content.Context; +import androidx.annotation.Nullable; + import com.kaltura.playkit.player.BaseExoplayerView; import com.kaltura.playkit.player.PlayerEngine; @@ -11,7 +13,7 @@ public interface VRPlayerFactory { - PlayerEngine newInstance(Context context, PlayerEngine player); + PlayerEngine newInstance(Context context, PlayerEngine player, @Nullable VRSettings vrSettings); BaseExoplayerView newVRViewInstance(Context context); } diff --git a/playkit/src/main/java/com/kaltura/playkit/player/vr/VRSettings.java b/playkit/src/main/java/com/kaltura/playkit/player/vr/VRSettings.java index 411bc34ea..baa6b02a1 100644 --- a/playkit/src/main/java/com/kaltura/playkit/player/vr/VRSettings.java +++ b/playkit/src/main/java/com/kaltura/playkit/player/vr/VRSettings.java @@ -1,5 +1,7 @@ package com.kaltura.playkit.player.vr; +import javax.annotation.Nonnull; + /** * Created by anton.afanasiev on 25/03/2018. */ @@ -10,6 +12,7 @@ public class VRSettings { private boolean vrModeEnabled; //false by default private boolean zoomWithPinchEnabled = true; // true by default. private boolean flingEnabled; //false by default. + private VRDistortionConfig vrDistortionConfig; /** * Allows to enable/disable VR mode. Where content is shown in @@ -62,6 +65,11 @@ public VRSettings setFlingEnabled(boolean shouldEnable) { return this; } + public VRSettings setVrDistortionConfig(@Nonnull VRDistortionConfig vrDistortionConfig) { + this.vrDistortionConfig = vrDistortionConfig; + return this; + } + public VRInteractionMode getInteractionMode() { return interactionMode; } @@ -77,4 +85,8 @@ public boolean isZoomWithPinchEnabled() { public boolean isFlingEnabled() { return flingEnabled; } + + public VRDistortionConfig getVrDistortionConfig() { + return vrDistortionConfig; + } } diff --git a/playkit/src/main/java/com/kaltura/playkit/plugins/ads/AdEvent.java b/playkit/src/main/java/com/kaltura/playkit/plugins/ads/AdEvent.java index 6faccf77d..96453157a 100644 --- a/playkit/src/main/java/com/kaltura/playkit/plugins/ads/AdEvent.java +++ b/playkit/src/main/java/com/kaltura/playkit/plugins/ads/AdEvent.java @@ -16,6 +16,7 @@ import com.kaltura.playkit.PKError; import com.kaltura.playkit.PKEvent; +import com.kaltura.playkit.ads.AdBreakConfig; @SuppressWarnings("unused") public class AdEvent implements PKEvent { @@ -35,6 +36,8 @@ public class AdEvent implements PKEvent { public static final Class adClickedEvent = AdClickedEvent.class; public static final Class error = Error.class; public static final Class daiSourceSelected = DAISourceSelected.class; + public static final Class adWaterFalling = AdWaterFalling.class; + public static final Class adWaterFallingFailed = AdWaterFallingFailed.class; public static final AdEvent.Type adFirstPlay = Type.AD_FIRST_PLAY; public static final AdEvent.Type adDisplayedAfterContentPause = Type.AD_DISPLAYED_AFTER_CONTENT_PAUSE; @@ -136,10 +139,12 @@ public AdPlayHeadEvent(long adPlayHead) { public static class AdRequestedEvent extends AdEvent { public final String adTagUrl; + public final boolean isAutoPlay; - public AdRequestedEvent(String adTagUrl) { + public AdRequestedEvent(String adTagUrl, boolean isAutoPlay) { super(Type.AD_REQUESTED); this.adTagUrl = adTagUrl; + this.isAutoPlay = isAutoPlay; } } @@ -217,6 +222,26 @@ public DAISourceSelected(String sourceURL) { } } + public static class AdWaterFalling extends AdEvent { + + public final AdBreakConfig adBreakConfig; + + public AdWaterFalling(AdBreakConfig adBreakConfig) { + super(Type.AD_WATERFALLING); + this.adBreakConfig = adBreakConfig; + } + } + + public static class AdWaterFallingFailed extends AdEvent { + + public final AdBreakConfig adBreakConfig; + + public AdWaterFallingFailed(AdBreakConfig adBreakConfig) { + super(Type.AD_WATERFALLING_FAILED); + this.adBreakConfig = adBreakConfig; + } + } + public enum Type { AD_REQUESTED, AD_FIRST_PLAY, @@ -251,7 +276,9 @@ public enum Type { AD_BUFFER_END, AD_PLAYBACK_INFO_UPDATED, ERROR, - DAI_SOURCE_SELECTED + DAI_SOURCE_SELECTED, + AD_WATERFALLING, + AD_WATERFALLING_FAILED } diff --git a/playkit/src/main/java/com/kaltura/playkit/plugins/ads/AdInfo.java b/playkit/src/main/java/com/kaltura/playkit/plugins/ads/AdInfo.java index a6dc0398e..1b08faae3 100644 --- a/playkit/src/main/java/com/kaltura/playkit/plugins/ads/AdInfo.java +++ b/playkit/src/main/java/com/kaltura/playkit/plugins/ads/AdInfo.java @@ -14,6 +14,9 @@ import com.kaltura.playkit.ads.PKAdInfo; +import java.util.Collections; +import java.util.List; + /** * Created by gilad.nadav on 22/11/2016. */ @@ -37,6 +40,9 @@ public class AdInfo implements PKAdInfo { private String dealId; private String surveyUrl; private String traffickingParams; + private List adWrapperCreativeIds; + private List adWrapperIds; + private List adWrapperSystems; private int adHeight; private int adWidth; private int mediaBitrate; @@ -51,7 +57,9 @@ public AdInfo(String adDescription, long adDuration, long adPlayHead, String adT boolean isAdSkippable, long skipTimeOffset, String adContentType, String adId, String adSystem, String creativeId, String creativeAdId, String advertiserName, String dealId, String surveyUrl, - String traffickingParams, int adHeight, int adWidth, int mediaBitrate, + String traffickingParams, List adWrapperCreativeIds, + List adWrapperIds, List adWrapperSystems, + int adHeight, int adWidth, int mediaBitrate, int totalAdsInPod, int adIndexInPod, int currentPodIndex, int podCount, boolean isBumper, long adPodTimeOffset) { @@ -70,6 +78,9 @@ public AdInfo(String adDescription, long adDuration, long adPlayHead, String adT this.dealId = dealId; this.surveyUrl = surveyUrl; this.traffickingParams = traffickingParams; + this.adWrapperCreativeIds = adWrapperCreativeIds; + this.adWrapperIds = adWrapperIds; + this.adWrapperSystems = adWrapperSystems; this.adHeight = adHeight; this.adWidth = adWidth; this.mediaBitrate = mediaBitrate; @@ -177,6 +188,18 @@ public String getTraffickingParams() { return traffickingParams; } + public List getAdWrapperCreativeIds() { + return adWrapperCreativeIds != null ? adWrapperCreativeIds : Collections.emptyList(); + } + + public List getAdWrapperIds() { + return adWrapperIds != null ? adWrapperIds : Collections.emptyList(); + } + + public List getAdWrapperSystems() { + return adWrapperSystems != null ? adWrapperSystems : Collections.emptyList(); + } + @Override public boolean isAdSkippable() { return isAdSkippable; diff --git a/playkit/src/main/java/com/kaltura/playkit/plugins/ads/AdsProvider.java b/playkit/src/main/java/com/kaltura/playkit/plugins/ads/AdsProvider.java index fbd4d2815..885c8c668 100644 --- a/playkit/src/main/java/com/kaltura/playkit/plugins/ads/AdsProvider.java +++ b/playkit/src/main/java/com/kaltura/playkit/plugins/ads/AdsProvider.java @@ -12,9 +12,17 @@ package com.kaltura.playkit.plugins.ads; +import androidx.annotation.NonNull; + +import com.kaltura.playkit.ads.AdType; +import com.kaltura.playkit.ads.IMAEventsListener; import com.kaltura.playkit.ads.PKAdInfo; import com.kaltura.playkit.ads.PKAdPluginType; import com.kaltura.playkit.ads.PKAdProviderListener; +import com.kaltura.playkit.ads.PKAdvertisingAdInfo; +import com.kaltura.playkit.player.PKAspectRatioResizeMode; + +import java.util.List; public interface AdsProvider { @@ -26,6 +34,10 @@ public interface AdsProvider { void pause(); + default void setVolume(float volume) {} + + default void updateSurfaceAspectRatioResizeMode(PKAspectRatioResizeMode resizeMode) {} + void contentCompleted(); PKAdInfo getAdInfo(); @@ -71,4 +83,17 @@ default void seekTo(long position) {} default PKAdPluginType getAdPluginType() { return PKAdPluginType.client; } boolean isContentPrepared(); + + default boolean isAdvertisingConfigured() { return false; } + + default void setAdvertisingConfig(boolean isConfigured, @NonNull AdType adType, IMAEventsListener imaEventsListener) {} + + // @param adTag Ad url or Ad response + default void advertisingPlayAdNow(String adTag) {} + + default void advertisingSetCuePoints(List cuePoints) {} + + default void advertisingSetAdInfo(PKAdvertisingAdInfo pkAdvertisingAdInfo) {} + + default void advertisingPreparePlayer() {} } diff --git a/playkit/src/main/java/com/kaltura/playkit/plugins/playback/CustomPlaybackRequestAdapter.kt b/playkit/src/main/java/com/kaltura/playkit/plugins/playback/CustomPlaybackRequestAdapter.kt new file mode 100644 index 000000000..de60f6c0b --- /dev/null +++ b/playkit/src/main/java/com/kaltura/playkit/plugins/playback/CustomPlaybackRequestAdapter.kt @@ -0,0 +1,33 @@ +package com.kaltura.playkit.plugins.playback + +import com.kaltura.playkit.PKRequestParams +import com.kaltura.playkit.Player +import com.kaltura.playkit.plugins.playback.PlaybackUtils.Companion.getPKRequestParams + +class CustomPlaybackRequestAdapter(applicationName: String?, player: Player) : PKRequestParams.Adapter { + + private var applicationName: String? = null + private var playSessionId: String? = null + private var httpHeaders: Map? = null + + init { + this.applicationName = applicationName + updateParams(player) + } + + fun setHttpHeaders(httpHeaders: Map?) { + this.httpHeaders = httpHeaders + } + + override fun adapt(requestParams: PKRequestParams): PKRequestParams { + return getPKRequestParams(requestParams, playSessionId, applicationName, httpHeaders) + } + + override fun updateParams(player: Player) { + playSessionId = player.sessionId + } + + override fun getApplicationName(): String? { + return applicationName + } +} diff --git a/playkit/src/main/java/com/kaltura/playkit/plugins/playback/KalturaPlaybackRequestAdapter.java b/playkit/src/main/java/com/kaltura/playkit/plugins/playback/KalturaPlaybackRequestAdapter.java index e1bdf037d..bcef44408 100644 --- a/playkit/src/main/java/com/kaltura/playkit/plugins/playback/KalturaPlaybackRequestAdapter.java +++ b/playkit/src/main/java/com/kaltura/playkit/plugins/playback/KalturaPlaybackRequestAdapter.java @@ -12,17 +12,14 @@ package com.kaltura.playkit.plugins.playback; -import android.net.Uri; -import androidx.annotation.NonNull; import android.text.TextUtils; +import androidx.annotation.NonNull; + import com.kaltura.playkit.PKRequestParams; import com.kaltura.playkit.Player; import com.kaltura.playkit.player.PlayerSettings; -import static com.kaltura.playkit.PlayKitManager.CLIENT_TAG; -import static com.kaltura.playkit.Utils.toBase64; - /** * Created by Noam Tamim @ Kaltura on 28/03/2017. */ @@ -50,26 +47,7 @@ private KalturaPlaybackRequestAdapter(String applicationName, Player player) { @NonNull @Override public PKRequestParams adapt(PKRequestParams requestParams) { - Uri url = requestParams.url; - - if (url != null && url.getPath().contains("/playManifest/")) { - Uri alt = url.buildUpon() - .appendQueryParameter("clientTag", CLIENT_TAG) - .appendQueryParameter("playSessionId", playSessionId).build(); - - if (!TextUtils.isEmpty(applicationName)) { - alt = alt.buildUpon().appendQueryParameter("referrer", toBase64(applicationName.getBytes())).build(); - } - - String lastPathSegment = requestParams.url.getLastPathSegment(); - if (lastPathSegment != null && lastPathSegment.endsWith(".wvm")) { - // in old android device it will not play wvc if url is not ended in wvm - alt = alt.buildUpon().appendQueryParameter("name", lastPathSegment).build(); - } - return new PKRequestParams(alt, requestParams.headers); - } - - return requestParams; + return PlaybackUtils.getPKRequestParams(requestParams, playSessionId, applicationName, null); } @Override diff --git a/playkit/src/main/java/com/kaltura/playkit/plugins/playback/KalturaUDRMLicenseRequestAdapter.java b/playkit/src/main/java/com/kaltura/playkit/plugins/playback/KalturaUDRMLicenseRequestAdapter.java index 83c740b94..6350e8e4b 100644 --- a/playkit/src/main/java/com/kaltura/playkit/plugins/playback/KalturaUDRMLicenseRequestAdapter.java +++ b/playkit/src/main/java/com/kaltura/playkit/plugins/playback/KalturaUDRMLicenseRequestAdapter.java @@ -19,6 +19,7 @@ import com.kaltura.playkit.PKRequestParams; import com.kaltura.playkit.Player; +import com.kaltura.playkit.Utils; import static com.kaltura.playkit.PlayKitManager.CLIENT_TAG; @@ -45,7 +46,7 @@ public PKRequestParams adapt(PKRequestParams requestParams) { boolean isEmptyApplicationName = TextUtils.isEmpty(applicationName); if (!isEmptyApplicationName) { - requestParams.headers.put("Referrer", applicationName); + requestParams.headers.put("Referrer", Utils.toBase64(applicationName.getBytes())); } Uri licenseUrl = requestParams.url; @@ -55,7 +56,7 @@ public PKRequestParams adapt(PKRequestParams requestParams) { .appendQueryParameter("clientTag", CLIENT_TAG).build(); if (!isEmptyApplicationName) { - alt = alt.buildUpon().appendQueryParameter("referrer", applicationName).build(); + alt = alt.buildUpon().appendQueryParameter("referrer", Utils.toBase64(applicationName.getBytes())).build(); } return new PKRequestParams(alt, requestParams.headers); diff --git a/playkit/src/main/java/com/kaltura/playkit/plugins/playback/PlaybackUtils.kt b/playkit/src/main/java/com/kaltura/playkit/plugins/playback/PlaybackUtils.kt new file mode 100644 index 000000000..c465b60a5 --- /dev/null +++ b/playkit/src/main/java/com/kaltura/playkit/plugins/playback/PlaybackUtils.kt @@ -0,0 +1,55 @@ +package com.kaltura.playkit.plugins.playback + +import com.kaltura.playkit.PKRequestParams +import com.kaltura.playkit.PlayKitManager +import com.kaltura.playkit.Utils + +class PlaybackUtils { + + companion object { + + @JvmStatic + fun getPKRequestParams(requestParams: PKRequestParams, + playSessionId: String?, applicationName: String?, + httpHeaders: Map?): PKRequestParams { + val url = requestParams.url + + url?.let { + it.path?.let { path -> + if (path.contains("/playManifest/")) { + var alt = url.buildUpon() + .appendQueryParameter("clientTag", PlayKitManager.CLIENT_TAG) + .appendQueryParameter("playSessionId", playSessionId).build() + if (!applicationName.isNullOrEmpty()) { + alt = alt.buildUpon().appendQueryParameter("referrer", Utils.toBase64(applicationName.toByteArray())).build() + } + val lastPathSegment = requestParams.url.lastPathSegment + if (!lastPathSegment.isNullOrEmpty() && lastPathSegment.endsWith(".wvm")) { + // in old android device it will not play wvc if url is not ended in wvm + alt = alt.buildUpon().appendQueryParameter("name", lastPathSegment).build() + } + setCustomHeaders(requestParams, httpHeaders) + return PKRequestParams(alt, requestParams.headers) + } + } + } + + setCustomHeaders(requestParams, httpHeaders) + return requestParams + } + + private fun setCustomHeaders(requestParams: PKRequestParams, httpHeaders: Map?) { + httpHeaders?.let { header -> + if (header.isNotEmpty()) { + header.forEach { (key, value) -> + key?.let { requestKey -> + value?.let { requestValue -> + requestParams.headers[requestKey] = requestValue + } + } + } + } + } + } + } +} diff --git a/playkit/src/main/java/com/kaltura/playkit/profiler/ExoPlayerProfilingListener.java b/playkit/src/main/java/com/kaltura/playkit/profiler/ExoPlayerProfilingListener.java index d4dc4e9e3..177981f1a 100644 --- a/playkit/src/main/java/com/kaltura/playkit/profiler/ExoPlayerProfilingListener.java +++ b/playkit/src/main/java/com/kaltura/playkit/profiler/ExoPlayerProfilingListener.java @@ -1,56 +1,60 @@ package com.kaltura.playkit.profiler; -import android.view.Surface; +import static com.kaltura.androidx.media3.common.C.DATA_TYPE_AD; +import static com.kaltura.androidx.media3.common.C.DATA_TYPE_DRM; +import static com.kaltura.androidx.media3.common.C.DATA_TYPE_MANIFEST; +import static com.kaltura.androidx.media3.common.C.DATA_TYPE_MEDIA; +import static com.kaltura.androidx.media3.common.C.DATA_TYPE_MEDIA_INITIALIZATION; +import static com.kaltura.androidx.media3.common.C.DATA_TYPE_TIME_SYNCHRONIZATION; +import static com.kaltura.androidx.media3.common.C.DATA_TYPE_UNKNOWN; +import static com.kaltura.androidx.media3.common.C.SELECTION_REASON_ADAPTIVE; +import static com.kaltura.androidx.media3.common.C.SELECTION_REASON_INITIAL; +import static com.kaltura.androidx.media3.common.C.SELECTION_REASON_MANUAL; +import static com.kaltura.androidx.media3.common.C.SELECTION_REASON_TRICK_PLAY; +import static com.kaltura.androidx.media3.common.C.SELECTION_REASON_UNKNOWN; +import static com.kaltura.androidx.media3.common.C.TRACK_TYPE_AUDIO; +import static com.kaltura.androidx.media3.common.C.TRACK_TYPE_DEFAULT; +import static com.kaltura.androidx.media3.common.C.TRACK_TYPE_TEXT; +import static com.kaltura.androidx.media3.common.C.TRACK_TYPE_VIDEO; +import static com.kaltura.androidx.media3.common.Player.DISCONTINUITY_REASON_AUTO_TRANSITION; +import static com.kaltura.androidx.media3.common.Player.DISCONTINUITY_REASON_INTERNAL; +import static com.kaltura.androidx.media3.common.Player.DISCONTINUITY_REASON_REMOVE; +import static com.kaltura.androidx.media3.common.Player.DISCONTINUITY_REASON_SEEK; +import static com.kaltura.androidx.media3.common.Player.DISCONTINUITY_REASON_SEEK_ADJUSTMENT; +import static com.kaltura.androidx.media3.common.Player.DISCONTINUITY_REASON_SKIP; +import static com.kaltura.playkit.profiler.PlayKitProfiler.MSEC_MULTIPLIER_FLOAT; +import static com.kaltura.playkit.profiler.PlayKitProfiler.field; +import static com.kaltura.playkit.profiler.PlayKitProfiler.joinFields; +import static com.kaltura.playkit.profiler.PlayKitProfiler.nullable; +import static com.kaltura.playkit.profiler.PlayKitProfiler.timeField; + +import android.util.Pair; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.kaltura.android.exoplayer2.source.LoadEventInfo; -import com.kaltura.android.exoplayer2.source.MediaLoadData; +import com.google.common.collect.ImmutableList; import com.google.gson.JsonArray; import com.google.gson.JsonObject; -import com.kaltura.android.exoplayer2.ExoPlaybackException; -import com.kaltura.android.exoplayer2.Format; -import com.kaltura.android.exoplayer2.Player; -import com.kaltura.android.exoplayer2.analytics.AnalyticsListener; -import com.kaltura.android.exoplayer2.decoder.DecoderCounters; -import com.kaltura.android.exoplayer2.metadata.Metadata; -import com.kaltura.android.exoplayer2.source.TrackGroup; -import com.kaltura.android.exoplayer2.source.TrackGroupArray; -import com.kaltura.android.exoplayer2.trackselection.TrackSelection; -import com.kaltura.android.exoplayer2.trackselection.TrackSelectionArray; +import com.kaltura.androidx.media3.common.Format; +import com.kaltura.androidx.media3.common.PlaybackException; +import com.kaltura.androidx.media3.common.PlaybackParameters; +import com.kaltura.androidx.media3.common.Player; +import com.kaltura.androidx.media3.common.Tracks; +import com.kaltura.androidx.media3.exoplayer.analytics.AnalyticsListener; +import com.kaltura.androidx.media3.exoplayer.DecoderCounters; +import com.kaltura.androidx.media3.exoplayer.DecoderReuseEvaluation; +import com.kaltura.androidx.media3.exoplayer.drm.DrmSession; +import com.kaltura.androidx.media3.common.Metadata; +import com.kaltura.androidx.media3.exoplayer.source.LoadEventInfo; +import com.kaltura.androidx.media3.exoplayer.source.MediaLoadData; +import com.kaltura.androidx.media3.common.TrackGroup; +import com.kaltura.androidx.media3.common.VideoSize; +import com.kaltura.playkit.PKPlaybackException; +import com.kaltura.playkit.player.PKPlayerErrorType; import java.io.IOException; -import java.util.LinkedHashSet; - -import static com.kaltura.android.exoplayer2.C.DATA_TYPE_AD; -import static com.kaltura.android.exoplayer2.C.DATA_TYPE_DRM; -import static com.kaltura.android.exoplayer2.C.DATA_TYPE_MANIFEST; -import static com.kaltura.android.exoplayer2.C.DATA_TYPE_MEDIA; -import static com.kaltura.android.exoplayer2.C.DATA_TYPE_MEDIA_INITIALIZATION; -import static com.kaltura.android.exoplayer2.C.DATA_TYPE_TIME_SYNCHRONIZATION; -import static com.kaltura.android.exoplayer2.C.DATA_TYPE_UNKNOWN; -import static com.kaltura.android.exoplayer2.C.SELECTION_REASON_ADAPTIVE; -import static com.kaltura.android.exoplayer2.C.SELECTION_REASON_INITIAL; -import static com.kaltura.android.exoplayer2.C.SELECTION_REASON_MANUAL; -import static com.kaltura.android.exoplayer2.C.SELECTION_REASON_TRICK_PLAY; -import static com.kaltura.android.exoplayer2.C.SELECTION_REASON_UNKNOWN; -import static com.kaltura.android.exoplayer2.C.TRACK_TYPE_AUDIO; -import static com.kaltura.android.exoplayer2.C.TRACK_TYPE_DEFAULT; -import static com.kaltura.android.exoplayer2.C.TRACK_TYPE_TEXT; -import static com.kaltura.android.exoplayer2.C.TRACK_TYPE_VIDEO; -import static com.kaltura.android.exoplayer2.Player.DISCONTINUITY_REASON_AD_INSERTION; -import static com.kaltura.android.exoplayer2.Player.DISCONTINUITY_REASON_INTERNAL; -import static com.kaltura.android.exoplayer2.Player.DISCONTINUITY_REASON_PERIOD_TRANSITION; -import static com.kaltura.android.exoplayer2.Player.DISCONTINUITY_REASON_SEEK; -import static com.kaltura.android.exoplayer2.Player.DISCONTINUITY_REASON_SEEK_ADJUSTMENT; - -import static com.kaltura.playkit.profiler.PlayKitProfiler.MSEC_MULTIPLIER_FLOAT; -import static com.kaltura.playkit.profiler.PlayKitProfiler.field; -import static com.kaltura.playkit.profiler.PlayKitProfiler.joinFields; -import static com.kaltura.playkit.profiler.PlayKitProfiler.nullable; -import static com.kaltura.playkit.profiler.PlayKitProfiler.timeField; class ExoPlayerProfilingListener implements AnalyticsListener { @@ -111,7 +115,13 @@ private String dataTypeString(int dataType) { } @Override - public void onPlaybackStateChanged(EventTime eventTime, int playbackState) { + public void onIsPlayingChanged(@NonNull EventTime eventTime, boolean isPlaying) { + log("IsPlayingChanged", + field("isPlaying", isPlaying)); + } + + @Override + public void onPlaybackStateChanged(@NonNull EventTime eventTime, int playbackState) { String state; switch (playbackState) { case Player.STATE_IDLE: @@ -133,33 +143,47 @@ public void onPlaybackStateChanged(EventTime eventTime, int playbackState) { } @Override - public void onPlayWhenReadyChanged(EventTime eventTime, boolean playWhenReady, int reason) { + public void onPlayWhenReadyChanged(@NonNull EventTime eventTime, boolean playWhenReady, int reason) { shouldPlay = playWhenReady; } @Override - public void onTimelineChanged(EventTime eventTime, int reason) { - + public void onTimelineChanged(@NonNull EventTime eventTime, int reason) { + String timeLineChangeReason = "NONE"; + switch (reason) { + case Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE: + timeLineChangeReason = "SOURCE_UPDATE"; + break; + case Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED: + timeLineChangeReason = "PLAYLIST_CHANGED"; + break; + } + log("onTimelineChanged", field("reason", timeLineChangeReason)); } @Override - public void onPositionDiscontinuity(EventTime eventTime, int reason) { + public void onPositionDiscontinuity(@NonNull EventTime eventTime, @NonNull Player.PositionInfo oldPosition, + @NonNull Player.PositionInfo newPosition, int reason) { String reasonString; switch (reason) { - case DISCONTINUITY_REASON_PERIOD_TRANSITION: - reasonString = "PeriodTransition"; + case DISCONTINUITY_REASON_AUTO_TRANSITION: + reasonString = "AutoTransition"; break; case DISCONTINUITY_REASON_SEEK: - reasonString = "PeriodTransition"; + log("SeekProcessed"); + reasonString = "Seek"; break; case DISCONTINUITY_REASON_SEEK_ADJUSTMENT: - reasonString = "PeriodTransition"; + reasonString = "SeekAdjustment"; + break; + case DISCONTINUITY_REASON_SKIP: + reasonString = "Skip"; break; - case DISCONTINUITY_REASON_AD_INSERTION: - reasonString = "PeriodTransition"; + case DISCONTINUITY_REASON_REMOVE: + reasonString = "Remove"; break; case DISCONTINUITY_REASON_INTERNAL: - reasonString = "PeriodTransition"; + reasonString = "Internal"; break; default: reasonString = "Unknown:" + reason; @@ -169,12 +193,14 @@ public void onPositionDiscontinuity(EventTime eventTime, int reason) { } @Override - public void onSeekStarted(EventTime eventTime) { - log("SeekStarted"); + public void onPlaybackParametersChanged(@NonNull EventTime eventTime, PlaybackParameters playbackParameters) { + log("PlaybackParametersChanged", + field("speed", playbackParameters.speed), + field("pitch", playbackParameters.pitch)); } @Override - public void onRepeatModeChanged(EventTime eventTime, int repeatMode) { + public void onRepeatModeChanged(@NonNull EventTime eventTime, int repeatMode) { String strMode; switch (repeatMode) { case Player.REPEAT_MODE_OFF: @@ -194,68 +220,56 @@ public void onRepeatModeChanged(EventTime eventTime, int repeatMode) { } @Override - public void onShuffleModeChanged(EventTime eventTime, boolean shuffleModeEnabled) { + public void onShuffleModeChanged(@NonNull EventTime eventTime, boolean shuffleModeEnabled) { log("ShuffleModeChanged", field("shuffleModeEnabled", shuffleModeEnabled)); } @Override - public void onPlayerError(EventTime eventTime, ExoPlaybackException error) { - String type = null; - switch (error.type) { - case ExoPlaybackException.TYPE_SOURCE: - type = "SourceError"; - break; - case ExoPlaybackException.TYPE_RENDERER: - type = "RendererError"; - break; - case ExoPlaybackException.TYPE_UNEXPECTED: - type = "UnexpectedError"; - break; - case ExoPlaybackException.TYPE_OUT_OF_MEMORY: - type = "OutOfMemoryError"; - break; - case ExoPlaybackException.TYPE_REMOTE: - type = "remoteComponentError"; - break; - } + public void onAudioCodecError(@NonNull EventTime eventTime, Exception audioCodecError) { + log("PlayerError", field("type", "audioCodecError"), "cause={" + audioCodecError.getCause() + "}"); + } - log("PlayerError", field("type", type), "cause={" + error.getCause() + "}"); + @Override + public void onAudioSinkError(@NonNull EventTime eventTime, Exception audioSinkError) { + log("PlayerError", field("type", "audioSinkError"), "cause={" + audioSinkError.getCause() + "}"); } @Override - public void onTracksChanged(EventTime eventTime, TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { - LinkedHashSet trackGroupSet = new LinkedHashSet<>(trackGroups.length); - for (int i = 0; i < trackSelections.length; i++) { - final TrackSelection trackSelection = trackSelections.get(i); - if (trackSelection != null) { - trackGroupSet.add(trackSelection.getTrackGroup()); - } - } + public void onVideoCodecError(@NonNull EventTime eventTime, Exception videoCodecError) { + log("PlayerError", field("type", "videoCodecError"), "cause={" + videoCodecError.getCause() + "}"); + } - // Add the rest - for (int i = 0; i < trackGroups.length; i++) { - final TrackGroup trackGroup = trackGroups.get(i); - trackGroupSet.add(trackGroup); - } + @Override + public void onPlayerError(@NonNull EventTime eventTime, @NonNull PlaybackException playbackException) { + Pair exceptionPair = PKPlaybackException.getPlaybackExceptionType(playbackException); + String type = exceptionPair.first.toString(); + log("PlayerError", field("type", type), "cause={" + playbackException.getCause() + "}"); + } + + @Override + public void onTracksChanged(@NonNull EventTime eventTime, Tracks tracks) { + // Tracks.Group contains video/audio/text track groups + ImmutableList trackGroups = tracks.getGroups(); + JsonArray jTrackGroups = new JsonArray(trackGroups.size()); + JsonArray jTrackSelections = new JsonArray(); + + for (Tracks.Group groups : trackGroups) { + JsonArray jTrackGroup = new JsonArray(groups.length); + + // Get track groups for each Video/Audio/Text + final TrackGroup trackGroup = groups.getMediaTrackGroup(); - JsonArray jTrackGroups = new JsonArray(trackGroups.length); - for (TrackGroup trackGroup : trackGroupSet) { - JsonArray jTrackGroup = new JsonArray(trackGroup.length); for (int j = 0; j < trackGroup.length; j++) { - final Format format = trackGroup.getFormat(j); + // Get format of each track group present individually in video/audio/text + Format format = trackGroup.getFormat(j); jTrackGroup.add(toJSON(format)); + if (groups.isSelected() && groups.isTrackSelected(j)) { + jTrackSelections.add(toJSON(format)); + } } jTrackGroups.add(jTrackGroup); } - - JsonArray jTrackSelections = new JsonArray(trackSelections.length); - for (int i = 0; i < trackSelections.length; i++) { - final TrackSelection trackSelection = trackSelections.get(i); - final Format selectedFormat = trackSelection == null ? null : trackSelection.getSelectedFormat(); - jTrackSelections.add(toJSON(selectedFormat)); - } - log("TracksChanged", field("available", jTrackGroups.toString()), field("selected", jTrackSelections.toString())); @@ -298,28 +312,28 @@ private void logLoadingEvent(String event, LoadEventInfo loadEventInfo, MediaLoa } @Override - public void onLoadStarted(EventTime eventTime, LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) { + public void onLoadStarted(@NonNull EventTime eventTime, @NonNull LoadEventInfo loadEventInfo, @NonNull MediaLoadData mediaLoadData) { logLoadingEvent("LoadStarted", loadEventInfo, mediaLoadData, null, null); profiler.maybeLogServerInfo(loadEventInfo.uri); } @Override - public void onLoadCompleted(EventTime eventTime, LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) { + public void onLoadCompleted(@NonNull EventTime eventTime, @NonNull LoadEventInfo loadEventInfo, @NonNull MediaLoadData mediaLoadData) { logLoadingEvent("LoadCompleted", loadEventInfo, mediaLoadData, null, null); } @Override - public void onLoadCanceled(EventTime eventTime, LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) { + public void onLoadCanceled(@NonNull EventTime eventTime, @NonNull LoadEventInfo loadEventInfo, @NonNull MediaLoadData mediaLoadData) { logLoadingEvent("LoadCanceled", loadEventInfo, mediaLoadData, null, null); } @Override - public void onLoadError(EventTime eventTime, LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData, IOException error, boolean wasCanceled) { + public void onLoadError(@NonNull EventTime eventTime, @NonNull LoadEventInfo loadEventInfo, @NonNull MediaLoadData mediaLoadData, @NonNull IOException error, boolean wasCanceled) { logLoadingEvent("LoadError", loadEventInfo, mediaLoadData, error, wasCanceled); } @Override - public void onDownstreamFormatChanged(EventTime eventTime, MediaLoadData mediaLoadData) { + public void onDownstreamFormatChanged(@NonNull EventTime eventTime, MediaLoadData mediaLoadData) { String trackTypeString = trackTypeString(mediaLoadData.trackType); if (trackTypeString == null) { return; @@ -329,7 +343,7 @@ public void onDownstreamFormatChanged(EventTime eventTime, MediaLoadData mediaLo } @Override - public void onUpstreamDiscarded(EventTime eventTime, MediaLoadData mediaLoadData) { + public void onUpstreamDiscarded(@NonNull EventTime eventTime, MediaLoadData mediaLoadData) { String trackTypeString = trackTypeString(mediaLoadData.trackType); if (trackTypeString == null) { return; @@ -338,7 +352,7 @@ public void onUpstreamDiscarded(EventTime eventTime, MediaLoadData mediaLoadData } @Override - public void onBandwidthEstimate(EventTime eventTime, int totalLoadTimeMs, long totalBytesLoaded, long bitrateEstimate) { + public void onBandwidthEstimate(@NonNull EventTime eventTime, int totalLoadTimeMs, long totalBytesLoaded, long bitrateEstimate) { log("BandwidthSample", field("bandwidth", bitrateEstimate), timeField("totalLoadTime", totalLoadTimeMs), @@ -347,97 +361,92 @@ public void onBandwidthEstimate(EventTime eventTime, int totalLoadTimeMs, long t } @Override - public void onMetadata(EventTime eventTime, Metadata metadata) { + public void onMetadata(@NonNull EventTime eventTime, @NonNull Metadata metadata) { } @Override - public void onAudioEnabled(EventTime eventTime, DecoderCounters counters) { + public void onAudioEnabled(@NonNull EventTime eventTime, @NonNull DecoderCounters counters) { } @Override - public void onVideoEnabled(EventTime eventTime, DecoderCounters counters) { + public void onVideoEnabled(@NonNull EventTime eventTime, @NonNull DecoderCounters counters) { } @Override - public void onVideoDecoderInitialized(EventTime eventTime, String decoderName, long initializationDurationMs) { + public void onVideoDecoderInitialized(@NonNull EventTime eventTime, @NonNull String decoderName, long initializedTimestampMs, long initializationDurationMs) { log("DecoderInitialized", field("name", decoderName), field("duration", initializationDurationMs / MSEC_MULTIPLIER_FLOAT)); } @Override - public void onVideoInputFormatChanged(EventTime eventTime, Format format) { + public void onVideoInputFormatChanged(@NonNull EventTime eventTime, Format format, DecoderReuseEvaluation decoderReuseEvaluation) { log("DecoderInputFormatChanged", field("id", format.id), field("codecs", format.codecs), field("bitrate", format.bitrate)); } @Override - public void onVideoDisabled(EventTime eventTime, DecoderCounters decoderCounters) { - - } - - @Override - public void onAudioSessionId(EventTime eventTime, int audioSessionId) { + public void onVideoDisabled(@NonNull EventTime eventTime, @NonNull DecoderCounters decoderCounters) { } @Override - public void onAudioUnderrun(EventTime eventTime, int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { + public void onAudioUnderrun(@NonNull EventTime eventTime, int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { } @Override - public void onDroppedVideoFrames(EventTime eventTime, int droppedFrames, long elapsedMs) { + public void onDroppedVideoFrames(@NonNull EventTime eventTime, int droppedFrames, long elapsedMs) { log("DroppedFrames", field("count", droppedFrames), field("time", elapsedMs / MSEC_MULTIPLIER_FLOAT)); } @Override - public void onVideoSizeChanged(EventTime eventTime, int width, int height, int unappliedRotationDegrees, float pixelWidthHeightRatio) { - log("VideoSizeChanged", field("width", width), field("height", height)); + public void onVideoSizeChanged(@NonNull EventTime eventTime, @NonNull VideoSize videoSize) { + log("VideoSizeChanged", field("width", videoSize.width), field("height", videoSize.height)); } @Override - public void onRenderedFirstFrame(EventTime eventTime, Surface surface) { + public void onRenderedFirstFrame(@NonNull EventTime eventTime, @NonNull Object output, long renderTimeMs) { log("RenderedFirstFrame"); } @Override - public void onSurfaceSizeChanged(EventTime eventTime, int width, int height) { + public void onSurfaceSizeChanged(@NonNull EventTime eventTime, int width, int height) { log("ViewportSizeChange", field("width", width), field("height", height)); } @Override - public void onVolumeChanged(EventTime eventTime, float volume) { + public void onVolumeChanged(@NonNull EventTime eventTime, float volume) { log("VolumeChanged", field("volume", volume)); } @Override - public void onDrmSessionAcquired(EventTime eventTime) { + public void onDrmSessionAcquired(@NonNull EventTime eventTime, @DrmSession.State int state) { log("DrmSessionAcquired"); } @Override - public void onDrmSessionReleased(EventTime eventTime) { + public void onDrmSessionReleased(@NonNull EventTime eventTime) { log("DrmSessionReleased"); } @Override - public void onDrmKeysLoaded(EventTime eventTime) { - + public void onDrmKeysLoaded(@NonNull EventTime eventTime) { + log("onDrmKeysLoaded"); } @Override - public void onDrmSessionManagerError(EventTime eventTime, Exception error) { - + public void onDrmSessionManagerError(@NonNull EventTime eventTime, @NonNull Exception error) { + log("onDrmSessionManagerError " + field("error", error.getMessage())); } @Override - public void onDrmKeysRestored(EventTime eventTime) { - + public void onDrmKeysRestored(@NonNull EventTime eventTime) { + log("onDrmKeysRestored"); } @Override - public void onDrmKeysRemoved(EventTime eventTime) { - + public void onDrmKeysRemoved(@NonNull EventTime eventTime) { + log("onDrmKeysRemoved"); } } diff --git a/playkit/src/main/java/com/kaltura/playkit/profiler/PlayKitProfiler.java b/playkit/src/main/java/com/kaltura/playkit/profiler/PlayKitProfiler.java index 8ff52a125..b9f1a4d91 100644 --- a/playkit/src/main/java/com/kaltura/playkit/profiler/PlayKitProfiler.java +++ b/playkit/src/main/java/com/kaltura/playkit/profiler/PlayKitProfiler.java @@ -1,7 +1,6 @@ package com.kaltura.playkit.profiler; import android.content.Context; -import android.net.ConnectivityManager; import android.net.Uri; import android.os.Build; import android.os.Handler; @@ -12,15 +11,14 @@ import android.text.TextUtils; import android.util.DisplayMetrics; -import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonObject; import com.google.gson.JsonParseException; -import com.kaltura.android.exoplayer2.C; -import com.kaltura.android.exoplayer2.analytics.AnalyticsListener; +import com.kaltura.androidx.media3.common.C; +import com.kaltura.androidx.media3.exoplayer.analytics.AnalyticsListener; import com.kaltura.playkit.PKDrmParams; import com.kaltura.playkit.PKLog; import com.kaltura.playkit.PKMediaConfig; @@ -40,8 +38,6 @@ import java.io.BufferedWriter; import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; import java.io.FileWriter; import java.io.IOException; import java.lang.ref.WeakReference; @@ -67,7 +63,7 @@ public class PlayKitProfiler { // Dev mode: shorter logs, write to local file, always enable private static final boolean devMode = false; - private static final int SEND_INTERVAL_DEV = 10; // in seconds + private static final int SEND_INTERVAL_DEV = 60; // in seconds private static final int SEND_PERCENTAGE_DEV = 100; // always private static final int SEND_INTERVAL_PROD = 120; // 2 minutes @@ -76,8 +72,7 @@ public class PlayKitProfiler { private static final float DEFAULT_SEND_PERCENTAGE = devMode ? SEND_PERCENTAGE_DEV : 0; // Start disabled private static final String CONFIG_CACHE_FILENAME = "profilerConfig.json"; - private static final String CONFIG_URL = "https://s3.amazonaws.com/player-profiler/config.json"; - private static final String DEFAULT_POST_URL = "https://3vbje2fyag.execute-api.us-east-1.amazonaws.com/default/profilog"; + private static final String CONFIG_BASE_URL = "https://s3.amazonaws.com/player-profiler/configs/"; private static final int MAX_CONFIG_SIZE = 10240; static final float MSEC_MULTIPLIER_FLOAT = 1000f; @@ -87,7 +82,8 @@ public class PlayKitProfiler { private static final Map experiments = new LinkedHashMap<>(); private static final int PERCENTAGE_MULTIPLIER = 100; // Configuration - private static String postURL = DEFAULT_POST_URL; + private static String configToken; + private static String postURL; private static float sendPercentage = DEFAULT_SEND_PERCENTAGE; // Static setup private static Handler ioHandler; @@ -129,13 +125,13 @@ public void run() { * Initialize the static part of the profiler -- load the config and store it, * create IO thread and handler. Must be called by the app to enable the profiler. */ - public static void init(Context context) { + public static void init(Context context, String jsonConfigToken) { // This only has to happen once. if (initialized) { return; } - + configToken = jsonConfigToken; synchronized (PlayKitProfiler.class) { // Ask again, after sync. @@ -145,9 +141,6 @@ public static void init(Context context) { final Context appContext = context.getApplicationContext(); - // Load cached config. Will load from network later, in a handler thread. - loadCachedConfig(appContext); - HandlerThread handlerThread = new HandlerThread("ProfilerIO", Process.THREAD_PRIORITY_BACKGROUND); handlerThread.start(); ioHandler = new Handler(handlerThread.getLooper()); @@ -173,24 +166,6 @@ public static void init(Context context) { } } - private static String getNetworkType(Context context) { - - final ConnectivityManager manager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); - if (manager == null) { - return "Unknown"; - } - - switch (manager.getActiveNetworkInfo().getType()) { - case ConnectivityManager.TYPE_MOBILE: - return "Mobile"; - case ConnectivityManager.TYPE_WIFI: - return "Wifi"; - case ConnectivityManager.TYPE_ETHERNET: - return "Ethernet"; - } - return null; - } - /** * Set an experiment key+value that will be added to the log. There's no limit to the number of * experiments that can be set, but the key must be unique (using the same key again overrides @@ -326,7 +301,7 @@ private static void downloadConfig(Context context) { // Download try { - bytes = Utils.executeGet(CONFIG_URL, null); + bytes = Utils.executeGet(CONFIG_BASE_URL + configToken + ".json", null); if (bytes == null || bytes.length == 0) { pkLog.w("Nothing returned from executeGet"); @@ -337,51 +312,13 @@ private static void downloadConfig(Context context) { } catch (IOException e) { pkLog.w("Failed to download config", e); - return; } - - // Save to cache - final File cachedConfigFile = getCachedConfigFile(context); - if (cachedConfigFile.getParentFile().canWrite()) { - FileOutputStream outputStream = null; - try { - outputStream = new FileOutputStream(cachedConfigFile); - outputStream.write(bytes); - } catch (IOException e) { - pkLog.e("Failed to save config to cache", e); - } finally { - Utils.safeClose(outputStream); - } - } - } - - private static void loadCachedConfig(Context context) { - final File configFile = getCachedConfigFile(context); - - if (configFile.canRead()) { - FileInputStream inputStream = null; - try { - inputStream = new FileInputStream(configFile); - parseConfig(Utils.fullyReadInputStream(inputStream, MAX_CONFIG_SIZE).toByteArray()); - - } catch (IOException e) { - pkLog.e("Failed to read cached config file", e); - - } finally { - Utils.safeClose(inputStream); - } - } - } - - @NonNull - private static File getCachedConfigFile(Context context) { - return new File(context.getFilesDir(), CONFIG_CACHE_FILENAME); } private static void parseConfig(byte[] bytes) { try { final ConfigFile configFile = new Gson().fromJson(new String(bytes), ConfigFile.class); - postURL = configFile.putLogURL; + postURL = configFile.postURL; sendPercentage = configFile.sendPercentage; } catch (JsonParseException e) { pkLog.e("Failed to parse config", e); @@ -712,7 +649,7 @@ public EventListener.Factory getOkListenerFactory() { }; private static class ConfigFile { - String putLogURL; + String postURL; float sendPercentage; } } diff --git a/playkit/src/main/java/com/kaltura/playkit/utils/Consts.java b/playkit/src/main/java/com/kaltura/playkit/utils/Consts.java index 8391e5b4a..bed5f8fe1 100644 --- a/playkit/src/main/java/com/kaltura/playkit/utils/Consts.java +++ b/playkit/src/main/java/com/kaltura/playkit/utils/Consts.java @@ -12,6 +12,8 @@ package com.kaltura.playkit.utils; +import com.kaltura.androidx.media3.common.C; + /** * Created by anton.afanasiev on 04/12/2016. */ @@ -31,6 +33,23 @@ public class Consts { */ public static final int POSITION_UNSET = -1; + /** + * Represents an unset or unknown rate. + */ + public static final float RATE_UNSET = -Float.MAX_VALUE; + + /** + * The default minimum factor by which playback can be sped up that should be used if no minimum + * playback speed is defined by the media. + */ + public static final float DEFAULT_FALLBACK_MIN_PLAYBACK_SPEED = 0.97f; + + /** + * The default maximum factor by which playback can be sped up that should be used if no maximum + * playback speed is defined by the media. + */ + public static final float DEFAULT_FALLBACK_MAX_PLAYBACK_SPEED = 1.03f; + /** * Represents an unset or unknown volume. */ @@ -48,6 +67,10 @@ public class Consts { * Identifier for the Text track type. */ public static final int TRACK_TYPE_TEXT = 2; + /** + * Identifier for the Image track type. + */ + public static final int TRACK_TYPE_IMAGE = 3; /** * Identifier for the unknown track type. */ @@ -63,8 +86,8 @@ public class Consts { * Flag that indicates, that this specified track will be * selected by the player as default track. */ - public static final int DEFAULT_TRACK_SELECTION_FLAG_HLS = 5; - public static final int DEFAULT_TRACK_SELECTION_FLAG_DASH = 1; + public static final int DEFAULT_TRACK_SELECTION_FLAG_HLS = C.SELECTION_FLAG_DEFAULT | C.SELECTION_FLAG_AUTOSELECT; + public static final int DEFAULT_TRACK_SELECTION_FLAG_DASH = C.SELECTION_FLAG_DEFAULT; /** * Flag that indicates, that this specified track will not @@ -104,7 +127,13 @@ public class Consts { // 60 is the number of seconds set in ExoPlayer 2.10.3. public static final int MIN_OFFLINE_LICENSE_DURATION_TO_PLAY = 60; + public static final int FORMAT_HANDLED = 4; + + public static String EXO_DOWNLOAD_CHANNEL_ID = "download_channel"; + public static final String HTTP_METHOD_POST = "POST"; public static final String HTTP_METHOD_GET = "GET"; + + public static final String EXO_TIMEOUT_OPERATION_RELEASE = "Player release timed out."; } diff --git a/playkit/src/main/java/com/kaltura/playkit/utils/NetworkUtils.java b/playkit/src/main/java/com/kaltura/playkit/utils/NetworkUtils.java new file mode 100644 index 000000000..9ff3848e2 --- /dev/null +++ b/playkit/src/main/java/com/kaltura/playkit/utils/NetworkUtils.java @@ -0,0 +1,171 @@ +package com.kaltura.playkit.utils; + +import android.content.Context; +import android.net.Uri; +import android.os.Handler; +import android.text.TextUtils; + +import androidx.annotation.NonNull; + +import com.kaltura.playkit.PKLog; +import com.kaltura.playkit.PlayKitManager; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; + +import static com.kaltura.playkit.Utils.toBase64; + +public class NetworkUtils { + + private static final PKLog log = PKLog.get("NetworkUtils"); + private static final OkHttpClient client = new OkHttpClient(); + private static final String DEFAULT_BASE_URL = "https://analytics.kaltura.com/api_v3/index.php"; + public static final String DEFAULT_KAVA_ENTRY_ID = "1_3bwzbc9o"; + public static final int DEFAULT_KAVA_PARTNER_ID = 2504201; + public static final String KAVA_EVENT_IMPRESSION = "1"; + public static final String KAVA_EVENT_PLAY_REQUEST = "2"; + + public static void requestOvpConfigByPartnerId(Context context, String baseUrl, int partnerId, String apiPrefix, NetworkUtilsCallback callback) { + + Map params = new LinkedHashMap<>(); + params.put("service", "partner"); + params.put("action", "getPublicInfo"); + params.put("id", String.valueOf(partnerId)); + params.put("format", "1"); + + String configByPartnerIdUrl = buildConfigByPartnerIdUrl(context, baseUrl + apiPrefix, params); + //log.d("ovp configByPartnerIdUrl = " + configByPartnerIdUrl); + executeGETRequest(context, "requestOvpConfigByPartnerId", configByPartnerIdUrl, callback); + } + + public static void requestOttConfigByPartnerId(Context context, String baseUrl, int partnerId, String playerName, String udid, NetworkUtilsCallback callback) { + Map params = new LinkedHashMap<>(); + params.put("service", "Configurations"); + params.put("action", "serveByDevice"); + params.put("partnerId", String.valueOf(partnerId)); + params.put("applicationName", playerName + "." + partnerId); + params.put("clientVersion", "4"); + params.put("platform", "Android"); + params.put("tag", "tag"); + params.put("udid", udid); + + String configByPartnerIdUrl = buildConfigByPartnerIdUrl(context, baseUrl, params); + //log.d("ott configByPartnerIdUrl = " + configByPartnerIdUrl); + executeGETRequest(context, "requestOttConfigByPartnerId", configByPartnerIdUrl, callback); + } + + private static String buildConfigByPartnerIdUrl(Context context, String baseUrl, Map params) { + + Uri.Builder builder = Uri.parse(baseUrl).buildUpon(); + Set keys = params.keySet(); + if (keys != null) { + for (String key : keys) { + builder.appendQueryParameter(key, params.get(key)); + } + } + return builder.build().toString(); + } + + public static void sendKavaAnalytics(Context context, int partnerId, String entryId, String eventType, String sessionId) { + String kavaImpressionUrl = buildKavaImpressionUrl(context, partnerId, entryId, eventType, sessionId); + log.d("KavaAnalytics URL = " + kavaImpressionUrl); + executeGETRequest(context, "sendKavaImpression", kavaImpressionUrl, null); + } + + private static String buildKavaImpressionUrl(Context context, int partnerId, String entryId, String eventType, String sessionId) { + Uri.Builder builtUri = Uri.parse(DEFAULT_BASE_URL).buildUpon(); + builtUri.appendQueryParameter("service", "analytics") + .appendQueryParameter("action", "trackEvent") + .appendQueryParameter("eventType", eventType) + .appendQueryParameter("partnerId", String.valueOf(partnerId)) + .appendQueryParameter("entryId", entryId) + .appendQueryParameter("sessionId", !TextUtils.isEmpty(sessionId) ? sessionId : generateSessionId()) + .appendQueryParameter("eventIndex", "1") + .appendQueryParameter("referrer", toBase64(context.getPackageName().getBytes())) + .appendQueryParameter("deliveryType", "dash") + .appendQueryParameter("playbackType", "vod") + .appendQueryParameter("clientVer", PlayKitManager.CLIENT_TAG) + .appendQueryParameter("position", "0") + .appendQueryParameter("application", context.getPackageName()); + return builtUri.build().toString(); + } + + private static String generateSessionId() { + String mediaSessionId = UUID.randomUUID().toString(); + String newSessionId = UUID.randomUUID().toString(); + newSessionId += ":"; + newSessionId += mediaSessionId; + return newSessionId; + } + + private static void executeGETRequest(Context context, String apiName, String configByPartnerIdUrl, NetworkUtilsCallback callback) { + try { + Request request = new Request.Builder() + .url(configByPartnerIdUrl) + .build(); + + client.newCall(request).enqueue(new Callback() { + final Handler mainHandler = new Handler(context.getMainLooper()); + @Override + public void onFailure(@NonNull Call call, @NonNull IOException e) { + mainHandler.post(() -> { + sendError(callback, apiName + " called failed url = " + configByPartnerIdUrl + ", error = " + e.getMessage()); + }); + } + + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + + if (!response.isSuccessful()) { + mainHandler.post(() -> { + sendError(callback, apiName + " call failed url = " + configByPartnerIdUrl); + }); + } else { + try { + ResponseBody responseBody = response.body(); + if (responseBody != null) { + String body = responseBody.string(); + if (!body.contains("KalturaAPIException")) { + mainHandler.post(() -> { + if (callback != null) { + callback.finished(body, null); + } + }); + return; + } + } + } catch(IOException e){ + mainHandler.post(() -> { + sendError(callback, apiName + " call failed url = " + configByPartnerIdUrl + ", error = " + e.getMessage()); + }); + return; + } + + mainHandler.post(() -> { + sendError(callback, apiName + " called failed url = " + configByPartnerIdUrl); + }); + } + } + }); + } catch (Exception e) { + sendError(callback, apiName + " call failed url = " + configByPartnerIdUrl + ", error = " + e.getMessage()); + } + } + + private static void sendError(NetworkUtilsCallback callback, String errorMessage) { + log.e(errorMessage); + if (callback != null) { + callback.finished(null, errorMessage); + } + } +} diff --git a/playkit/src/main/java/com/kaltura/playkit/utils/NetworkUtilsCallback.kt b/playkit/src/main/java/com/kaltura/playkit/utils/NetworkUtilsCallback.kt new file mode 100644 index 000000000..3a6ef52cd --- /dev/null +++ b/playkit/src/main/java/com/kaltura/playkit/utils/NetworkUtilsCallback.kt @@ -0,0 +1,5 @@ +package com.kaltura.playkit.utils + +interface NetworkUtilsCallback { + fun finished(json: String?, errorMessage: String?) +} diff --git a/playkit/src/main/java/com/kaltura/playkit/utils/ResumableCountDownTimer.java b/playkit/src/main/java/com/kaltura/playkit/utils/ResumableCountDownTimer.java new file mode 100644 index 000000000..38aacfabb --- /dev/null +++ b/playkit/src/main/java/com/kaltura/playkit/utils/ResumableCountDownTimer.java @@ -0,0 +1,133 @@ +package com.kaltura.playkit.utils; + +import android.os.Handler; +import android.os.SystemClock; +import android.os.Message; + +public abstract class ResumableCountDownTimer { + + /** + * Millis since epoch when alarm should stop. + */ + private final long mMillisInFuture; + + /** + * The interval in millis that the user receives callbacks + */ + private final long mCountdownInterval; + + private long mStopTimeInFuture; + + private long mPauseTime; + + private boolean mCancelled = false; + + private boolean mPaused = false; + + /** + * @param millisInFuture The number of millis in the future from the call + * to {@link #start()} until the countdown is done and {@link #onFinish()} + * is called. + * @param countDownInterval The interval along the way to receive + * {@link #onTick(long)} callbacks. + */ + public ResumableCountDownTimer(long millisInFuture, long countDownInterval) { + mMillisInFuture = millisInFuture; + mCountdownInterval = countDownInterval; + } + + /** + * Cancel the countdown. + * + * Do not call it from inside CountDownTimer threads + */ + public final void cancel() { + mHandler.removeMessages(MSG); + mCancelled = true; + } + + /** + * Start the countdown. + */ + public synchronized final ResumableCountDownTimer start() { + if (mMillisInFuture <= 0) { + onFinish(); + return this; + } + mStopTimeInFuture = SystemClock.elapsedRealtime() + mMillisInFuture; + mHandler.sendMessage(mHandler.obtainMessage(MSG)); + mCancelled = false; + mPaused = false; + return this; + } + + /** + * Pause the countdown. + */ + public long pause() { + mPauseTime = mStopTimeInFuture - SystemClock.elapsedRealtime(); + mPaused = true; + return mPauseTime; + } + + /** + * Resume the countdown. + */ + public void resume() { + if (mPaused) { + mStopTimeInFuture = mPauseTime + SystemClock.elapsedRealtime(); + mPaused = false; + mHandler.sendMessage(mHandler.obtainMessage(MSG)); + } + } + + /** + * Callback fired on regular interval. + * @param millisUntilFinished The amount of time until finished. + */ + public abstract void onTick(long millisUntilFinished); + + /** + * Callback fired when the time is up. + */ + public abstract void onFinish(); + + + private static final int MSG = 1; + + + // handles counting down + private Handler mHandler = new Handler() { + + @Override + public void handleMessage(Message msg) { + + synchronized (ResumableCountDownTimer.this) { + if (!mPaused) { + final long millisLeft = mStopTimeInFuture - SystemClock.elapsedRealtime(); + + if (millisLeft <= 0) { + onFinish(); + } else if (millisLeft < mCountdownInterval) { + // no tick, just delay until done + sendMessageDelayed(obtainMessage(MSG), millisLeft); + } else { + long lastTickStart = SystemClock.elapsedRealtime(); + onTick(millisLeft); + + // take into account user's onTick taking time to execute + long delay = lastTickStart + mCountdownInterval - SystemClock.elapsedRealtime(); + + // special case: user's onTick took more than interval to + // complete, skip to next interval + while (delay < 0) delay += mCountdownInterval; + + if (!mCancelled) { + sendMessageDelayed(obtainMessage(MSG), delay); + } + } + } + } + } + }; +} diff --git a/playkit/version.gradle b/playkit/version.gradle index edbaf1680..d55857503 100644 --- a/playkit/version.gradle +++ b/playkit/version.gradle @@ -8,21 +8,3 @@ if (playkitVersion == 'dev') { def commit = proc.text.trim() ext.playkitVersion = 'dev.' + commit } - -// Publish to Bintray -try { - apply plugin: 'bintray-release' - - publish { - // If project name is "profiler", publish as "playkit-profiler" - artifactId = project.name == 'playkit' ? 'playkit' : 'playkit-' + project.name - desc = 'PlayKit: Kaltura Player SDK' - repoName = 'android' - userOrg = 'kaltura' - groupId = 'com.kaltura.playkit' - publishVersion = playkitVersion - autoPublish = true - } -} catch (UnknownPluginException ignored) { - // Ignore - it's ok not to have this plugin - it's only used for bintray uploads. -}