From a08c09829dba91741889200a10ab8ad4d7094acb Mon Sep 17 00:00:00 2001 From: Elmar Meyer Date: Sat, 8 Aug 2026 22:34:33 +0200 Subject: [PATCH] Initial Commit! --- .gitignore | 15 + .idea/.gitignore | 3 + .idea/AndroidProjectSystem.xml | 6 + .idea/compiler.xml | 6 + .idea/deploymentTargetSelector.xml | 10 + .idea/gradle.xml | 19 + .idea/migrations.xml | 10 + .idea/misc.xml | 9 + .idea/runConfigurations.xml | 17 + "app/\001" | 0 app/.gitignore | 1 + app/build.gradle | 87 ++ app/proguard-rules.pro | 21 + app/release/baselineProfiles/0/app-release.dm | Bin 0 -> 13466 bytes app/release/baselineProfiles/1/app-release.dm | Bin 0 -> 13406 bytes app/release/output-metadata.json | 37 + .../exoplayertv/ExampleInstrumentedTest.java | 26 + app/src/main/AndroidManifest.xml | 120 +++ .../exoplayertv/DemoDownloadService.java | 127 +++ .../com/example/exoplayertv/DemoUtil.java | 205 ++++ .../example/exoplayertv/DownloadTracker.java | 417 ++++++++ .../java/com/example/exoplayertv/Gesten.java | 122 +++ .../com/example/exoplayertv/IntentUtil.java | 266 +++++ .../example/exoplayertv/PlayerActivity.java | 694 +++++++++++++ .../exoplayertv/SampleChooserActivity.java | 948 ++++++++++++++++++ .../StartActivityOnBootReceiver.java | 17 + .../exoplayertv/TrackSelectionDialog.java | 359 +++++++ .../com/example/exoplayertv/package-info.java | 19 + .../example/exoplayertv/tvepg/epg/EPG.java | 816 +++++++++++++++ .../tvepg/epg/EPGClickListener.java | 16 + .../exoplayertv/tvepg/epg/EPGData.java | 24 + .../tvepg/epg/domain/EPGChannel.java | 47 + .../tvepg/epg/domain/EPGEvent.java | 90 ++ .../exoplayertv/tvepg/epg/misc/EPGUtil.java | 56 ++ .../exoplayertv/tvepg/xmltv/ContentTask.java | 63 ++ .../exoplayertv/tvepg/xmltv/WebAssets.java | 61 ++ .../tvepg/xmltv/custom/CustomTvGuide.java | 73 ++ .../tvepg/xmltv/custom/CustomTvGuideRow.java | 63 ++ .../custom/CustomTvGuideRowComparator.java | 26 + .../xmltv/grabber/AbstractXpathFactory.java | 46 + .../tvepg/xmltv/grabber/ChannelsFactory.java | 52 + .../exoplayertv/tvepg/xmltv/grabber/Curl.java | 31 + .../tvepg/xmltv/grabber/Grabber.java | 42 + .../tvepg/xmltv/grabber/ProgramsFactory.java | 112 +++ .../tvepg/xmltv/model/Channel.java | 125 +++ .../tvepg/xmltv/model/DisplayMode.java | 10 + .../tvepg/xmltv/model/Program.java | 94 ++ .../exoplayertv/xmltv/ContentTask.java | 70 ++ .../example/exoplayertv/xmltv/WebAssets.java | 61 ++ .../xmltv/custom/CustomTvGuide.java | 78 ++ .../xmltv/custom/CustomTvGuideRow.java | 63 ++ .../custom/CustomTvGuideRowComparator.java | 26 + .../xmltv/grabber/AbstractXpathFactory.java | 46 + .../xmltv/grabber/ChannelsFactory.java | 70 ++ .../exoplayertv/xmltv/grabber/Curl.java | 33 + .../exoplayertv/xmltv/grabber/Grabber.java | 41 + .../xmltv/grabber/ProgramsFactory.java | 58 ++ .../exoplayertv/xmltv/model/Channel.java | 105 ++ .../exoplayertv/xmltv/model/DisplayMode.java | 10 + .../exoplayertv/xmltv/model/Program.java | 44 + .../main/res/drawable-hdpi/ic_download.png | Bin 0 -> 199 bytes .../res/drawable-hdpi/ic_download_done.png | Bin 0 -> 218 bytes .../main/res/drawable-mdpi/ic_download.png | Bin 0 -> 163 bytes .../res/drawable-mdpi/ic_download_done.png | Bin 0 -> 182 bytes app/src/main/res/drawable-xhdpi/ic_banner.png | Bin 0 -> 4299 bytes .../main/res/drawable-xhdpi/ic_download.png | Bin 0 -> 187 bytes .../res/drawable-xhdpi/ic_download_done.png | Bin 0 -> 304 bytes .../main/res/drawable-xxhdpi/ic_download.png | Bin 0 -> 261 bytes .../res/drawable-xxhdpi/ic_download_done.png | Bin 0 -> 450 bytes .../main/res/drawable-xxxhdpi/ic_download.png | Bin 0 -> 263 bytes .../res/drawable-xxxhdpi/ic_download_done.png | Bin 0 -> 575 bytes app/src/main/res/drawable/reset.png | Bin 0 -> 8531 bytes .../main/res/layout/dialog_description.xml | 192 ++++ app/src/main/res/layout/player_activity.xml | 67 ++ .../res/layout/sample_chooser_activity.xml | 26 + app/src/main/res/layout/sample_list_item.xml | 38 + .../res/layout/track_selection_dialog.xml | 59 ++ app/src/main/res/menu/sample_chooser_menu.xml | 26 + app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 9488 bytes .../main/res/mipmap-hdpi/ic_launcher_old.png | Bin 0 -> 3394 bytes app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 6477 bytes .../main/res/mipmap-mdpi/ic_launcher_old.png | Bin 0 -> 2184 bytes app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 13196 bytes .../main/res/mipmap-xhdpi/ic_launcher_old.png | Bin 0 -> 4886 bytes .../res/mipmap-xxhdpi/ic_launcher_old.png | Bin 0 -> 7492 bytes .../res/mipmap-xxxhdpi/ic_launcher_old.png | Bin 0 -> 10801 bytes app/src/main/res/values/colors.xml | 11 + app/src/main/res/values/dimens.xml | 18 + app/src/main/res/values/ids.xml | 4 + app/src/main/res/values/strings.xml | 69 ++ app/src/main/res/values/styles.xml | 39 + .../example/exoplayertv/ExampleUnitTest.java | 17 + build.gradle | 4 + gradle.properties | 21 + gradle/libs.versions.toml | 24 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59203 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 185 ++++ gradlew.bat | 89 ++ settings.gradle | 23 + 100 files changed, 7031 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/AndroidProjectSystem.xml create mode 100644 .idea/compiler.xml create mode 100644 .idea/deploymentTargetSelector.xml create mode 100644 .idea/gradle.xml create mode 100644 .idea/migrations.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/runConfigurations.xml create mode 100644 "app/\001" create mode 100644 app/.gitignore create mode 100644 app/build.gradle create mode 100644 app/proguard-rules.pro create mode 100644 app/release/baselineProfiles/0/app-release.dm create mode 100644 app/release/baselineProfiles/1/app-release.dm create mode 100644 app/release/output-metadata.json create mode 100644 app/src/androidTest/java/com/example/exoplayertv/ExampleInstrumentedTest.java create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/example/exoplayertv/DemoDownloadService.java create mode 100644 app/src/main/java/com/example/exoplayertv/DemoUtil.java create mode 100644 app/src/main/java/com/example/exoplayertv/DownloadTracker.java create mode 100644 app/src/main/java/com/example/exoplayertv/Gesten.java create mode 100644 app/src/main/java/com/example/exoplayertv/IntentUtil.java create mode 100644 app/src/main/java/com/example/exoplayertv/PlayerActivity.java create mode 100644 app/src/main/java/com/example/exoplayertv/SampleChooserActivity.java create mode 100644 app/src/main/java/com/example/exoplayertv/StartActivityOnBootReceiver.java create mode 100644 app/src/main/java/com/example/exoplayertv/TrackSelectionDialog.java create mode 100644 app/src/main/java/com/example/exoplayertv/package-info.java create mode 100644 app/src/main/java/com/example/exoplayertv/tvepg/epg/EPG.java create mode 100644 app/src/main/java/com/example/exoplayertv/tvepg/epg/EPGClickListener.java create mode 100644 app/src/main/java/com/example/exoplayertv/tvepg/epg/EPGData.java create mode 100644 app/src/main/java/com/example/exoplayertv/tvepg/epg/domain/EPGChannel.java create mode 100644 app/src/main/java/com/example/exoplayertv/tvepg/epg/domain/EPGEvent.java create mode 100644 app/src/main/java/com/example/exoplayertv/tvepg/epg/misc/EPGUtil.java create mode 100644 app/src/main/java/com/example/exoplayertv/tvepg/xmltv/ContentTask.java create mode 100644 app/src/main/java/com/example/exoplayertv/tvepg/xmltv/WebAssets.java create mode 100644 app/src/main/java/com/example/exoplayertv/tvepg/xmltv/custom/CustomTvGuide.java create mode 100644 app/src/main/java/com/example/exoplayertv/tvepg/xmltv/custom/CustomTvGuideRow.java create mode 100644 app/src/main/java/com/example/exoplayertv/tvepg/xmltv/custom/CustomTvGuideRowComparator.java create mode 100644 app/src/main/java/com/example/exoplayertv/tvepg/xmltv/grabber/AbstractXpathFactory.java create mode 100644 app/src/main/java/com/example/exoplayertv/tvepg/xmltv/grabber/ChannelsFactory.java create mode 100644 app/src/main/java/com/example/exoplayertv/tvepg/xmltv/grabber/Curl.java create mode 100644 app/src/main/java/com/example/exoplayertv/tvepg/xmltv/grabber/Grabber.java create mode 100644 app/src/main/java/com/example/exoplayertv/tvepg/xmltv/grabber/ProgramsFactory.java create mode 100644 app/src/main/java/com/example/exoplayertv/tvepg/xmltv/model/Channel.java create mode 100644 app/src/main/java/com/example/exoplayertv/tvepg/xmltv/model/DisplayMode.java create mode 100644 app/src/main/java/com/example/exoplayertv/tvepg/xmltv/model/Program.java create mode 100644 app/src/main/java/com/example/exoplayertv/xmltv/ContentTask.java create mode 100644 app/src/main/java/com/example/exoplayertv/xmltv/WebAssets.java create mode 100644 app/src/main/java/com/example/exoplayertv/xmltv/custom/CustomTvGuide.java create mode 100644 app/src/main/java/com/example/exoplayertv/xmltv/custom/CustomTvGuideRow.java create mode 100644 app/src/main/java/com/example/exoplayertv/xmltv/custom/CustomTvGuideRowComparator.java create mode 100644 app/src/main/java/com/example/exoplayertv/xmltv/grabber/AbstractXpathFactory.java create mode 100644 app/src/main/java/com/example/exoplayertv/xmltv/grabber/ChannelsFactory.java create mode 100644 app/src/main/java/com/example/exoplayertv/xmltv/grabber/Curl.java create mode 100644 app/src/main/java/com/example/exoplayertv/xmltv/grabber/Grabber.java create mode 100644 app/src/main/java/com/example/exoplayertv/xmltv/grabber/ProgramsFactory.java create mode 100644 app/src/main/java/com/example/exoplayertv/xmltv/model/Channel.java create mode 100644 app/src/main/java/com/example/exoplayertv/xmltv/model/DisplayMode.java create mode 100644 app/src/main/java/com/example/exoplayertv/xmltv/model/Program.java create mode 100644 app/src/main/res/drawable-hdpi/ic_download.png create mode 100644 app/src/main/res/drawable-hdpi/ic_download_done.png create mode 100644 app/src/main/res/drawable-mdpi/ic_download.png create mode 100644 app/src/main/res/drawable-mdpi/ic_download_done.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_banner.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_download.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_download_done.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_download.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_download_done.png create mode 100644 app/src/main/res/drawable-xxxhdpi/ic_download.png create mode 100644 app/src/main/res/drawable-xxxhdpi/ic_download_done.png create mode 100644 app/src/main/res/drawable/reset.png create mode 100644 app/src/main/res/layout/dialog_description.xml create mode 100644 app/src/main/res/layout/player_activity.xml create mode 100644 app/src/main/res/layout/sample_chooser_activity.xml create mode 100644 app/src/main/res/layout/sample_list_item.xml create mode 100644 app/src/main/res/layout/track_selection_dialog.xml create mode 100644 app/src/main/res/menu/sample_chooser_menu.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_old.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_old.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_old.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_old.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_old.png create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/dimens.xml create mode 100644 app/src/main/res/values/ids.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/styles.xml create mode 100644 app/src/test/java/com/example/exoplayertv/ExampleUnitTest.java create mode 100644 build.gradle create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..b268ef3 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..639c779 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..b2c751a --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git "a/app/\001" "b/app/\001" new file mode 100644 index 0000000..e69de29 diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..6459f5b --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,87 @@ +plugins { + alias(libs.plugins.android.application) +} + +android { + namespace 'com.example.exoplayertv' + compileSdk 36 + + defaultConfig { + applicationId "com.example.exoplayertv" + minSdk 21 + targetSdk 36 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + buildFeatures { + viewBinding true + } +} + + + +project.ext { + checkerframeworkVersion = '3.13.0' + androidxAnnotationVersion = '1.6.0' + androidxAppCompatVersion = '1.6.1' + androidxMaterialVersion = '1.8.0' + androidxMultidexVersion = '2.0.1' + androidxMedia3Version = '1.8.0' + comPicassoVersion = '2.8' + okhttpVersion = '5.0.0' + jotaTimeVersion = '2.7' +} + + +dependencies { + + implementation libs.appcompat + implementation libs.material + implementation libs.constraintlayout + implementation libs.navigation.fragment + implementation libs.navigation.ui + testImplementation libs.junit + androidTestImplementation libs.ext.junit + androidTestImplementation libs.espresso.core + + + compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion + implementation 'androidx.annotation:annotation:' + androidxAnnotationVersion + implementation 'androidx.appcompat:appcompat:' + androidxAppCompatVersion + implementation 'com.google.android.material:material:' + androidxMaterialVersion + + + + implementation 'androidx.multidex:multidex:' + androidxMultidexVersion + + implementation "androidx.media3:media3-exoplayer:" + androidxMedia3Version + implementation "androidx.media3:media3-common:" + androidxMedia3Version + implementation "androidx.media3:media3-ui:" + androidxMedia3Version + + implementation "androidx.media3:media3-exoplayer-ima:" + androidxMedia3Version + implementation "androidx.media3:media3-exoplayer-dash:" + androidxMedia3Version +// implementation "androidx.media3:media3-demo:" + androidxMedia3Version + implementation "androidx.media3:media3-datasource:" + androidxMedia3Version + implementation "androidx.media3:media3-datasource-cronet:" + androidxMedia3Version + implementation "androidx.media3:media3-ui-compose:" + androidxMedia3Version + + implementation 'com.google.android.exoplayer:exoplayer-ui:2.19.1' + + implementation 'com.squareup.picasso:picasso:' + comPicassoVersion + implementation 'com.squareup.okhttp3:okhttp:' + okhttpVersion + implementation 'com.squareup.okhttp3:okhttp-urlconnection:' + okhttpVersion + implementation 'joda-time:joda-time:' + jotaTimeVersion +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/release/baselineProfiles/0/app-release.dm b/app/release/baselineProfiles/0/app-release.dm new file mode 100644 index 0000000000000000000000000000000000000000..dea33c733760275612f31913791e87b5b5949730 GIT binary patch literal 13466 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 literal 0 HcmV?d00001 diff --git a/app/release/baselineProfiles/1/app-release.dm b/app/release/baselineProfiles/1/app-release.dm new file mode 100644 index 0000000000000000000000000000000000000000..1f009b5d870da51e39228eee3ab6f56f1d0f85a9 GIT binary patch literal 13406 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 literal 0 HcmV?d00001 diff --git a/app/release/output-metadata.json b/app/release/output-metadata.json new file mode 100644 index 0000000..fd284df --- /dev/null +++ b/app/release/output-metadata.json @@ -0,0 +1,37 @@ +{ + "version": 3, + "artifactType": { + "type": "APK", + "kind": "Directory" + }, + "applicationId": "com.example.exoplayertv", + "variantName": "release", + "elements": [ + { + "type": "SINGLE", + "filters": [], + "attributes": [], + "versionCode": 1, + "versionName": "1.0", + "outputFile": "app-release.apk" + } + ], + "elementType": "File", + "baselineProfiles": [ + { + "minApi": 28, + "maxApi": 30, + "baselineProfiles": [ + "baselineProfiles/1/app-release.dm" + ] + }, + { + "minApi": 31, + "maxApi": 2147483647, + "baselineProfiles": [ + "baselineProfiles/0/app-release.dm" + ] + } + ], + "minSdkVersionForDexing": 21 +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/example/exoplayertv/ExampleInstrumentedTest.java b/app/src/androidTest/java/com/example/exoplayertv/ExampleInstrumentedTest.java new file mode 100644 index 0000000..5d36f9e --- /dev/null +++ b/app/src/androidTest/java/com/example/exoplayertv/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package com.example.exoplayertv; + +import android.content.Context; + +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.*; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + @Test + public void useAppContext() { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + assertEquals("com.example.exoplayertv", appContext.getPackageName()); + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ce799fe --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/example/exoplayertv/DemoDownloadService.java b/app/src/main/java/com/example/exoplayertv/DemoDownloadService.java new file mode 100644 index 0000000..f0e870f --- /dev/null +++ b/app/src/main/java/com/example/exoplayertv/DemoDownloadService.java @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.example.exoplayertv; + +//import static androidx.media3.exoplayer.demo.DemoUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID; + +import android.app.Notification; +import android.content.Context; +import androidx.annotation.Nullable; +import androidx.media3.exoplayer.offline.Download; +import androidx.media3.exoplayer.offline.DownloadManager; +import androidx.media3.exoplayer.offline.DownloadService; +import androidx.media3.exoplayer.scheduler.PlatformScheduler; +import androidx.media3.exoplayer.scheduler.Requirements; +import androidx.media3.exoplayer.scheduler.Scheduler; +import androidx.media3.exoplayer.offline.DownloadNotificationHelper; +import androidx.media3.common.util.NotificationUtil; +import androidx.media3.common.util.Util; +import java.util.List; + +/** A service for downloading media. */ +@androidx.media3.common.util.UnstableApi +public class DemoDownloadService extends DownloadService { + + private static final int JOB_ID = 1; + private static final int FOREGROUND_NOTIFICATION_ID = 1; + public static final String DOWNLOAD_NOTIFICATION_CHANNEL_ID = "download_channel"; + + + public DemoDownloadService() { + super( + FOREGROUND_NOTIFICATION_ID, + DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL, + DOWNLOAD_NOTIFICATION_CHANNEL_ID, + androidx.media3.exoplayer.R.string.exo_download_notification_channel_name, + /* channelDescriptionResourceId= */ 0); + } + + @Override + protected DownloadManager getDownloadManager() { + // This will only happen once, because getDownloadManager is guaranteed to be called only once + // in the life cycle of the process. + DownloadManager downloadManager = DemoUtil.getDownloadManager(/* context= */ this); + DownloadNotificationHelper downloadNotificationHelper = + DemoUtil.getDownloadNotificationHelper(/* context= */ this); + downloadManager.addListener( + new TerminalStateNotificationHelper( + this, downloadNotificationHelper, FOREGROUND_NOTIFICATION_ID + 1)); + return downloadManager; + } + + @Override + protected Scheduler getScheduler() { + return Util.SDK_INT >= 21 ? new PlatformScheduler(this, JOB_ID) : null; + } + + @Override + protected Notification getForegroundNotification( + List downloads, @Requirements.RequirementFlags int notMetRequirements) { + return DemoUtil.getDownloadNotificationHelper(/* context= */ this) + .buildProgressNotification( + /* context= */ this, + R.drawable.ic_download, + /* contentIntent= */ null, + /* message= */ null, + downloads, + notMetRequirements); + } + + /** + * Creates and displays notifications for downloads when they complete or fail. + * + *

This helper will outlive the lifespan of a single instance of {@link DemoDownloadService}. + * It is static to avoid leaking the first {@link DemoDownloadService} instance. + */ + private static final class TerminalStateNotificationHelper implements DownloadManager.Listener { + + private final Context context; + private final DownloadNotificationHelper notificationHelper; + + private int nextNotificationId; + + public TerminalStateNotificationHelper( + Context context, DownloadNotificationHelper notificationHelper, int firstNotificationId) { + this.context = context.getApplicationContext(); + this.notificationHelper = notificationHelper; + nextNotificationId = firstNotificationId; + } + + @Override + public void onDownloadChanged( + DownloadManager downloadManager, Download download, @Nullable Exception finalException) { + Notification notification; + if (download.state == Download.STATE_COMPLETED) { + notification = + notificationHelper.buildDownloadCompletedNotification( + context, + R.drawable.ic_download_done, + /* contentIntent= */ null, + Util.fromUtf8Bytes(download.request.data)); + } else if (download.state == Download.STATE_FAILED) { + notification = + notificationHelper.buildDownloadFailedNotification( + context, + R.drawable.ic_download_done, + /* contentIntent= */ null, + Util.fromUtf8Bytes(download.request.data)); + } else { + return; + } + NotificationUtil.setNotification(context, nextNotificationId++, notification); + } + } +} diff --git a/app/src/main/java/com/example/exoplayertv/DemoUtil.java b/app/src/main/java/com/example/exoplayertv/DemoUtil.java new file mode 100644 index 0000000..9e509c0 --- /dev/null +++ b/app/src/main/java/com/example/exoplayertv/DemoUtil.java @@ -0,0 +1,205 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.example.exoplayertv; + +import android.content.Context; + +import androidx.media3.common.util.UnstableApi; +import androidx.media3.exoplayer.DefaultRenderersFactory; +import androidx.media3.exoplayer.RenderersFactory; +import androidx.media3.database.DatabaseProvider; +import androidx.media3.database.StandaloneDatabaseProvider; +import androidx.media3.datasource.cronet.CronetDataSource; +import androidx.media3.datasource.cronet.CronetUtil; +import androidx.media3.exoplayer.offline.DownloadManager; +import androidx.media3.exoplayer.offline.DownloadNotificationHelper; +import androidx.media3.datasource.DataSource; +import androidx.media3.datasource.DefaultDataSource; +import androidx.media3.datasource.DefaultHttpDataSource; +import androidx.media3.datasource.cache.Cache; +import androidx.media3.datasource.cache.CacheDataSource; +import androidx.media3.datasource.cache.NoOpCacheEvictor; +import androidx.media3.datasource.cache.SimpleCache; +import java.io.File; +import java.net.CookieHandler; +import java.net.CookieManager; +import java.net.CookiePolicy; +import java.util.concurrent.Executors; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.chromium.net.CronetEngine; + +/** Utility methods for the demo app. */ +public final class DemoUtil { + + public static final String DOWNLOAD_NOTIFICATION_CHANNEL_ID = "download_channel"; + + /** + * Whether the demo application uses Cronet for networking. Note that Cronet does not provide + * automatic support for cookies (https://github.com/google/ExoPlayer/issues/5975). + * + *

If set to false, the platform's default network stack is used with a {@link CookieManager} + * configured in {@link #getHttpDataSourceFactory}. + */ + private static final boolean USE_CRONET_FOR_NETWORKING = true; + + private static final String TAG = "DemoUtil"; + private static final String DOWNLOAD_CONTENT_DIRECTORY = "downloads"; + + private static DataSource.@MonotonicNonNull Factory dataSourceFactory; + private static DataSource.@MonotonicNonNull Factory httpDataSourceFactory; + @UnstableApi + private static @MonotonicNonNull DatabaseProvider databaseProvider; + private static @MonotonicNonNull File downloadDirectory; + @UnstableApi + private static @MonotonicNonNull Cache downloadCache; + @UnstableApi + private static @MonotonicNonNull DownloadManager downloadManager; + private static @MonotonicNonNull DownloadTracker downloadTracker; + @UnstableApi + private static @MonotonicNonNull DownloadNotificationHelper downloadNotificationHelper; + + /** Returns whether extension renderers should be used. */ + public static boolean useExtensionRenderers() { + return false; // Elmar: BuildConfig.USE_DECODER_EXTENSIONS; + } + + @UnstableApi + public static RenderersFactory buildRenderersFactory( + Context context, boolean preferExtensionRenderer) { + @DefaultRenderersFactory.ExtensionRendererMode + int extensionRendererMode = + useExtensionRenderers() + ? (preferExtensionRenderer + ? DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER + : DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON) + : DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF; + return new DefaultRenderersFactory(context.getApplicationContext()) + .setExtensionRendererMode(extensionRendererMode); + } + + public static synchronized DataSource.Factory getHttpDataSourceFactory(Context context) { + if (httpDataSourceFactory == null) { + if (USE_CRONET_FOR_NETWORKING) { + context = context.getApplicationContext(); + @Nullable CronetEngine cronetEngine = CronetUtil.buildCronetEngine(context); + if (cronetEngine != null) { + httpDataSourceFactory = + new CronetDataSource.Factory(cronetEngine, Executors.newSingleThreadExecutor()); + } + } + if (httpDataSourceFactory == null) { + // We don't want to use Cronet, or we failed to instantiate a CronetEngine. + CookieManager cookieManager = new CookieManager(); + cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ORIGINAL_SERVER); + CookieHandler.setDefault(cookieManager); + httpDataSourceFactory = new DefaultHttpDataSource.Factory(); + } + } + return httpDataSourceFactory; + } + + /** Returns a {@link DataSource.Factory}. */ + @UnstableApi + public static synchronized DataSource.Factory getDataSourceFactory(Context context) { + if (dataSourceFactory == null) { + context = context.getApplicationContext(); + DefaultDataSource.Factory upstreamFactory = + new DefaultDataSource.Factory(context, getHttpDataSourceFactory(context)); + dataSourceFactory = buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache(context)); + } + return dataSourceFactory; + } + + @UnstableApi + public static synchronized DownloadNotificationHelper getDownloadNotificationHelper( + Context context) { + if (downloadNotificationHelper == null) { + downloadNotificationHelper = + new DownloadNotificationHelper(context, DOWNLOAD_NOTIFICATION_CHANNEL_ID); + } + return downloadNotificationHelper; + } + + @UnstableApi + public static synchronized DownloadManager getDownloadManager(Context context) { + ensureDownloadManagerInitialized(context); + return downloadManager; + } + + @UnstableApi + public static synchronized DownloadTracker getDownloadTracker(Context context) { + ensureDownloadManagerInitialized(context); + return downloadTracker; + } + + @UnstableApi + private static synchronized Cache getDownloadCache(Context context) { + if (downloadCache == null) { + File downloadContentDirectory = + new File(getDownloadDirectory(context), DOWNLOAD_CONTENT_DIRECTORY); + downloadCache = + new SimpleCache( + downloadContentDirectory, new NoOpCacheEvictor(), getDatabaseProvider(context)); + } + return downloadCache; + } + + @UnstableApi + private static synchronized void ensureDownloadManagerInitialized(Context context) { + if (downloadManager == null) { + downloadManager = + new DownloadManager( + context, + getDatabaseProvider(context), + getDownloadCache(context), + getHttpDataSourceFactory(context), + Executors.newFixedThreadPool(/* nThreads= */ 6)); + downloadTracker = + new DownloadTracker(context, getHttpDataSourceFactory(context), downloadManager); + } + } + + @UnstableApi + private static synchronized DatabaseProvider getDatabaseProvider(Context context) { + if (databaseProvider == null) { + databaseProvider = new StandaloneDatabaseProvider(context); + } + return databaseProvider; + } + + private static synchronized File getDownloadDirectory(Context context) { + if (downloadDirectory == null) { + downloadDirectory = context.getExternalFilesDir(/* type= */ null); + if (downloadDirectory == null) { + downloadDirectory = context.getFilesDir(); + } + } + return downloadDirectory; + } + + @UnstableApi + private static 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 DemoUtil() {} +} diff --git a/app/src/main/java/com/example/exoplayertv/DownloadTracker.java b/app/src/main/java/com/example/exoplayertv/DownloadTracker.java new file mode 100644 index 0000000..f7e66bb --- /dev/null +++ b/app/src/main/java/com/example/exoplayertv/DownloadTracker.java @@ -0,0 +1,417 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.example.exoplayertv; + +import static com.google.common.base.Preconditions.checkNotNull; + +import android.content.Context; +import android.content.DialogInterface; +import android.net.Uri; +import android.os.AsyncTask; +import android.widget.Toast; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.fragment.app.FragmentManager; +import androidx.media3.common.Format; +import androidx.media3.common.MediaItem; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.exoplayer.RenderersFactory; +import androidx.media3.common.Tracks; +import androidx.media3.common.DrmInitData; +import androidx.media3.exoplayer.drm.DrmSession; +import androidx.media3.exoplayer.drm.DrmSessionEventListener; +import androidx.media3.exoplayer.drm.OfflineLicenseHelper; +import androidx.media3.exoplayer.offline.Download; +import androidx.media3.exoplayer.offline.DownloadCursor; +import androidx.media3.exoplayer.offline.DownloadHelper; +import androidx.media3.exoplayer.offline.DownloadHelper.LiveContentUnsupportedException; +import androidx.media3.exoplayer.offline.DownloadIndex; +import androidx.media3.exoplayer.offline.DownloadManager; +import androidx.media3.exoplayer.offline.DownloadRequest; +import androidx.media3.exoplayer.offline.DownloadService; +import androidx.media3.common.TrackGroup; +import androidx.media3.exoplayer.source.TrackGroupArray; +import androidx.media3.exoplayer.trackselection.MappingTrackSelector.MappedTrackInfo; +import androidx.media3.common.TrackSelectionParameters; +import androidx.media3.datasource.DataSource; +import androidx.media3.common.util.Log; +import androidx.media3.common.util.Util; +import java.io.IOException; +import java.util.HashMap; +import java.util.concurrent.CopyOnWriteArraySet; + +/** Tracks media that has been downloaded. */ +public class DownloadTracker { + + /** Listens for changes in the tracked downloads. */ + public interface Listener { + + /** Called when the tracked downloads changed. */ + void onDownloadsChanged(); + } + + private static final String TAG = "DownloadTracker"; + + private final Context context; + private final DataSource.Factory dataSourceFactory; + private final CopyOnWriteArraySet listeners; + @UnstableApi + private final HashMap downloads; + @UnstableApi + private final DownloadIndex downloadIndex; + + @Nullable private StartDownloadDialogHelper startDownloadDialogHelper; + + @UnstableApi + public DownloadTracker( + Context context, DataSource.Factory dataSourceFactory, DownloadManager downloadManager) { + this.context = context.getApplicationContext(); + this.dataSourceFactory = dataSourceFactory; + listeners = new CopyOnWriteArraySet<>(); + downloads = new HashMap<>(); + downloadIndex = downloadManager.getDownloadIndex(); + downloadManager.addListener(new DownloadManagerListener()); + loadDownloads(); + } + + public void addListener(Listener listener) { + listeners.add(checkNotNull(listener)); + } + + public void removeListener(Listener listener) { + listeners.remove(listener); + } + + @UnstableApi + public boolean isDownloaded(MediaItem mediaItem) { + @Nullable Download download = downloads.get(checkNotNull(mediaItem.localConfiguration).uri); + return download != null && download.state != Download.STATE_FAILED; + } + + @UnstableApi + @Nullable + public DownloadRequest getDownloadRequest(Uri uri) { + @Nullable Download download = downloads.get(uri); + return download != null && download.state != Download.STATE_FAILED ? download.request : null; + } + + @UnstableApi + public void toggleDownload( + FragmentManager fragmentManager, MediaItem mediaItem, RenderersFactory renderersFactory) { + @Nullable Download download = downloads.get(checkNotNull(mediaItem.localConfiguration).uri); + if (download != null && download.state != Download.STATE_FAILED) { + DownloadService.sendRemoveDownload( + context, DemoDownloadService.class, download.request.id, /* foreground= */ false); + } else { + if (startDownloadDialogHelper != null) { + startDownloadDialogHelper.release(); + } + startDownloadDialogHelper = + new StartDownloadDialogHelper( + fragmentManager, + DownloadHelper.forMediaItem(context, mediaItem, renderersFactory, dataSourceFactory), + mediaItem); + } + } + + @UnstableApi + private void loadDownloads() { + try (DownloadCursor loadedDownloads = downloadIndex.getDownloads()) { + while (loadedDownloads.moveToNext()) { + Download download = loadedDownloads.getDownload(); + downloads.put(download.request.uri, download); + } + } catch (IOException e) { + Log.w(TAG, "Failed to query downloads", e); + } + } + + @UnstableApi + private class DownloadManagerListener implements DownloadManager.Listener { + + @Override + public void onDownloadChanged( + DownloadManager downloadManager, Download download, @Nullable Exception finalException) { + downloads.put(download.request.uri, download); + for (Listener listener : listeners) { + listener.onDownloadsChanged(); + } + } + + @Override + public void onDownloadRemoved(DownloadManager downloadManager, Download download) { + downloads.remove(download.request.uri); + for (Listener listener : listeners) { + listener.onDownloadsChanged(); + } + } + } + + @UnstableApi + private final class StartDownloadDialogHelper + implements DownloadHelper.Callback, + TrackSelectionDialog.TrackSelectionListener, + DialogInterface.OnDismissListener { + + private final FragmentManager fragmentManager; + private final DownloadHelper downloadHelper; + private final MediaItem mediaItem; + + private TrackSelectionDialog trackSelectionDialog; + private WidevineOfflineLicenseFetchTask widevineOfflineLicenseFetchTask; + @Nullable private byte[] keySetId; + + public StartDownloadDialogHelper( + FragmentManager fragmentManager, DownloadHelper downloadHelper, MediaItem mediaItem) { + this.fragmentManager = fragmentManager; + this.downloadHelper = downloadHelper; + this.mediaItem = mediaItem; + downloadHelper.prepare(this); + } + + public void release() { + downloadHelper.release(); + if (trackSelectionDialog != null) { + trackSelectionDialog.dismiss(); + } + if (widevineOfflineLicenseFetchTask != null) { + widevineOfflineLicenseFetchTask.cancel(false); + } + } + + // DownloadHelper.Callback implementation. + + @Override + public void onPrepared(DownloadHelper helper, boolean tracksInfoAvailable) { + @Nullable Format format = getFirstFormatWithDrmInitData(helper); + if (format == null) { + onDownloadPrepared(helper); + return; + } + + // The content is DRM protected. We need to acquire an offline license. + if (Util.SDK_INT < 18) { + Toast.makeText(context, R.string.error_drm_unsupported_before_api_18, Toast.LENGTH_LONG) + .show(); + Log.e(TAG, "Downloading DRM protected content is not supported on API versions below 18"); + return; + } + // TODO(internal b/163107948): Support cases where DrmInitData are not in the manifest. + if (!hasSchemaData(format.drmInitData)) { + Toast.makeText(context, R.string.download_start_error_offline_license, Toast.LENGTH_LONG) + .show(); + Log.e( + TAG, + "Downloading content where DRM scheme data is not located in the manifest is not" + + " supported"); + return; + } + widevineOfflineLicenseFetchTask = + new WidevineOfflineLicenseFetchTask( + format, + mediaItem.localConfiguration.drmConfiguration, + dataSourceFactory, + /* dialogHelper= */ this, + helper); + widevineOfflineLicenseFetchTask.execute(); + } + + @Override + public void onPrepareError(DownloadHelper helper, IOException e) { + boolean isLiveContent = e instanceof LiveContentUnsupportedException; + int toastStringId = + isLiveContent ? R.string.download_live_unsupported : R.string.download_start_error; + String logMessage = + isLiveContent ? "Downloading live content unsupported" : "Failed to start download"; + Toast.makeText(context, toastStringId, Toast.LENGTH_LONG).show(); + Log.e(TAG, logMessage, e); + } + + // TrackSelectionListener implementation. + + @Override + public void onTracksSelected(TrackSelectionParameters trackSelectionParameters) { + for (int periodIndex = 0; periodIndex < downloadHelper.getPeriodCount(); periodIndex++) { + downloadHelper.clearTrackSelections(periodIndex); + downloadHelper.addTrackSelection(periodIndex, trackSelectionParameters); + } + DownloadRequest downloadRequest = buildDownloadRequest(); + if (downloadRequest.streamKeys.isEmpty()) { + // All tracks were deselected in the dialog. Don't start the download. + return; + } + startDownload(downloadRequest); + } + + // DialogInterface.OnDismissListener implementation. + + @Override + public void onDismiss(DialogInterface dialogInterface) { + trackSelectionDialog = null; + downloadHelper.release(); + } + + // Internal methods. + + /** + * Returns the first {@link Format} with a non-null {@link Format#drmInitData} found in the + * content's tracks, or null if none is found. + */ + @Nullable + private Format getFirstFormatWithDrmInitData(DownloadHelper helper) { + for (int periodIndex = 0; periodIndex < helper.getPeriodCount(); periodIndex++) { + MappedTrackInfo mappedTrackInfo = helper.getMappedTrackInfo(periodIndex); + for (int rendererIndex = 0; + rendererIndex < mappedTrackInfo.getRendererCount(); + rendererIndex++) { + TrackGroupArray trackGroups = mappedTrackInfo.getTrackGroups(rendererIndex); + for (int trackGroupIndex = 0; trackGroupIndex < trackGroups.length; trackGroupIndex++) { + TrackGroup trackGroup = trackGroups.get(trackGroupIndex); + for (int formatIndex = 0; formatIndex < trackGroup.length; formatIndex++) { + Format format = trackGroup.getFormat(formatIndex); + if (format.drmInitData != null) { + return format; + } + } + } + } + } + return null; + } + + private void onOfflineLicenseFetched(DownloadHelper helper, byte[] keySetId) { + this.keySetId = keySetId; + onDownloadPrepared(helper); + } + + private void onOfflineLicenseFetchedError(DrmSession.DrmSessionException e) { + Toast.makeText(context, R.string.download_start_error_offline_license, Toast.LENGTH_LONG) + .show(); + Log.e(TAG, "Failed to fetch offline DRM license", e); + } + + private void onDownloadPrepared(DownloadHelper helper) { + if (helper.getPeriodCount() == 0) { + Log.d(TAG, "No periods found. Downloading entire stream."); + startDownload(); + downloadHelper.release(); + return; + } + + Tracks tracks = downloadHelper.getTracks(/* periodIndex= */ 0); + if (!TrackSelectionDialog.willHaveContent(tracks)) { + Log.d(TAG, "No dialog content. Downloading entire stream."); + startDownload(); + downloadHelper.release(); + return; + } + trackSelectionDialog = + TrackSelectionDialog.createForTracksAndParameters( + /* titleId= */ androidx.media3.exoplayer.R.string.exo_download_description, + tracks, + DownloadHelper.getDefaultTrackSelectorParameters(context), + /* allowAdaptiveSelections= */ false, + /* allowMultipleOverrides= */ true, + /* onTracksSelectedListener= */ this, + /* onDismissListener= */ this); + trackSelectionDialog.show(fragmentManager, /* tag= */ null); + } + + /** + * Returns whether any the {@link DrmInitData.SchemeData} contained in {@code drmInitData} has + * non-null {@link DrmInitData.SchemeData#data}. + */ + private boolean hasSchemaData(DrmInitData drmInitData) { + for (int i = 0; i < drmInitData.schemeDataCount; i++) { + if (drmInitData.get(i).hasData()) { + return true; + } + } + return false; + } + + private void startDownload() { + startDownload(buildDownloadRequest()); + } + + private void startDownload(DownloadRequest downloadRequest) { + DownloadService.sendAddDownload( + context, DemoDownloadService.class, downloadRequest, /* foreground= */ false); + } + + private DownloadRequest buildDownloadRequest() { + return downloadHelper + .getDownloadRequest( + Util.getUtf8Bytes(checkNotNull(mediaItem.mediaMetadata.title.toString()))) + .copyWithKeySetId(keySetId); + } + } + + /** Downloads a Widevine offline license in a background thread. */ + @RequiresApi(18) + private static final class WidevineOfflineLicenseFetchTask extends AsyncTask { + + private final Format format; + private final MediaItem.DrmConfiguration drmConfiguration; + private final DataSource.Factory dataSourceFactory; + private final StartDownloadDialogHelper dialogHelper; + private final DownloadHelper downloadHelper; + + @Nullable private byte[] keySetId; + @Nullable private DrmSession.DrmSessionException drmSessionException; + + public WidevineOfflineLicenseFetchTask( + Format format, + MediaItem.DrmConfiguration drmConfiguration, + DataSource.Factory dataSourceFactory, + StartDownloadDialogHelper dialogHelper, + DownloadHelper downloadHelper) { + this.format = format; + this.drmConfiguration = drmConfiguration; + this.dataSourceFactory = dataSourceFactory; + this.dialogHelper = dialogHelper; + this.downloadHelper = downloadHelper; + } + + @Override + protected Void doInBackground(Void... voids) { + OfflineLicenseHelper offlineLicenseHelper = + OfflineLicenseHelper.newWidevineInstance( + drmConfiguration.licenseUri.toString(), + drmConfiguration.forceDefaultLicenseUri, + dataSourceFactory, + drmConfiguration.licenseRequestHeaders, + new DrmSessionEventListener.EventDispatcher()); + try { + keySetId = offlineLicenseHelper.downloadLicense(format); + } catch (DrmSession.DrmSessionException e) { + drmSessionException = e; + } finally { + offlineLicenseHelper.release(); + } + return null; + } + + @Override + protected void onPostExecute(Void aVoid) { + if (drmSessionException != null) { + dialogHelper.onOfflineLicenseFetchedError(drmSessionException); + } else { + dialogHelper.onOfflineLicenseFetched(downloadHelper, checkNotNull(keySetId)); + } + } + } +} diff --git a/app/src/main/java/com/example/exoplayertv/Gesten.java b/app/src/main/java/com/example/exoplayertv/Gesten.java new file mode 100644 index 0000000..37caab0 --- /dev/null +++ b/app/src/main/java/com/example/exoplayertv/Gesten.java @@ -0,0 +1,122 @@ +package com.example.exoplayertv; +// https://droid-lernen.de/wischgesten-erkennen/ + +import android.content.Context; +import android.util.Log; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.View; +import android.util.DisplayMetrics; + +public class Gesten implements View.OnTouchListener { + + private GestureDetector gestureDetector = null; + private Context cxt = null; + private float xDown=-1,yDown=-1; + private boolean isDown = false; + + private float ScreenWidth; + private float ScreenHeight; + + public Gesten(Context context) { + DisplayMetrics dm = context.getResources().getDisplayMetrics(); + ScreenWidth = dm.widthPixels; + ScreenHeight = dm.heightPixels; + + gestureDetector = new GestureDetector(context, new GestureListener()); + cxt = context; + } + + public boolean onTouch(View v, MotionEvent event) { + if(event.getAction()==MotionEvent.ACTION_DOWN) { + isDown = true; + xDown = event.getRawX()/ScreenWidth; + yDown = event.getRawY()/ScreenHeight; + onDown(xDown,yDown); + }else if(event.getAction()==MotionEvent.ACTION_UP) { + isDown = false; + xDown = -1; + yDown = -1; + onUp(event.getRawX()/ScreenWidth,event.getRawY()/ScreenHeight); + }else if(event.getAction()==MotionEvent.ACTION_MOVE && isDown) { + onMove(xDown-event.getRawX()/ScreenWidth, yDown-event.getRawY()/ScreenHeight,ScreenWidth,ScreenHeight); + } + boolean res = gestureDetector.onTouchEvent(event); + return res; + } + + private final class GestureListener extends GestureDetector.SimpleOnGestureListener { + + private static final int SWIPE_THRESHOLD = 100; + private static final int SWIPE_VELOCITY_THRESHOLD = 100; + + @Override + public boolean onDown(MotionEvent e) { + return true; + } + + @Override + public boolean onSingleTapUp(MotionEvent e) { + return onClick(); + //return super.onSingleTapUp(e); + } + + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { + boolean result = false; + try { + float diffY = e2.getY() - e1.getY(); + float diffX = e2.getX() - e1.getX(); + if (Math.abs(diffX) > Math.abs(diffY)) { + if (Math.abs(diffX) > SWIPE_THRESHOLD && Math.abs(velocityX) > SWIPE_VELOCITY_THRESHOLD) { + if (diffX > 0) { + result = onSwipeRight(diffX); + } else { + result = onSwipeLeft(diffX); + } + } + } else { + if (Math.abs(diffY) > SWIPE_THRESHOLD && Math.abs(velocityY) > SWIPE_VELOCITY_THRESHOLD) { + if (diffY > 0) { + result = onSwipeBottom(diffY); + } else { + result = onSwipeTop(diffY); + } + } + } + } catch (Exception exception) { + exception.printStackTrace(); + } + return result; + } + } + + public boolean onSwipeRight(float diffX) { + return false; + } + + public boolean onSwipeLeft(float diffX) { + return false; + } + + public void onMove(float diffX, float diffY, float ScreenWidth, float ScreenHeight){ + Log.i("Elmar", "xDiff="+String.valueOf(diffX)+", yDiff="+String.valueOf(diffY)); + } + + public void onDown(float diffX, float diffY){ } + + public void onUp(float diffX, float diffY){ } + + public boolean onClick(){ + return false; + } + + public boolean onSwipeTop(float diffY) { + return false; + } + + public boolean onSwipeBottom(float diffY) { + return false; + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/example/exoplayertv/IntentUtil.java b/app/src/main/java/com/example/exoplayertv/IntentUtil.java new file mode 100644 index 0000000..5588e9d --- /dev/null +++ b/app/src/main/java/com/example/exoplayertv/IntentUtil.java @@ -0,0 +1,266 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.example.exoplayertv; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; + +import android.content.Intent; +import android.net.Uri; +import androidx.annotation.Nullable; +import androidx.media3.common.C; +import androidx.media3.common.MediaItem; +import androidx.media3.common.MediaItem.ClippingConfiguration; +import androidx.media3.common.MediaItem.SubtitleConfiguration; +import androidx.media3.common.MediaMetadata; +import androidx.media3.common.util.Util; +import com.google.common.collect.ImmutableList; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** Util to read from and populate an intent. */ +public class IntentUtil { + + // Actions. + + public static final String ACTION_VIEW = "com.google.android.exoplayer.demo.action.VIEW"; + public static final String ACTION_VIEW_LIST = + "com.google.android.exoplayer.demo.action.VIEW_LIST"; + + // Activity extras. + public static final String PREFER_EXTENSION_DECODERS_EXTRA = "prefer_extension_decoders"; + + // Media item configuration extras. + + public static final String URI_EXTRA = "uri"; + public static final String TITLE_EXTRA = "title"; + public static final String MIME_TYPE_EXTRA = "mime_type"; + public static final String CLIP_START_POSITION_MS_EXTRA = "clip_start_position_ms"; + public static final String CLIP_END_POSITION_MS_EXTRA = "clip_end_position_ms"; + + public static final String AD_TAG_URI_EXTRA = "ad_tag_uri"; + + public static final String DRM_SCHEME_EXTRA = "drm_scheme"; + public static final String DRM_LICENSE_URI_EXTRA = "drm_license_uri"; + public static final String DRM_KEY_REQUEST_PROPERTIES_EXTRA = "drm_key_request_properties"; + public static final String DRM_SESSION_FOR_CLEAR_CONTENT = "drm_session_for_clear_content"; + public static final String DRM_MULTI_SESSION_EXTRA = "drm_multi_session"; + public static final String DRM_FORCE_DEFAULT_LICENSE_URI_EXTRA = "drm_force_default_license_uri"; + + public static final String SUBTITLE_URI_EXTRA = "subtitle_uri"; + public static final String SUBTITLE_MIME_TYPE_EXTRA = "subtitle_mime_type"; + public static final String SUBTITLE_LANGUAGE_EXTRA = "subtitle_language"; + + /** Creates a list of {@link MediaItem media items} from an {@link Intent}. */ + public static List createMediaItemsFromIntent(Intent intent) { + List mediaItems = new ArrayList<>(); + if (ACTION_VIEW_LIST.equals(intent.getAction())) { + int index = 0; + while (intent.hasExtra(URI_EXTRA + "_" + index)) { + Uri uri = Uri.parse(intent.getStringExtra(URI_EXTRA + "_" + index)); + mediaItems.add(createMediaItemFromIntent(uri, intent, /* extrasKeySuffix= */ "_" + index)); + index++; + } + } else { + Uri uri = intent.getData(); + mediaItems.add(createMediaItemFromIntent(uri, intent, /* extrasKeySuffix= */ "")); + } + return mediaItems; + } + + /** Populates the intent with the given list of {@link MediaItem media items}. */ + public static void addToIntent(List mediaItems, Intent intent) { + checkArgument(!mediaItems.isEmpty()); + if (mediaItems.size() == 1) { + MediaItem mediaItem = mediaItems.get(0); + MediaItem.LocalConfiguration localConfiguration = checkNotNull(mediaItem.localConfiguration); + intent.setAction(ACTION_VIEW).setData(mediaItem.localConfiguration.uri); + if (mediaItem.mediaMetadata.title != null) { + intent.putExtra(TITLE_EXTRA, mediaItem.mediaMetadata.title); + } + addPlaybackPropertiesToIntent(localConfiguration, intent, /* extrasKeySuffix= */ ""); + addClippingConfigurationToIntent( + mediaItem.clippingConfiguration, intent, /* extrasKeySuffix= */ ""); + } else { + intent.setAction(ACTION_VIEW_LIST); + for (int i = 0; i < mediaItems.size(); i++) { + MediaItem mediaItem = mediaItems.get(i); + MediaItem.LocalConfiguration localConfiguration = + checkNotNull(mediaItem.localConfiguration); + intent.putExtra(URI_EXTRA + ("_" + i), localConfiguration.uri.toString()); + addPlaybackPropertiesToIntent(localConfiguration, intent, /* extrasKeySuffix= */ "_" + i); + addClippingConfigurationToIntent( + mediaItem.clippingConfiguration, intent, /* extrasKeySuffix= */ "_" + i); + if (mediaItem.mediaMetadata.title != null) { + intent.putExtra(TITLE_EXTRA + ("_" + i), mediaItem.mediaMetadata.title); + } + } + } + } + + private static MediaItem createMediaItemFromIntent( + Uri uri, Intent intent, String extrasKeySuffix) { + @Nullable String mimeType = intent.getStringExtra(MIME_TYPE_EXTRA + extrasKeySuffix); + @Nullable String title = intent.getStringExtra(TITLE_EXTRA + extrasKeySuffix); + @Nullable String adTagUri = intent.getStringExtra(AD_TAG_URI_EXTRA + extrasKeySuffix); + @Nullable + SubtitleConfiguration subtitleConfiguration = + createSubtitleConfiguration(intent, extrasKeySuffix); + MediaItem.Builder builder = + new MediaItem.Builder() + .setUri(uri) + .setMimeType(mimeType) + .setMediaMetadata(new MediaMetadata.Builder().setTitle(title).build()) + .setClippingConfiguration( + new ClippingConfiguration.Builder() + .setStartPositionMs( + intent.getLongExtra(CLIP_START_POSITION_MS_EXTRA + extrasKeySuffix, 0)) + .setEndPositionMs( + intent.getLongExtra( + CLIP_END_POSITION_MS_EXTRA + extrasKeySuffix, C.TIME_END_OF_SOURCE)) + .build()); + if (adTagUri != null) { + builder.setAdsConfiguration( + new MediaItem.AdsConfiguration.Builder(Uri.parse(adTagUri)).build()); + } + if (subtitleConfiguration != null) { + builder.setSubtitleConfigurations(ImmutableList.of(subtitleConfiguration)); + } + + return populateDrmPropertiesFromIntent(builder, intent, extrasKeySuffix).build(); + } + + @Nullable + private static MediaItem.SubtitleConfiguration createSubtitleConfiguration( + Intent intent, String extrasKeySuffix) { + if (!intent.hasExtra(SUBTITLE_URI_EXTRA + extrasKeySuffix)) { + return null; + } + return new MediaItem.SubtitleConfiguration.Builder( + Uri.parse(intent.getStringExtra(SUBTITLE_URI_EXTRA + extrasKeySuffix))) + .setMimeType( + checkNotNull(intent.getStringExtra(SUBTITLE_MIME_TYPE_EXTRA + extrasKeySuffix))) + .setLanguage(intent.getStringExtra(SUBTITLE_LANGUAGE_EXTRA + extrasKeySuffix)) + .setSelectionFlags(C.SELECTION_FLAG_DEFAULT) + .build(); + } + + private static MediaItem.Builder populateDrmPropertiesFromIntent( + MediaItem.Builder builder, Intent intent, String extrasKeySuffix) { + String schemeKey = DRM_SCHEME_EXTRA + extrasKeySuffix; + @Nullable String drmSchemeExtra = intent.getStringExtra(schemeKey); + if (drmSchemeExtra == null) { + return builder; + } + Map headers = new HashMap<>(); + @Nullable + String[] keyRequestPropertiesArray = + intent.getStringArrayExtra(DRM_KEY_REQUEST_PROPERTIES_EXTRA + extrasKeySuffix); + if (keyRequestPropertiesArray != null) { + for (int i = 0; i < keyRequestPropertiesArray.length; i += 2) { + headers.put(keyRequestPropertiesArray[i], keyRequestPropertiesArray[i + 1]); + } + } + @Nullable UUID drmUuid = Util.getDrmUuid(drmSchemeExtra); + if (drmUuid != null) { + builder.setDrmConfiguration( + new MediaItem.DrmConfiguration.Builder(drmUuid) + .setLicenseUri(intent.getStringExtra(DRM_LICENSE_URI_EXTRA + extrasKeySuffix)) + .setMultiSession( + intent.getBooleanExtra(DRM_MULTI_SESSION_EXTRA + extrasKeySuffix, false)) + .setForceDefaultLicenseUri( + intent.getBooleanExtra( + DRM_FORCE_DEFAULT_LICENSE_URI_EXTRA + extrasKeySuffix, false)) + .setLicenseRequestHeaders(headers) + .setForceSessionsForAudioAndVideoTracks( + intent.getBooleanExtra(DRM_SESSION_FOR_CLEAR_CONTENT + extrasKeySuffix, false)) + .build()); + } + return builder; + } + + private static void addPlaybackPropertiesToIntent( + MediaItem.LocalConfiguration localConfiguration, Intent intent, String extrasKeySuffix) { + intent + .putExtra(MIME_TYPE_EXTRA + extrasKeySuffix, localConfiguration.mimeType) + .putExtra( + AD_TAG_URI_EXTRA + extrasKeySuffix, + localConfiguration.adsConfiguration != null + ? localConfiguration.adsConfiguration.adTagUri.toString() + : null); + if (localConfiguration.drmConfiguration != null) { + addDrmConfigurationToIntent(localConfiguration.drmConfiguration, intent, extrasKeySuffix); + } + if (!localConfiguration.subtitleConfigurations.isEmpty()) { + checkState(localConfiguration.subtitleConfigurations.size() == 1); + MediaItem.SubtitleConfiguration subtitleConfiguration = + localConfiguration.subtitleConfigurations.get(0); + intent.putExtra(SUBTITLE_URI_EXTRA + extrasKeySuffix, subtitleConfiguration.uri.toString()); + intent.putExtra(SUBTITLE_MIME_TYPE_EXTRA + extrasKeySuffix, subtitleConfiguration.mimeType); + intent.putExtra(SUBTITLE_LANGUAGE_EXTRA + extrasKeySuffix, subtitleConfiguration.language); + } + } + + private static void addDrmConfigurationToIntent( + MediaItem.DrmConfiguration drmConfiguration, Intent intent, String extrasKeySuffix) { + intent.putExtra(DRM_SCHEME_EXTRA + extrasKeySuffix, drmConfiguration.scheme.toString()); + intent.putExtra( + DRM_LICENSE_URI_EXTRA + extrasKeySuffix, + drmConfiguration.licenseUri != null ? drmConfiguration.licenseUri.toString() : null); + intent.putExtra(DRM_MULTI_SESSION_EXTRA + extrasKeySuffix, drmConfiguration.multiSession); + intent.putExtra( + DRM_FORCE_DEFAULT_LICENSE_URI_EXTRA + extrasKeySuffix, + drmConfiguration.forceDefaultLicenseUri); + + String[] drmKeyRequestProperties = + new String[drmConfiguration.licenseRequestHeaders.size() * 2]; + int index = 0; + for (Map.Entry entry : drmConfiguration.licenseRequestHeaders.entrySet()) { + drmKeyRequestProperties[index++] = entry.getKey(); + drmKeyRequestProperties[index++] = entry.getValue(); + } + intent.putExtra(DRM_KEY_REQUEST_PROPERTIES_EXTRA + extrasKeySuffix, drmKeyRequestProperties); + + List<@C.TrackType Integer> forcedDrmSessionTrackTypes = + drmConfiguration.forcedSessionTrackTypes; + if (!forcedDrmSessionTrackTypes.isEmpty()) { + // Only video and audio together are supported. + checkState( + forcedDrmSessionTrackTypes.size() == 2 + && forcedDrmSessionTrackTypes.contains(C.TRACK_TYPE_VIDEO) + && forcedDrmSessionTrackTypes.contains(C.TRACK_TYPE_AUDIO)); + intent.putExtra(DRM_SESSION_FOR_CLEAR_CONTENT + extrasKeySuffix, true); + } + } + + private static void addClippingConfigurationToIntent( + MediaItem.ClippingConfiguration clippingConfiguration, + Intent intent, + String extrasKeySuffix) { + if (clippingConfiguration.startPositionMs != 0) { + intent.putExtra( + CLIP_START_POSITION_MS_EXTRA + extrasKeySuffix, clippingConfiguration.startPositionMs); + } + if (clippingConfiguration.endPositionMs != C.TIME_END_OF_SOURCE) { + intent.putExtra( + CLIP_END_POSITION_MS_EXTRA + extrasKeySuffix, clippingConfiguration.endPositionMs); + } + } +} diff --git a/app/src/main/java/com/example/exoplayertv/PlayerActivity.java b/app/src/main/java/com/example/exoplayertv/PlayerActivity.java new file mode 100644 index 0000000..ebead60 --- /dev/null +++ b/app/src/main/java/com/example/exoplayertv/PlayerActivity.java @@ -0,0 +1,694 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.example.exoplayertv; + +import static java.lang.Math.abs; + +import android.content.Intent; +import android.content.pm.PackageManager; +import android.media.AudioManager; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.util.Pair; +import android.view.KeyEvent; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.Window; +import android.view.WindowManager; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.media3.common.C; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.exoplayer.ExoPlayer; +import androidx.media3.common.MediaItem; +import androidx.media3.common.PlaybackException; +import androidx.media3.common.Player; +import androidx.media3.exoplayer.RenderersFactory; +import androidx.media3.common.Tracks; +import androidx.media3.common.AudioAttributes; +import androidx.media3.exoplayer.drm.DefaultDrmSessionManagerProvider; +import androidx.media3.exoplayer.drm.FrameworkMediaDrm; +import androidx.media3.exoplayer.ima.ImaAdsLoader; +import androidx.media3.exoplayer.ima.ImaServerSideAdInsertionMediaSource; +import androidx.media3.exoplayer.mediacodec.MediaCodecRenderer.DecoderInitializationException; +import androidx.media3.exoplayer.mediacodec.MediaCodecUtil.DecoderQueryException; +import androidx.media3.exoplayer.offline.DownloadRequest; +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory; +import androidx.media3.exoplayer.source.MediaSource; +import androidx.media3.exoplayer.source.ads.AdsLoader; +import androidx.media3.common.TrackSelectionParameters; +import androidx.media3.ui.PlayerView; +import androidx.media3.datasource.DataSource; +//import androidx.media3.exoplayer.util.DebugTextViewHelper; +import androidx.media3.common.ErrorMessageProvider; +import androidx.media3.exoplayer.util.EventLogger; +import androidx.media3.common.util.Util; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** An activity that plays media using {@link ExoPlayer}. */ +public class PlayerActivity extends AppCompatActivity + implements OnClickListener, PlayerView.ControllerVisibilityListener { + + private AudioManager audioManager; + private int max_volume; + private int current_volume; + private WindowManager.LayoutParams lp; + private float current_brightness; + public static final int NONE = 0; + public static final int LOUDNESS = 1; + public static final int BRIGHTNESS = 2; + + private int state = NONE; + + // Saved instance state keys. + + private static final String KEY_TRACK_SELECTION_PARAMETERS = "track_selection_parameters"; + private static final String KEY_SERVER_SIDE_ADS_LOADER_STATE = "server_side_ads_loader_state"; + private static final String KEY_ITEM_INDEX = "item_index"; + private static final String KEY_POSITION = "position"; + private static final String KEY_AUTO_PLAY = "auto_play"; + + protected PlayerView playerView; + protected LinearLayout debugRootView; + protected @Nullable ExoPlayer player; + + private boolean isShowingTrackSelectionDialog; + private Button selectTracksButton; + + private TextView epgTitle; + + private Handler h; + Runnable run; + private DataSource.Factory dataSourceFactory; + private List mediaItems; + private TrackSelectionParameters trackSelectionParameters; + // private DebugTextViewHelper debugViewHelper; + private Tracks lastSeenTracks; + private boolean startAutoPlay; + private int startItemIndex; + private long startPosition; + + // For ad playback only. + + @Nullable private AdsLoader clientSideAdsLoader; + + // TODO: Annotate this and serverSideAdsLoaderState below with @OptIn when it can be applied to + // fields (needs http://r.android.com/2004032 to be released into a version of + // androidx.annotation:annotation-experimental). + @Nullable private ImaServerSideAdInsertionMediaSource.AdsLoader serverSideAdsLoader; + + private ImaServerSideAdInsertionMediaSource.AdsLoader.@MonotonicNonNull State + serverSideAdsLoaderState; + + // Activity lifecycle. + + @UnstableApi + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + dataSourceFactory = DemoUtil.getDataSourceFactory(/* context= */ this); + + setContentView(); + debugRootView = findViewById(R.id.controls_root); +// debugTextView = findViewById(R.id.debug_text_view); + selectTracksButton = findViewById(R.id.select_tracks_button); + selectTracksButton.setOnClickListener(this); + epgTitle = findViewById(R.id.debug_text_view); + char[] title = getIntent().getCharArrayExtra("Title"); + epgTitle.setText(title,0, title.length); + h = new Handler(); + run = new Runnable() { + @Override + public void run() { + debugRootView.setVisibility(View.GONE); + } + }; + + playerView = findViewById(R.id.player_view); + playerView.setControllerVisibilityListener(this); + playerView.setErrorMessageProvider(new PlayerErrorMessageProvider()); + playerView.requestFocus(); + + if (savedInstanceState != null) { + trackSelectionParameters = + TrackSelectionParameters.fromBundle( + savedInstanceState.getBundle(KEY_TRACK_SELECTION_PARAMETERS)); + startAutoPlay = savedInstanceState.getBoolean(KEY_AUTO_PLAY); + startItemIndex = savedInstanceState.getInt(KEY_ITEM_INDEX); + startPosition = savedInstanceState.getLong(KEY_POSITION); + restoreServerSideAdsLoaderState(savedInstanceState); + } else { + trackSelectionParameters = new TrackSelectionParameters.Builder(/* context= */ this).build(); + clearStartPosition(); + } + + + + audioManager = (AudioManager) getSystemService(AUDIO_SERVICE); + max_volume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC); + lp = getWindow().getAttributes(); + + playerView.setOnTouchListener(new Gesten(this) { + public boolean onSwipeTop(float diffY) { + //es wurde nach oben gewischt, hier den Code einfügen + return false; + } + public boolean onSwipeBottom(float diffY) { + //es wurde nach unten gewischt, hier den Code einfügen + return false; + } + public boolean onSwipeRight(float diffX) { + //es wurde nach rechts gewischt, hier den Code einfügen + Intent ret = PlayerActivity.this.getIntent(); + ret.putExtra(SampleChooserActivity.CHANNEL_OFFSET,-1); + PlayerActivity.this.setResult(RESULT_OK,ret); + PlayerActivity.this.finish(); + return true; + } + public boolean onSwipeLeft(float diffX) { + //es wurde nach links gewischt, hier den Code einfügen + Intent ret = PlayerActivity.this.getIntent(); + ret.putExtra(SampleChooserActivity.CHANNEL_OFFSET,1); + PlayerActivity.this.setResult(RESULT_OK,ret); + PlayerActivity.this.finish(); + return true; + } + public boolean onClick() { + char[] title = getIntent().getCharArrayExtra("Title"); + epgTitle.setText(title,0, title.length); + debugRootView.setVisibility(debugRootView.getVisibility()==View.VISIBLE?View.GONE:View.VISIBLE); + h.removeCallbacks(run); + h.postDelayed(run,5000); + /*playerView.showController();*/ + return true; + } + public void onDown(float diffX, float diffY) { + if(diffX>0.5) { + state = LOUDNESS; + current_volume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC); + } else { + state = BRIGHTNESS; + current_brightness = lp.screenBrightness; + } + } + public void onMove(float diffX, float diffY, float ScreenWidth, float ScreenHeight) { + if (abs(diffY)<0.02 || abs(diffX)>abs(diffY)) + return; + if(state==LOUDNESS) { + audioManager = (AudioManager) getSystemService(AUDIO_SERVICE); + audioManager.setStreamVolume( + AudioManager.STREAM_MUSIC, // Stream type + current_volume+(int)(max_volume*diffY), // Index + AudioManager.FLAG_SHOW_UI // Flags + ); + } else { + lp.screenBrightness = current_brightness+255*diffY; + getWindow().setAttributes(lp); + getWindow().addFlags(WindowManager.LayoutParams.FLAGS_CHANGED); + } + } + public void onUp(float diffX, float diffY) { } + }); + h.postDelayed(run,5000); + } + + @Override + public void onNewIntent(Intent intent) { + super.onNewIntent(intent); + releasePlayer(); + releaseClientSideAdsLoader(); + clearStartPosition(); + setIntent(intent); + } + + @Override + public void onStart() { + super.onStart(); + if (Build.VERSION.SDK_INT > 23) { + initializePlayer(); + if (playerView != null) { + playerView.onResume(); + } + } + } + + @Override + public void onResume() { + super.onResume(); + if (Build.VERSION.SDK_INT <= 23 || player == null) { + initializePlayer(); + if (playerView != null) { + playerView.onResume(); + } + } + } + + @Override + public void onPause() { + super.onPause(); + if (Build.VERSION.SDK_INT <= 23) { + if (playerView != null) { + playerView.onPause(); + } + releasePlayer(); + } + } + + @Override + public void onStop() { + super.onStop(); + if (Build.VERSION.SDK_INT > 23) { + if (playerView != null) { + playerView.onPause(); + } + releasePlayer(); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + releaseClientSideAdsLoader(); + } + + @Override + public void onRequestPermissionsResult( + int requestCode, String[] permissions, int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (grantResults.length == 0) { + // Empty results are triggered if a permission is requested while another request was already + // pending and can be safely ignored in this case. + return; + } + if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { + initializePlayer(); + } else { + showToast(R.string.storage_permission_denied); + finish(); + } + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + updateTrackSelectorParameters(); + updateStartPosition(); + outState.putBundle(KEY_TRACK_SELECTION_PARAMETERS, trackSelectionParameters.toBundle()); + outState.putBoolean(KEY_AUTO_PLAY, startAutoPlay); + outState.putInt(KEY_ITEM_INDEX, startItemIndex); + outState.putLong(KEY_POSITION, startPosition); + saveServerSideAdsLoaderState(outState); + } + + // Activity input + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + // See whether the player view wants to handle media or DPAD keys events. + if (event.getKeyCode()==KeyEvent.KEYCODE_DPAD_UP) { + Intent ret = PlayerActivity.this.getIntent(); + ret.putExtra(SampleChooserActivity.CHANNEL_OFFSET,-1); + PlayerActivity.this.setResult(RESULT_OK,ret); + PlayerActivity.this.finish(); + return true; + } + if (event.getKeyCode()==KeyEvent.KEYCODE_DPAD_DOWN) { + Intent ret = PlayerActivity.this.getIntent(); + ret.putExtra(SampleChooserActivity.CHANNEL_OFFSET,1); + PlayerActivity.this.setResult(RESULT_OK,ret); + PlayerActivity.this.finish(); + return true; + } + if (event.getKeyCode()==KeyEvent.KEYCODE_ENTER) { + debugRootView.setVisibility(View.VISIBLE); + } + if (debugRootView.getVisibility()==View.VISIBLE && + (event.getKeyCode()==KeyEvent.KEYCODE_DPAD_LEFT || event.getKeyCode()==KeyEvent.KEYCODE_DPAD_UP)) { + selectTracksButton.setFocusableInTouchMode(true); + return true; + } + return playerView.dispatchKeyEvent(event) || super.dispatchKeyEvent(event); + } + + // OnClickListener methods + + @Override + public void onClick(View view) { + if (view == selectTracksButton + && !isShowingTrackSelectionDialog + && TrackSelectionDialog.willHaveContent(player)) { + isShowingTrackSelectionDialog = true; + TrackSelectionDialog trackSelectionDialog = + TrackSelectionDialog.createForPlayer( + player, + /* onDismissListener= */ dismissedDialog -> isShowingTrackSelectionDialog = false); + trackSelectionDialog.show(getSupportFragmentManager(), /* tag= */ null); + } + } + + // StyledPlayerView.ControllerVisibilityListener implementation + + @Override + public void onVisibilityChanged(int visibility) { + debugRootView.setVisibility(visibility); + } + + // Internal methods + + protected void setContentView() { + setContentView(R.layout.player_activity); + } + + /** + * @return Whether initialization was successful. + */ + protected boolean initializePlayer() { + if (player == null) { + Intent intent = getIntent(); + + mediaItems = createMediaItems(intent); + if (mediaItems.isEmpty()) { + return false; + } + + lastSeenTracks = Tracks.EMPTY; + ExoPlayer.Builder playerBuilder = + new ExoPlayer.Builder(/* context= */ this) + .setMediaSourceFactory(createMediaSourceFactory()); + setRenderersFactory( + playerBuilder, intent.getBooleanExtra(IntentUtil.PREFER_EXTENSION_DECODERS_EXTRA, false)); + player = playerBuilder.build(); + player.setTrackSelectionParameters(trackSelectionParameters); + player.addListener(new PlayerEventListener()); + player.addAnalyticsListener(new EventLogger()); + player.setAudioAttributes(AudioAttributes.DEFAULT, /* handleAudioFocus= */ true); + player.setPlayWhenReady(startAutoPlay); + playerView.setPlayer(player); + configurePlayerWithServerSideAdsLoader(); +// debugViewHelper = new DebugTextViewHelper(player, debugTextView); +// debugViewHelper.start(); + } + boolean haveStartPosition = startItemIndex != C.INDEX_UNSET; + if (haveStartPosition) { + player.seekTo(startItemIndex, startPosition); + } + player.setMediaItems(mediaItems, /* resetPosition= */ !haveStartPosition); + player.prepare(); + updateButtonVisibility(); + return true; + } + + private MediaSource.Factory createMediaSourceFactory() { + DefaultDrmSessionManagerProvider drmSessionManagerProvider = + new DefaultDrmSessionManagerProvider(); + drmSessionManagerProvider.setDrmHttpDataSourceFactory( + DemoUtil.getHttpDataSourceFactory(/* context= */ this)); + ImaServerSideAdInsertionMediaSource.AdsLoader.Builder serverSideAdLoaderBuilder = + new ImaServerSideAdInsertionMediaSource.AdsLoader.Builder(/* context= */ this, playerView); + if (serverSideAdsLoaderState != null) { + serverSideAdLoaderBuilder.setAdsLoaderState(serverSideAdsLoaderState); + } + serverSideAdsLoader = serverSideAdLoaderBuilder.build(); + ImaServerSideAdInsertionMediaSource.Factory imaServerSideAdInsertionMediaSourceFactory = + new ImaServerSideAdInsertionMediaSource.Factory( + serverSideAdsLoader, + new DefaultMediaSourceFactory(/* context= */ this) + .setDataSourceFactory(dataSourceFactory)); + return new DefaultMediaSourceFactory(/* context= */ this) + .setDataSourceFactory(dataSourceFactory) + .setDrmSessionManagerProvider(drmSessionManagerProvider) + .setLocalAdInsertionComponents( + this::getClientSideAdsLoader, /* adViewProvider= */ playerView) + .setServerSideAdInsertionMediaSourceFactory(imaServerSideAdInsertionMediaSourceFactory); + } + + private void setRenderersFactory( + ExoPlayer.Builder playerBuilder, boolean preferExtensionDecoders) { + RenderersFactory renderersFactory = + DemoUtil.buildRenderersFactory(/* context= */ this, preferExtensionDecoders); + playerBuilder.setRenderersFactory(renderersFactory); + } + + private void configurePlayerWithServerSideAdsLoader() { + serverSideAdsLoader.setPlayer(player); + } + + private List createMediaItems(Intent intent) { + String action = intent.getAction(); + boolean actionIsListView = IntentUtil.ACTION_VIEW_LIST.equals(action); + if (!actionIsListView && !IntentUtil.ACTION_VIEW.equals(action)) { + showToast(getString(R.string.unexpected_intent_action, action)); + finish(); + return Collections.emptyList(); + } + + List mediaItems = + createMediaItems(intent, DemoUtil.getDownloadTracker(/* context= */ this)); + for (int i = 0; i < mediaItems.size(); i++) { + MediaItem mediaItem = mediaItems.get(i); + + if (!Util.checkCleartextTrafficPermitted(mediaItem)) { + showToast(R.string.error_cleartext_not_permitted); + finish(); + return Collections.emptyList(); + } + if (Util.maybeRequestReadExternalStoragePermission(/* activity= */ this, mediaItem)) { + // The player will be reinitialized if the permission is granted. + return Collections.emptyList(); + } + + MediaItem.DrmConfiguration drmConfiguration = mediaItem.localConfiguration.drmConfiguration; + if (drmConfiguration != null) { + if (Build.VERSION.SDK_INT < 18) { + showToast(R.string.error_drm_unsupported_before_api_18); + finish(); + return Collections.emptyList(); + } else if (!FrameworkMediaDrm.isCryptoSchemeSupported(drmConfiguration.scheme)) { + showToast(R.string.error_drm_unsupported_scheme); + finish(); + return Collections.emptyList(); + } + } + } + return mediaItems; + } + + private AdsLoader getClientSideAdsLoader(MediaItem.AdsConfiguration adsConfiguration) { + // The ads loader is reused for multiple playbacks, so that ad playback can resume. + if (clientSideAdsLoader == null) { + clientSideAdsLoader = new ImaAdsLoader.Builder(/* context= */ this).build(); + } + clientSideAdsLoader.setPlayer(player); + return clientSideAdsLoader; + } + + protected void releasePlayer() { + if (player != null) { + updateTrackSelectorParameters(); + updateStartPosition(); + releaseServerSideAdsLoader(); +// debugViewHelper.stop(); +// debugViewHelper = null; + player.release(); + player = null; + playerView.setPlayer(/* player= */ null); + mediaItems = Collections.emptyList(); + } + if (clientSideAdsLoader != null) { + clientSideAdsLoader.setPlayer(null); + } else { + playerView.getAdViewGroup().removeAllViews(); + } + } + + private void releaseServerSideAdsLoader() { + serverSideAdsLoaderState = serverSideAdsLoader.release(); + serverSideAdsLoader = null; + } + + private void releaseClientSideAdsLoader() { + if (clientSideAdsLoader != null) { + clientSideAdsLoader.release(); + clientSideAdsLoader = null; + playerView.getAdViewGroup().removeAllViews(); + } + } + + private void saveServerSideAdsLoaderState(Bundle outState) { + if (serverSideAdsLoaderState != null) { + outState.putBundle(KEY_SERVER_SIDE_ADS_LOADER_STATE, serverSideAdsLoaderState.toBundle()); + } + } + + private void restoreServerSideAdsLoaderState(Bundle savedInstanceState) { + Bundle adsLoaderStateBundle = savedInstanceState.getBundle(KEY_SERVER_SIDE_ADS_LOADER_STATE); + if (adsLoaderStateBundle != null) { + serverSideAdsLoaderState = + ImaServerSideAdInsertionMediaSource.AdsLoader.State.fromBundle( + adsLoaderStateBundle); + } + } + + private void updateTrackSelectorParameters() { + if (player != null) { + trackSelectionParameters = player.getTrackSelectionParameters(); + } + } + + private void updateStartPosition() { + if (player != null) { + startAutoPlay = player.getPlayWhenReady(); + startItemIndex = player.getCurrentMediaItemIndex(); + startPosition = Math.max(0, player.getContentPosition()); + } + } + + protected void clearStartPosition() { + startAutoPlay = true; + startItemIndex = C.INDEX_UNSET; + startPosition = C.TIME_UNSET; + } + + // User controls + + private void updateButtonVisibility() { + selectTracksButton.setEnabled(player != null && TrackSelectionDialog.willHaveContent(player)); + epgTitle.setEnabled(player != null && TrackSelectionDialog.willHaveContent(player)); + } + + private void showControls() { + debugRootView.setVisibility(View.VISIBLE); + } + + private void showToast(int messageId) { + showToast(getString(messageId)); + } + + private void showToast(String message) { + Toast.makeText(getApplicationContext(), message, Toast.LENGTH_LONG).show(); + } + + private class PlayerEventListener implements Player.Listener { + + @Override + public void onPlaybackStateChanged(@Player.State int playbackState) { + if (playbackState == Player.STATE_ENDED) { + showControls(); + } + updateButtonVisibility(); + } + + @Override + public void onPlayerError(PlaybackException error) { + if (error.errorCode == PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW) { + player.seekToDefaultPosition(); + player.prepare(); + } else { + updateButtonVisibility(); + showControls(); + } + } + + @Override + @SuppressWarnings("ReferenceEquality") + public void onTracksChanged(Tracks tracks) { + updateButtonVisibility(); + if (tracks == lastSeenTracks) { + return; + } + if (tracks.containsType(C.TRACK_TYPE_VIDEO) + && !tracks.isTypeSupported(C.TRACK_TYPE_VIDEO, /* allowExceedsCapabilities= */ true)) { + showToast(R.string.error_unsupported_video); + } + if (tracks.containsType(C.TRACK_TYPE_AUDIO) + && !tracks.isTypeSupported(C.TRACK_TYPE_AUDIO, /* allowExceedsCapabilities= */ true)) { + showToast(R.string.error_unsupported_audio); + } + lastSeenTracks = tracks; + } + } + + private class PlayerErrorMessageProvider implements ErrorMessageProvider { + + @Override + public Pair getErrorMessage(PlaybackException e) { + String errorString = getString(R.string.error_generic); + Throwable cause = e.getCause(); + if (cause instanceof DecoderInitializationException) { + // Special case for decoder initialization failures. + DecoderInitializationException decoderInitializationException = + (DecoderInitializationException) cause; + if (decoderInitializationException.codecInfo == null) { + if (decoderInitializationException.getCause() instanceof DecoderQueryException) { + errorString = getString(R.string.error_querying_decoders); + } else if (decoderInitializationException.secureDecoderRequired) { + errorString = + getString( + R.string.error_no_secure_decoder, decoderInitializationException.mimeType); + } else { + errorString = + getString(R.string.error_no_decoder, decoderInitializationException.mimeType); + } + } else { + errorString = + getString( + R.string.error_instantiating_decoder, + decoderInitializationException.codecInfo.name); + } + } + return Pair.create(0, errorString); + } + } + + private static List createMediaItems(Intent intent, DownloadTracker downloadTracker) { + List mediaItems = new ArrayList<>(); + for (MediaItem item : IntentUtil.createMediaItemsFromIntent(intent)) { + mediaItems.add( + maybeSetDownloadProperties( + item, downloadTracker.getDownloadRequest(item.localConfiguration.uri))); + } + return mediaItems; + } + + private static MediaItem maybeSetDownloadProperties( + MediaItem item, @Nullable DownloadRequest downloadRequest) { + if (downloadRequest == null) { + return item; + } + MediaItem.Builder builder = item.buildUpon(); + builder + .setMediaId(downloadRequest.id) + .setUri(downloadRequest.uri) + .setCustomCacheKey(downloadRequest.customCacheKey) + .setMimeType(downloadRequest.mimeType) + .setStreamKeys(downloadRequest.streamKeys); + @Nullable + MediaItem.DrmConfiguration drmConfiguration = item.localConfiguration.drmConfiguration; + if (drmConfiguration != null) { + builder.setDrmConfiguration( + drmConfiguration.buildUpon().setKeySetId(downloadRequest.keySetId).build()); + } + return builder.build(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/exoplayertv/SampleChooserActivity.java b/app/src/main/java/com/example/exoplayertv/SampleChooserActivity.java new file mode 100644 index 0000000..fefa58b --- /dev/null +++ b/app/src/main/java/com/example/exoplayertv/SampleChooserActivity.java @@ -0,0 +1,948 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.example.exoplayertv; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; + +import android.Manifest; +import android.app.Dialog; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.graphics.Color; +import android.graphics.drawable.ColorDrawable; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Build; +import android.os.Bundle; +import android.text.TextUtils; +import android.util.JsonReader; +import android.view.KeyEvent; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.Window; +import android.view.WindowManager; +import android.widget.Button; +import android.widget.ExpandableListView; +import android.widget.ExpandableListView.OnChildClickListener; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; +import androidx.annotation.DoNotInline; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.appcompat.app.AppCompatActivity; +import androidx.media3.common.MediaItem; +import androidx.media3.common.MediaItem.ClippingConfiguration; +import androidx.media3.common.MediaMetadata; +import androidx.media3.exoplayer.RenderersFactory; +import com.example.exoplayertv.tvepg.epg.EPG; +import com.example.exoplayertv.tvepg.epg.EPGClickListener; +import com.example.exoplayertv.tvepg.epg.EPGData; +import com.example.exoplayertv.tvepg.epg.domain.EPGChannel; +import com.example.exoplayertv.tvepg.epg.domain.EPGEvent; +import com.example.exoplayertv.tvepg.xmltv.ContentTask; +import com.example.exoplayertv.tvepg.xmltv.model.Channel; +import com.example.exoplayertv.tvepg.xmltv.model.Program; +import androidx.media3.exoplayer.offline.DownloadService; +import androidx.media3.common.util.Log; +import androidx.media3.common.util.Util; +import com.google.common.base.Objects; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URL; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** An activity for selecting from a list of media samples. */ +public class SampleChooserActivity extends AppCompatActivity + implements DownloadTracker.Listener, OnChildClickListener, + ContentTask.TvGuide { + private EPG epg; + private static EPGData epgData; + private int curChannelPosition=-1; + private int curProgramPosition=-1; + private List playlists; + + private static final String TAG = "SampleChooserActivity"; + private static final String GROUP_POSITION_PREFERENCE_KEY = "sample_chooser_group_position"; + private static final String CHILD_POSITION_PREFERENCE_KEY = "sample_chooser_child_position"; + private static final int POST_NOTIFICATION_PERMISSION_REQUEST_CODE = 100; + + static final int PLAYER_REQUEST = 12345; // The request code + public static final String CHANNEL_OFFSET = "SampleChooserActivity.CHANNEL_OFFSET"; + static PlaylistGroup group; + static int childPosition = 0; + static boolean isExternalURL; + + private String[] uris; + private boolean useExtensionRenderers; + private DownloadTracker downloadTracker; + private MenuItem preferExtensionDecodersMenuItem; + @Nullable private MediaItem downloadMediaItemWaitingForNotificationPermission; + private boolean notificationPermissionToastShown; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.sample_chooser_activity); + + // ActionBar verstecken + if (getSupportActionBar() != null) { + getSupportActionBar().hide(); + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, + WindowManager.LayoutParams.FLAG_FULLSCREEN); + } + + epg = (EPG) findViewById(R.id.epg); + epg.setEPGClickListener(new EPGClickListener() { + @Override + public void onChannelClicked(int channelPosition, EPGChannel epgChannel) { + SampleChooserActivity.this.onChannelClicked(channelPosition, epgChannel); + } + + @Override + public void onEventClicked(int channelPosition, int programPosition, EPGEvent epgEvent) { + SampleChooserActivity.this.onEventClicked(channelPosition, programPosition, epgEvent); + } + + @Override + public void onResetButtonClicked() { + epg.recalculateAndRedraw(true); + } + }); + + // Do initial load of data. + if (epgData==null) { + new SampleChooserActivity.Channels().execute(); + } else { + epg.setEPGData(epgData); + epg.recalculateAndRedraw(true); + } + + uris = new String[]{"https://www.ask-meyer.ch/IPTV/Channels.json"}; + Arrays.sort(uris); + + useExtensionRenderers = DemoUtil.useExtensionRenderers(); + downloadTracker = DemoUtil.getDownloadTracker(/* context= */ this); + loadSample(); + startDownloadService(); + } + + /** Start the download service if it should be running but it's not currently. */ + private void startDownloadService() { + // Starting the service in the foreground causes notification flicker if there is no scheduled + // action. Starting it in the background throws an exception if the app is in the background too + // (e.g. if device screen is locked). + try { + DownloadService.start(this, DemoDownloadService.class); + } catch (IllegalStateException e) { + DownloadService.startForeground(this, DemoDownloadService.class); + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.sample_chooser_menu, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + int id = item.getItemId(); + + //noinspection SimplifiableIfStatement + if (id == R.id.action_update) { + // Do initial load of data. + new Channels().execute(); + curChannelPosition = -1; + curProgramPosition = -1; + return true; + } + + if (id == R.id.action_destroy) { + // Do initial load of data. + if (epg != null) { + epg.clearEPGImageCache(); + } + finish(); + return true; + } + + return super.onOptionsItemSelected(item); + } + + @Override + public void onStart() { + super.onStart(); + downloadTracker.addListener(this); + } + + @Override + public void onStop() { + downloadTracker.removeListener(this); + super.onStop(); + } + + @Override + public void onDownloadsChanged() { + } + + @Override + public void onRequestPermissionsResult( + int requestCode, String[] permissions, int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (requestCode == POST_NOTIFICATION_PERMISSION_REQUEST_CODE) { + handlePostNotificationPermissionGrantResults(grantResults); + } else { + handleExternalStoragePermissionGrantResults(grantResults); + } + } + + private void handlePostNotificationPermissionGrantResults(int[] grantResults) { + if (!notificationPermissionToastShown + && (grantResults.length == 0 || grantResults[0] != PackageManager.PERMISSION_GRANTED)) { + Toast.makeText( + getApplicationContext(), R.string.post_notification_not_granted, Toast.LENGTH_LONG) + .show(); + notificationPermissionToastShown = true; + } + if (downloadMediaItemWaitingForNotificationPermission != null) { + // Download with or without permission to post notifications. + toggleDownload(downloadMediaItemWaitingForNotificationPermission); + downloadMediaItemWaitingForNotificationPermission = null; + } + } + + private void handleExternalStoragePermissionGrantResults(int[] grantResults) { + if (grantResults.length == 0) { + // Empty results are triggered if a permission is requested while another request was already + // pending and can be safely ignored in this case. + return; + } else if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { + loadSample(); + } else { + Toast.makeText(getApplicationContext(), R.string.sample_list_load_error, Toast.LENGTH_LONG) + .show(); + finish(); + } + } + + private void loadSample() { + checkNotNull(uris); + + for (int i = 0; i < uris.length; i++) { + Uri uri = Uri.parse(uris[i]); + if (Util.maybeRequestReadExternalStoragePermission(this, uri)) { + return; + } + } + + SampleListLoader loaderTask = new SampleListLoader(); + loaderTask.execute(uris); + } + + private void onPlaylistGroups(final List groups, boolean sawError) { + if (sawError) { + Toast.makeText(getApplicationContext(), R.string.sample_list_load_error, Toast.LENGTH_LONG) + .show(); + } + group = groups.get(0); + + SharedPreferences preferences = getPreferences(MODE_PRIVATE); + int groupPosition = preferences.getInt(GROUP_POSITION_PREFERENCE_KEY, /* defValue= */ -1); + int childPosition = preferences.getInt(CHILD_POSITION_PREFERENCE_KEY, /* defValue= */ -1); + // Clear the group and child position if either are unset or if either are out of bounds. + if (groupPosition != -1 + && childPosition != -1 + && groupPosition < groups.size() + && childPosition < groups.get(groupPosition).playlists.size()) { + } + } + + @Override + public boolean onChildClick( + ExpandableListView parent, View view, int groupPosition, int childPosition, long id) { + // Save the selected item first to be able to restore it if the tested code crashes. + SharedPreferences.Editor prefEditor = getPreferences(MODE_PRIVATE).edit(); + prefEditor.putInt(GROUP_POSITION_PREFERENCE_KEY, groupPosition); + prefEditor.putInt(CHILD_POSITION_PREFERENCE_KEY, childPosition); + prefEditor.apply(); + + PlaylistHolder playlistHolder = (PlaylistHolder) view.getTag(); + Intent intent = new Intent(this, PlayerActivity.class); + intent.putExtra("Title",epg.getEPGData().getEvent(curProgramPosition,0).getTitle().toCharArray()); + intent.putExtra( + IntentUtil.PREFER_EXTENSION_DECODERS_EXTRA, + isNonNullAndChecked(preferExtensionDecodersMenuItem)); + IntentUtil.addToIntent(playlistHolder.mediaItems, intent); +// startActivity(intent); + this.childPosition = childPosition; + startActivityForResult(intent,PLAYER_REQUEST); + return true; + } + + private void onSampleDownloadButtonClicked(PlaylistHolder playlistHolder) { + int downloadUnsupportedStringId = getDownloadUnsupportedStringId(playlistHolder); + if (downloadUnsupportedStringId != 0) { + Toast.makeText(getApplicationContext(), downloadUnsupportedStringId, Toast.LENGTH_LONG) + .show(); + } else if (!notificationPermissionToastShown + && Util.SDK_INT >= 33 + && checkSelfPermission(Api33.getPostNotificationPermissionString()) + != PackageManager.PERMISSION_GRANTED) { + downloadMediaItemWaitingForNotificationPermission = playlistHolder.mediaItems.get(0); + requestPermissions( + new String[] {Api33.getPostNotificationPermissionString()}, + /* requestCode= */ POST_NOTIFICATION_PERMISSION_REQUEST_CODE); + } else { + toggleDownload(playlistHolder.mediaItems.get(0)); + } + } + + private void toggleDownload(MediaItem mediaItem) { + RenderersFactory renderersFactory = + DemoUtil.buildRenderersFactory( + /* context= */ this, isNonNullAndChecked(preferExtensionDecodersMenuItem)); + downloadTracker.toggleDownload(getSupportFragmentManager(), mediaItem, renderersFactory); + } + + private int getDownloadUnsupportedStringId(PlaylistHolder playlistHolder) { + if (playlistHolder.mediaItems.size() > 1) { + return R.string.download_playlist_unsupported; + } + MediaItem.LocalConfiguration localConfiguration = + checkNotNull(playlistHolder.mediaItems.get(0).localConfiguration); + if (localConfiguration.adsConfiguration != null) { + return R.string.download_ads_unsupported; + } + String scheme = localConfiguration.uri.getScheme(); + if (!("http".equals(scheme) || "https".equals(scheme))) { + return R.string.download_scheme_unsupported; + } + return 0; + } + + private static boolean isNonNullAndChecked(@Nullable MenuItem menuItem) { + // Temporary workaround for layouts that do not inflate the options menu. + return menuItem != null && menuItem.isChecked(); + } + + private final class SampleListLoader extends AsyncTask> { + + private boolean sawError; + + @Override + protected List doInBackground(String... uris) { + + List result = new ArrayList<>(); + try { + URL url = new URL("https://www.ask-meyer.ch/IPTV/Channels.json"); + InputStream inputStream = url.openStream(); + readPlaylistGroups(new JsonReader(new InputStreamReader(inputStream, "UTF-8")), result); + } catch (Exception e) { + Log.e(TAG, "Error loading sample list: ", e); + sawError = true; + } + return result; + } + + @Override + protected void onPostExecute(List result) { + playlists = result.get(0).playlists; + onPlaylistGroups(result, sawError); + } + + private void readPlaylistGroups(JsonReader reader, List groups) + throws IOException { + reader.beginArray(); + while (reader.hasNext()) { + readPlaylistGroup(reader, groups); + } + reader.endArray(); + } + + private void readPlaylistGroup(JsonReader reader, List groups) + throws IOException { + String groupName = ""; + ArrayList playlistHolders = new ArrayList<>(); + + reader.beginObject(); + while (reader.hasNext()) { + String name = reader.nextName(); + switch (name) { + case "name": + groupName = reader.nextString(); + break; + case "samples": + reader.beginArray(); + while (reader.hasNext()) { + playlistHolders.add(readEntry(reader, false)); + } + reader.endArray(); + break; + case "_comment": + reader.nextString(); // Ignore. + break; + default: + throw new IOException("Unsupported name: " + name, /* cause= */ null); + } + } + reader.endObject(); + + PlaylistGroup group = getGroup(groupName, groups); + group.playlists.addAll(playlistHolders); + } + + private PlaylistHolder readEntry(JsonReader reader, boolean insidePlaylist) throws IOException { + Uri uri = null; + String extension = null; + String title = null; + ArrayList children = null; + Uri subtitleUri = null; + String subtitleMimeType = null; + String subtitleLanguage = null; + UUID drmUuid = null; + String drmLicenseUri = null; + ImmutableMap drmLicenseRequestHeaders = ImmutableMap.of(); + boolean drmSessionForClearContent = false; + boolean drmMultiSession = false; + boolean drmForceDefaultLicenseUri = false; + MediaItem.ClippingConfiguration.Builder clippingConfiguration = + new ClippingConfiguration.Builder(); + + MediaItem.Builder mediaItem = new MediaItem.Builder(); + reader.beginObject(); + while (reader.hasNext()) { + String name = reader.nextName(); + switch (name) { + case "name": + title = reader.nextString(); + break; + case "logo": + reader.nextString(); + break; + case "uri": + uri = Uri.parse(reader.nextString()); + break; + case "extension": + extension = reader.nextString(); + break; + case "clip_start_position_ms": + clippingConfiguration.setStartPositionMs(reader.nextLong()); + break; + case "clip_end_position_ms": + clippingConfiguration.setEndPositionMs(reader.nextLong()); + break; + case "ad_tag_uri": + mediaItem.setAdsConfiguration( + new MediaItem.AdsConfiguration.Builder(Uri.parse(reader.nextString())).build()); + break; + case "drm_scheme": + drmUuid = Util.getDrmUuid(reader.nextString()); + break; + case "drm_license_uri": + case "drm_license_url": // For backward compatibility only. + drmLicenseUri = reader.nextString(); + break; + case "drm_key_request_properties": + Map requestHeaders = new HashMap<>(); + reader.beginObject(); + while (reader.hasNext()) { + requestHeaders.put(reader.nextName(), reader.nextString()); + } + reader.endObject(); + drmLicenseRequestHeaders = ImmutableMap.copyOf(requestHeaders); + break; + case "drm_session_for_clear_content": + drmSessionForClearContent = reader.nextBoolean(); + break; + case "drm_multi_session": + drmMultiSession = reader.nextBoolean(); + break; + case "drm_force_default_license_uri": + drmForceDefaultLicenseUri = reader.nextBoolean(); + break; + case "subtitle_uri": + subtitleUri = Uri.parse(reader.nextString()); + break; + case "subtitle_mime_type": + subtitleMimeType = reader.nextString(); + break; + case "subtitle_language": + subtitleLanguage = reader.nextString(); + break; + case "playlist": + checkState(!insidePlaylist, "Invalid nesting of playlists"); + children = new ArrayList<>(); + reader.beginArray(); + while (reader.hasNext()) { + children.add(readEntry(reader, /* insidePlaylist= */ true)); + } + reader.endArray(); + break; + default: + throw new IOException("Unsupported attribute name: " + name, /* cause= */ null); + } + } + reader.endObject(); + + if (children != null) { + List mediaItems = new ArrayList<>(); + for (int i = 0; i < children.size(); i++) { + mediaItems.addAll(children.get(i).mediaItems); + } + return new PlaylistHolder(title, mediaItems); + } else { + @Nullable + String adaptiveMimeType = + Util.getAdaptiveMimeTypeForContentType( + TextUtils.isEmpty(extension) + ? Util.inferContentType(uri) + : Util.inferContentTypeForExtension(extension)); + mediaItem + .setUri(uri) + .setMediaMetadata(new MediaMetadata.Builder().setTitle(title).build()) + .setMimeType(adaptiveMimeType) + .setClippingConfiguration(clippingConfiguration.build()); + if (drmUuid != null) { + mediaItem.setDrmConfiguration( + new MediaItem.DrmConfiguration.Builder(drmUuid) + .setLicenseUri(drmLicenseUri) + .setLicenseRequestHeaders(drmLicenseRequestHeaders) + .setForceSessionsForAudioAndVideoTracks(drmSessionForClearContent) + .setMultiSession(drmMultiSession) + .setForceDefaultLicenseUri(drmForceDefaultLicenseUri) + .build()); + } else { + checkState(drmLicenseUri == null, "drm_uuid is required if drm_license_uri is set."); + checkState( + drmLicenseRequestHeaders.isEmpty(), + "drm_uuid is required if drm_key_request_properties is set."); + checkState( + !drmSessionForClearContent, + "drm_uuid is required if drm_session_for_clear_content is set."); + checkState(!drmMultiSession, "drm_uuid is required if drm_multi_session is set."); + checkState( + !drmForceDefaultLicenseUri, + "drm_uuid is required if drm_force_default_license_uri is set."); + } + if (subtitleUri != null) { + MediaItem.SubtitleConfiguration subtitleConfiguration = + new MediaItem.SubtitleConfiguration.Builder(subtitleUri) + .setMimeType( + checkNotNull( + subtitleMimeType, + "subtitle_mime_type is required if subtitle_uri is set.")) + .setLanguage(subtitleLanguage) + .build(); + mediaItem.setSubtitleConfigurations(ImmutableList.of(subtitleConfiguration)); + } + return new PlaylistHolder(title, Collections.singletonList(mediaItem.build())); + } + } + + private PlaylistGroup getGroup(String groupName, List groups) { + for (int i = 0; i < groups.size(); i++) { + if (Objects.equal(groupName, groups.get(i).title)) { + return groups.get(i); + } + } + PlaylistGroup group = new PlaylistGroup(groupName); + groups.add(group); + return group; + } + } + + private static final class PlaylistHolder { + + public final String title; + public final List mediaItems; + + private PlaylistHolder(String title, List mediaItems) { + checkArgument(!mediaItems.isEmpty()); + this.title = title; + this.mediaItems = Collections.unmodifiableList(new ArrayList<>(mediaItems)); + } + } + + private static final class PlaylistGroup { + + public final String title; + public final List playlists; + + public PlaylistGroup(String title) { + this.title = title; + this.playlists = new ArrayList<>(); + } + } + + @RequiresApi(33) + private static class Api33 { + + @DoNotInline + public static String getPostNotificationPermissionString() { + return Manifest.permission.POST_NOTIFICATIONS; + } + } + + private void onSampleSelected(PlaylistHolder holder) { + Intent intent = new Intent(this, PlayerActivity.class); + intent.putExtra("Title",epg.getEPGData().getEvent(curChannelPosition,0).getTitle().toCharArray()); + intent.putExtra( + IntentUtil.PREFER_EXTENSION_DECODERS_EXTRA, + isNonNullAndChecked(preferExtensionDecodersMenuItem)); + IntentUtil.addToIntent(holder.mediaItems, intent); + startActivityForResult(intent,PLAYER_REQUEST); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + // Check which request we're responding to + if (requestCode == PLAYER_REQUEST) { + // Make sure the request was successful + if (resultCode == RESULT_OK) { + if (isExternalURL) { + Intent ret = getIntent(); + ret.putExtra("OFFSET", data.getIntExtra(CHANNEL_OFFSET, 0)); + setResult(RESULT_OK, ret); + finish(); + } else { + int size = group.playlists.size(); + childPosition = childPosition + data.getIntExtra(CHANNEL_OFFSET, 0); + if (childPosition >= size) + childPosition = 0; + else if (childPosition < 0) + childPosition = size - 1; + curChannelPosition = childPosition; + onSampleSelected(group.playlists.get(childPosition)); + } + } else { + if (isExternalURL) { + setResult(RESULT_CANCELED, null); + finish(); + } + } + } + } + + + + + public void setTvGuide(final List channels) { + epgData = new EPGData() { + public EPGChannel getChannel(int position) { + return new EPGChannel(channels.get(position).getLogo(), + channels.get(position).getName(), + channels.get(position).getId(), + channels.get(position).getStream(), + channels.get(position).getDrmScheme(), + channels.get(position).getDrmUri()); + } + public List getEvents(int channelPosition) { + List programs = channels.get(channelPosition).getPrograms(); + List epgList = Collections.synchronizedList(new ArrayList<>()); + for (Program program : programs) { + epgList.add(new EPGEvent(program.getStartAsString(), program.getStopAsString(), program.getName(), + program.getCategories(), program.getCountry(), program.getDate(), program.getDescription(), program.getDirector(), program.getWriter(), program.getActors())); + } + return epgList; + } + public EPGEvent getEvent(int channelPosition, int programPosition) { + Program program = channels.get(channelPosition).getPrograms().get(programPosition); + return new EPGEvent(program.getStartAsString(), program.getStopAsString(), program.getName(), + program.getCategories(), program.getCountry(), program.getDate(), program.getDescription(), program.getDirector(), program.getWriter(), program.getActors()); + } + public int getChannelCount() { return channels.size(); } + public boolean hasData() { return true; } + }; + epg.setEPGData(epgData); + epg.recalculateAndRedraw(false); + } + + public void onChannelClicked(int channelPosition, EPGChannel epgChannel) { + curChannelPosition=channelPosition; + childPosition=channelPosition; + Intent intent = new Intent(this, PlayerActivity.class); + intent.putExtra("Title",epg.getEPGData().getEvent(curChannelPosition,0).getTitle().toCharArray()); + intent.putExtra( + IntentUtil.PREFER_EXTENSION_DECODERS_EXTRA, + isNonNullAndChecked(preferExtensionDecodersMenuItem)); + IntentUtil.addToIntent(playlists.get(channelPosition).mediaItems, intent); + startActivityForResult(intent,PLAYER_REQUEST); + } + + public void onEventClicked(int channelPosition, int programPosition, EPGEvent epgEvent) { + final boolean isCurrent = epgEvent.isCurrent(); + final Dialog dialog = new Dialog(this); // Context, this, etc. + curChannelPosition=channelPosition; + curProgramPosition=programPosition; + dialog.setContentView(R.layout.dialog_description); + dialog.getWindow().setFeatureInt(Window.FEATURE_CUSTOM_TITLE, R.id.dialog_costumtitle); + Button bt_no = (Button)dialog.findViewById(R.id.dialog_cancel); + final LinearLayout border_no = (LinearLayout)dialog.findViewById(R.id.dialog_cancel_select); + bt_no.setOnFocusChangeListener(new View.OnFocusChangeListener() { + @Override + public void onFocusChange(View view, boolean hasFocus) { + if (hasFocus) { + border_no.setBackgroundColor(Color.parseColor("#76d8e2")); + } else { + border_no.setBackgroundColor(Color.parseColor("#cccccc")); + } + } + }); + Button bt_yes = (Button) dialog.findViewById(R.id.dialog_ok); + final LinearLayout border_yes = (LinearLayout)dialog.findViewById(R.id.dialog_ok_select); + bt_yes.setOnFocusChangeListener(new View.OnFocusChangeListener() { + @Override + public void onFocusChange(View view, boolean hasFocus) { + if (hasFocus) { + border_yes.setBackgroundColor(Color.parseColor("#76d8e2")); + } else { + border_yes.setBackgroundColor(Color.parseColor("#787878")); + } + } + }); + dialog.setOnKeyListener(new DialogInterface.OnKeyListener(){ + @Override + public boolean onKey(DialogInterface dialog, int keyCode, KeyEvent event) + { + if (event.getAction() != KeyEvent.ACTION_DOWN) + return false; + if(keyCode == KeyEvent.KEYCODE_ENTER) + { + if( ((ColorDrawable)border_yes.getBackground()).getColor() == Color.parseColor("#76d8e2")) { + if (isCurrent) + onChannelClicked(curChannelPosition, epg.getEPGData().getChannel(curChannelPosition)); + else { + EPGEvent epgEvent = epg.getEPGData().getEvents(curChannelPosition).get(curProgramPosition); + new SampleChooserActivity.doRecord().execute("https://www.ask-meyer.ch/cgi-bin/record.cgi?" + + "url=" + Uri.encode(epg.getEPGData().getChannel(curChannelPosition).getStreamURL()) + + "&prg=" + Uri.encode(epg.getEPGData().getChannel(curChannelPosition).getName() + + "."+epgEvent.getStartAsString().substring(0,8) + + "."+epgEvent.getStartAsString().substring(8,12) + + "."+epgEvent.getEndAsString().substring(8,12) + + ".mp4") + + "&start=" + Uri.encode(epgEvent.getStartAsString()) + + "&stop=" + Uri.encode(epgEvent.getEndAsString())); + } + } + dialog.dismiss(); + return true; + } + return false; + } + }); + TextView title = (TextView)dialog.findViewById(R.id.dialog_costumtitle); + title.setText(epgEvent.getTitle()); + TextView date = (TextView)dialog.findViewById(R.id.dialog_date); + date.setText(epgEvent.getDate()); + TextView country = (TextView)dialog.findViewById(R.id.dialog_country); + country.setText(epgEvent.getCountry()); + TextView categories = (TextView)dialog.findViewById(R.id.dialog_categories); + if (epgEvent.getCategories()!=null) { + String res = new String(); + for (String s : epgEvent.getCategories()) { + res += s + ", "; + } + categories.setText(res.substring(0,res.length()-2)); + dialog.findViewById(R.id.dialog_categories_layout).setVisibility(View.VISIBLE); + }else + dialog.findViewById(R.id.dialog_categories_layout).setVisibility(View.GONE); + TextView director = (TextView)dialog.findViewById(R.id.dialog_director); + if (epgEvent.getDirector()!=null) { + director.setText(epgEvent.getDirector()); + dialog.findViewById(R.id.dialog_director_layout).setVisibility(View.VISIBLE); + }else + dialog.findViewById(R.id.dialog_director_layout).setVisibility(View.GONE); + TextView actors = (TextView)dialog.findViewById(R.id.dialog_actors); + if (epgEvent.getActors()!=null) { + String res = new String(); + for (String s : epgEvent.getActors()) { + res += s + ", "; + } + actors.setText(res.substring(0,res.length()-2)); + dialog.findViewById(R.id.dialog_actors_layout).setVisibility(View.VISIBLE); + }else + dialog.findViewById(R.id.dialog_actors_layout).setVisibility(View.GONE); + TextView description = (TextView)dialog.findViewById(R.id.dialog_info); + description.setText(epgEvent.getDescription()); + if (isCurrent) + bt_yes.setText("Play"); + else { + bt_yes.setText("Record"); +// bt_yes.setEnabled(false); + } + + bt_yes.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + dialog.dismiss(); + if (isCurrent) + onChannelClicked(curChannelPosition, epg.getEPGData().getChannel(curChannelPosition)); + else { + EPGEvent epgEvent = epg.getEPGData().getEvents(curChannelPosition).get(curProgramPosition); + new SampleChooserActivity.doRecord().execute("https://www.ask-meyer.ch/cgi-bin/record.cgi?" + + "url=" + Uri.encode(epg.getEPGData().getChannel(curChannelPosition).getStreamURL()) + + "&prg=" + Uri.encode(epg.getEPGData().getChannel(curChannelPosition).getName() + + "."+epgEvent.getStartAsString().substring(0,8) + + "."+epgEvent.getStartAsString().substring(8,12) + + "."+epgEvent.getEndAsString().substring(8,12) + + ".mp4") + + "&start=" + Uri.encode(epgEvent.getStartAsString()) + + "&stop=" + Uri.encode(epgEvent.getEndAsString())); + } + } + }); + bt_no.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + dialog.dismiss(); + } + }); + + dialog.show(); + } + + private class Channels extends AsyncTask> { + @Override + protected List doInBackground(Void... params) { + List list = new ArrayList(); + try { + URL url = new URL("https://www.ask-meyer.ch/IPTV/Channels.json"); + JsonReader reader = new JsonReader(new InputStreamReader(url.openStream(), "UTF-8")); + reader.beginArray(); + while (reader.hasNext()) { + reader.beginObject(); + while (reader.hasNext()) { + String name = reader.nextName(); + switch (name) { + case "name": + name = reader.nextString(); + break; + case "samples": + + reader.beginArray(); + while (reader.hasNext()) { + reader.beginObject(); + String[] bundle = new String[5]; + while (reader.hasNext()) { + name = reader.nextName(); + switch (name) { + case "name": + bundle[0] = reader.nextString(); + break; + case "logo": + bundle[1] = reader.nextString(); + break; + case "uri": + bundle[2] = reader.nextString(); + break; + case "drm_scheme": + bundle[3] = reader.nextString(); + break; + case "drm_license_uri": + bundle[4] = reader.nextString(); + break; + default: + throw new IOException("Unsupported name: " + name, /* cause= */ null); + } + } + list.add(bundle); + reader.endObject(); + } + reader.endArray(); + + break; + default: + throw new IOException("Unsupported name: " + name, /* cause= */ null); + } + } + reader.endObject(); + } + reader.endArray(); + } catch (Exception e) { } + return list; + } + + @Override + protected void onPostExecute(List list) { + String[][] channels = new String[list.size()][5]; + channels = list.toArray(channels); + ContentTask contentTask = new ContentTask(SampleChooserActivity.this); + contentTask.execute(channels); + curChannelPosition = -1; + curProgramPosition = -1; + } + } + + private class doRecord extends AsyncTask { + @Override + protected String doInBackground(String... urls) { + try { + URL url = new URL(urls[0]); + BufferedReader rd = new BufferedReader(new InputStreamReader(url.openStream())); + String content = "", line; + while ((line = rd.readLine()) != null) { + content += line + "\n"; + } + return content; + } catch (IOException exception) {} + return null; + } + @Override + protected void onPostExecute(String content) { + Toast.makeText(SampleChooserActivity.this, content, Toast.LENGTH_SHORT).show(); + } + } + + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + if (hasFocus) { + hideNavigationBar(); + } + } + + private void hideNavigationBar() { + View decorView = getWindow().getDecorView(); + int uiOptions = View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_FULLSCREEN + | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY; + decorView.setSystemUiVisibility(uiOptions); + } +} diff --git a/app/src/main/java/com/example/exoplayertv/StartActivityOnBootReceiver.java b/app/src/main/java/com/example/exoplayertv/StartActivityOnBootReceiver.java new file mode 100644 index 0000000..e59a653 --- /dev/null +++ b/app/src/main/java/com/example/exoplayertv/StartActivityOnBootReceiver.java @@ -0,0 +1,17 @@ +package com.example.exoplayertv; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import com.example.exoplayertv.DemoDownloadService; + +public class StartActivityOnBootReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + if(Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) { + Intent i = new Intent(context, SampleChooserActivity.class); + i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(i); + } + } +} diff --git a/app/src/main/java/com/example/exoplayertv/TrackSelectionDialog.java b/app/src/main/java/com/example/exoplayertv/TrackSelectionDialog.java new file mode 100644 index 0000000..c197f1f --- /dev/null +++ b/app/src/main/java/com/example/exoplayertv/TrackSelectionDialog.java @@ -0,0 +1,359 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.example.exoplayertv; + +import android.app.Dialog; +import android.content.DialogInterface; +import android.content.res.Resources; +import android.os.Bundle; +import android.util.SparseArray; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatDialog; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentPagerAdapter; +import androidx.viewpager.widget.ViewPager; +import androidx.media3.common.C; +import androidx.media3.common.Player; +import androidx.media3.common.Tracks; +import androidx.media3.common.TrackGroup; +import androidx.media3.common.TrackSelectionOverride; +import androidx.media3.common.TrackSelectionParameters; +import androidx.media3.ui.TrackSelectionView; +import com.google.android.material.tabs.TabLayout; +import com.google.common.collect.ImmutableList; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** Dialog to select tracks. */ +public final class TrackSelectionDialog extends DialogFragment { + + /** Called when tracks are selected. */ + public interface TrackSelectionListener { + + /** + * Called when tracks are selected. + * + * @param trackSelectionParameters A {@link TrackSelectionParameters} representing the selected + * tracks. Any manual selections are defined by {@link + * TrackSelectionParameters#disabledTrackTypes} and {@link + * TrackSelectionParameters#overrides}. + */ + void onTracksSelected(TrackSelectionParameters trackSelectionParameters); + } + + public static final ImmutableList SUPPORTED_TRACK_TYPES = + ImmutableList.of(C.TRACK_TYPE_VIDEO, C.TRACK_TYPE_AUDIO, C.TRACK_TYPE_TEXT); + + private final SparseArray tabFragments; + private final ArrayList tabTrackTypes; + + private int titleId; + private DialogInterface.OnClickListener onClickListener; + private DialogInterface.OnDismissListener onDismissListener; + + /** + * Returns whether a track selection dialog will have content to display if initialized with the + * specified {@link Player}. + */ + public static boolean willHaveContent(Player player) { + return willHaveContent(player.getCurrentTracks()); + } + + /** + * Returns whether a track selection dialog will have content to display if initialized with the + * specified {@link Tracks}. + */ + public static boolean willHaveContent(Tracks tracks) { + for (Tracks.Group trackGroup : tracks.getGroups()) { + if (SUPPORTED_TRACK_TYPES.contains(trackGroup.getType())) { + return true; + } + } + return false; + } + + /** + * Creates a dialog for a given {@link Player}, whose parameters will be automatically updated + * when tracks are selected. + * + * @param player The {@link Player}. + * @param onDismissListener A {@link DialogInterface.OnDismissListener} to call when the dialog is + * dismissed. + */ + public static TrackSelectionDialog createForPlayer( + Player player, DialogInterface.OnDismissListener onDismissListener) { + return createForTracksAndParameters( + R.string.track_selection_title, + player.getCurrentTracks(), + player.getTrackSelectionParameters(), + /* allowAdaptiveSelections= */ true, + /* allowMultipleOverrides= */ false, + player::setTrackSelectionParameters, + onDismissListener); + } + + /** + * Creates a dialog for given {@link Tracks} and {@link TrackSelectionParameters}. + * + * @param titleId The resource id of the dialog title. + * @param tracks The {@link Tracks} describing the tracks to display. + * @param trackSelectionParameters The initial {@link TrackSelectionParameters}. + * @param allowAdaptiveSelections Whether adaptive selections (consisting of more than one track) + * can be made. + * @param allowMultipleOverrides Whether tracks from multiple track groups can be selected. + * @param trackSelectionListener Called when tracks are selected. + * @param onDismissListener {@link DialogInterface.OnDismissListener} called when the dialog is + * dismissed. + */ + public static TrackSelectionDialog createForTracksAndParameters( + int titleId, + Tracks tracks, + TrackSelectionParameters trackSelectionParameters, + boolean allowAdaptiveSelections, + boolean allowMultipleOverrides, + TrackSelectionListener trackSelectionListener, + DialogInterface.OnDismissListener onDismissListener) { + TrackSelectionDialog trackSelectionDialog = new TrackSelectionDialog(); + trackSelectionDialog.init( + tracks, + trackSelectionParameters, + titleId, + allowAdaptiveSelections, + allowMultipleOverrides, + /* onClickListener= */ (dialog, which) -> { + TrackSelectionParameters.Builder builder = trackSelectionParameters.buildUpon(); + for (int i = 0; i < SUPPORTED_TRACK_TYPES.size(); i++) { + int trackType = SUPPORTED_TRACK_TYPES.get(i); + builder.setTrackTypeDisabled(trackType, trackSelectionDialog.getIsDisabled(trackType)); + builder.clearOverridesOfType(trackType); + Map overrides = + trackSelectionDialog.getOverrides(trackType); + for (TrackSelectionOverride override : overrides.values()) { + builder.addOverride(override); + } + } + trackSelectionListener.onTracksSelected(builder.build()); + }, + onDismissListener); + return trackSelectionDialog; + } + + public TrackSelectionDialog() { + tabFragments = new SparseArray<>(); + tabTrackTypes = new ArrayList<>(); + // Retain instance across activity re-creation to prevent losing access to init data. + setRetainInstance(true); + } + + private void init( + Tracks tracks, + TrackSelectionParameters trackSelectionParameters, + int titleId, + boolean allowAdaptiveSelections, + boolean allowMultipleOverrides, + DialogInterface.OnClickListener onClickListener, + DialogInterface.OnDismissListener onDismissListener) { + this.titleId = titleId; + this.onClickListener = onClickListener; + this.onDismissListener = onDismissListener; + + for (int i = 0; i < SUPPORTED_TRACK_TYPES.size(); i++) { + @C.TrackType int trackType = SUPPORTED_TRACK_TYPES.get(i); + ArrayList trackGroups = new ArrayList<>(); + for (Tracks.Group trackGroup : tracks.getGroups()) { + if (trackGroup.getType() == trackType) { + trackGroups.add(trackGroup); + } + } + if (!trackGroups.isEmpty()) { + TrackSelectionViewFragment tabFragment = new TrackSelectionViewFragment(); + tabFragment.init( + trackGroups, + trackSelectionParameters.disabledTrackTypes.contains(trackType), + trackSelectionParameters.overrides, + allowAdaptiveSelections, + allowMultipleOverrides); + tabFragments.put(trackType, tabFragment); + tabTrackTypes.add(trackType); + } + } + } + + /** + * Returns whether the disabled option is selected for the specified track type. + * + * @param trackType The track type. + * @return Whether the disabled option is selected for the track type. + */ + public boolean getIsDisabled(int trackType) { + TrackSelectionViewFragment trackView = tabFragments.get(trackType); + return trackView != null && trackView.isDisabled; + } + + /** + * Returns the selected track overrides for the specified track type. + * + * @param trackType The track type. + * @return The track overrides for the track type. + */ + public Map getOverrides(int trackType) { + TrackSelectionViewFragment trackView = tabFragments.get(trackType); + return trackView == null ? Collections.emptyMap() : trackView.overrides; + } + + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + // We need to own the view to let tab layout work correctly on all API levels. We can't use + // AlertDialog because it owns the view itself, so we use AppCompatDialog instead, themed using + // the AlertDialog theme overlay with force-enabled title. + AppCompatDialog dialog = + new AppCompatDialog(getActivity(), R.style.TrackSelectionDialogThemeOverlay); + dialog.setTitle(titleId); + return dialog; + } + + @Override + public void onDismiss(DialogInterface dialog) { + super.onDismiss(dialog); + onDismissListener.onDismiss(dialog); + } + + @Override + public View onCreateView( + LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View dialogView = inflater.inflate(R.layout.track_selection_dialog, container, false); + TabLayout tabLayout = dialogView.findViewById(R.id.track_selection_dialog_tab_layout); + ViewPager viewPager = dialogView.findViewById(R.id.track_selection_dialog_view_pager); + Button cancelButton = dialogView.findViewById(R.id.track_selection_dialog_cancel_button); + Button okButton = dialogView.findViewById(R.id.track_selection_dialog_ok_button); + viewPager.setAdapter(new FragmentAdapter(getChildFragmentManager())); + tabLayout.setupWithViewPager(viewPager); + tabLayout.setVisibility(tabFragments.size() > 1 ? View.VISIBLE : View.GONE); + cancelButton.setOnClickListener(view -> dismiss()); + okButton.setOnClickListener( + view -> { + onClickListener.onClick(getDialog(), DialogInterface.BUTTON_POSITIVE); + dismiss(); + }); + return dialogView; + } + + private static String getTrackTypeString(Resources resources, @C.TrackType int trackType) { + switch (trackType) { + case C.TRACK_TYPE_VIDEO: + return resources.getString(com.google.android.exoplayer2.ui.R.string.exo_track_selection_title_video); + case C.TRACK_TYPE_AUDIO: + return resources.getString(com.google.android.exoplayer2.ui.R.string.exo_track_selection_title_audio); + case C.TRACK_TYPE_TEXT: + return resources.getString(com.google.android.exoplayer2.ui.R.string.exo_track_selection_title_text); + default: + throw new IllegalArgumentException(); + } + } + + private final class FragmentAdapter extends FragmentPagerAdapter { + + public FragmentAdapter(FragmentManager fragmentManager) { + super(fragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT); + } + + @Override + public Fragment getItem(int position) { + return tabFragments.get(tabTrackTypes.get(position)); + } + + @Override + public int getCount() { + return tabTrackTypes.size(); + } + + @Override + public CharSequence getPageTitle(int position) { + return getTrackTypeString(getResources(), tabTrackTypes.get(position)); + } + } + + /** Fragment to show a track selection in tab of the track selection dialog. */ + public static final class TrackSelectionViewFragment extends Fragment + implements TrackSelectionView.TrackSelectionListener { + + private List trackGroups; + private boolean allowAdaptiveSelections; + private boolean allowMultipleOverrides; + + /* package */ boolean isDisabled; + /* package */ Map overrides; + + public TrackSelectionViewFragment() { + // Retain instance across activity re-creation to prevent losing access to init data. + setRetainInstance(true); + } + + public void init( + List trackGroups, + boolean isDisabled, + Map overrides, + boolean allowAdaptiveSelections, + boolean allowMultipleOverrides) { + this.trackGroups = trackGroups; + this.isDisabled = isDisabled; + this.allowAdaptiveSelections = allowAdaptiveSelections; + this.allowMultipleOverrides = allowMultipleOverrides; + // TrackSelectionView does this filtering internally, but we need to do it here as well to + // handle the case where the TrackSelectionView is never created. + this.overrides = + new HashMap<>( + TrackSelectionView.filterOverrides(overrides, trackGroups, allowMultipleOverrides)); + } + + @Override + public View onCreateView( + LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + View rootView = + inflater.inflate( + com.google.android.exoplayer2.ui.R.layout.exo_track_selection_dialog, container, /* attachToRoot= */ false); + TrackSelectionView trackSelectionView = rootView.findViewById(com.google.android.exoplayer2.ui.R.id.exo_track_selection_view); + trackSelectionView.setShowDisableOption(true); + trackSelectionView.setAllowMultipleOverrides(allowMultipleOverrides); + trackSelectionView.setAllowAdaptiveSelections(allowAdaptiveSelections); + trackSelectionView.init( + trackGroups, + isDisabled, + overrides, + /* trackFormatComparator= */ null, + /* listener= */ this); + return rootView; + } + + @Override + public void onTrackSelectionChanged( + boolean isDisabled, Map overrides) { + this.isDisabled = isDisabled; + this.overrides = overrides; + } + } +} diff --git a/app/src/main/java/com/example/exoplayertv/package-info.java b/app/src/main/java/com/example/exoplayertv/package-info.java new file mode 100644 index 0000000..5d21a60 --- /dev/null +++ b/app/src/main/java/com/example/exoplayertv/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package com.example.exoplayertv; + +import androidx.media3.common.util.NonNullApi; diff --git a/app/src/main/java/com/example/exoplayertv/tvepg/epg/EPG.java b/app/src/main/java/com/example/exoplayertv/tvepg/epg/EPG.java new file mode 100644 index 0000000..54e477a --- /dev/null +++ b/app/src/main/java/com/example/exoplayertv/tvepg/epg/EPG.java @@ -0,0 +1,816 @@ +package com.example.exoplayertv.tvepg.epg; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.GestureDetector; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.ViewGroup; +import android.widget.Scroller; + +import com.example.exoplayertv.R; +import com.squareup.picasso.Picasso; +import com.squareup.picasso.Target; + +import org.joda.time.LocalDateTime; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.example.exoplayertv.tvepg.epg.domain.EPGEvent; +import com.example.exoplayertv.tvepg.epg.misc.EPGUtil; + +/** + * Classic EPG, electronic program guide, that scrolls both horizontal, vertical and diagonal. + * It utilize onDraw() to draw the graphic on screen. So there are some private helper methods calculating positions etc. + * Listed on Y-axis are channels and X-axis are programs/events. Data is added to EPG by using setEPGData() + * and pass in an EPGData implementation. A click listener can be added using setEPGClickListener(). + * Created by Kristoffer, http://kmdev.se + */ +public class EPG extends ViewGroup { + + public final String TAG = getClass().getSimpleName(); + public static final int DAYS_BACK_MILLIS = 3 * 24 * 60 * 60 * 1000; // 3 days + public static final int DAYS_FORWARD_MILLIS = 3 * 24 * 60 * 60 * 1000; // 3 days + public static final int HOURS_IN_VIEWPORT_MILLIS = 2 * 60 * 60 * 1000; // 2 hours + public static final int TIME_LABEL_SPACING_MILLIS = 30 * 60 * 1000; // 30 minutes + + private final Rect mClipRect; + private final Rect mDrawingRect; + private final Rect mMeasuringRect; + private final Paint mPaint; + private final Scroller mScroller; + private final GestureDetector mGestureDetector; + + private final int mChannelLayoutMargin; + private final int mChannelLayoutPadding; + private final int mChannelLayoutHeight; + private final int mChannelLayoutWidth; + private final int mChannelLayoutBackground; + private final int mEventLayoutBackground; + private final int mEventLayoutBackgroundCurrent; + private final int mEventLayoutBackgroundSelected; + private final int mEventLayoutBackgroundCurrentSelected; + private final int mEventLayoutTextColor; + private final int mEventLayoutTextSize; + private final int mTimeBarLineWidth; + private final int mTimeBarLineColor; + private final int mTimeBarHeight; + private final int mTimeBarTextSize; + + private final int mResetButtonSize; + private final int mResetButtonMargin; + private final Bitmap mResetButtonIcon; + + private final int mEPGBackground; + private final Map mChannelImageCache; + private final Map mChannelImageTargetCache; + + private EPGClickListener mClickListener; + private int mMaxHorizontalScroll; + private int mMaxVerticalScroll; + private long mMillisPerPixel; + private long mTimeOffset; + private long mTimeLowerBoundary; + private long mTimeUpperBoundary; + + private EPGData epgData = null; + + private int channel = 1; + private long playtime = -1; + private long timer = 0; + private static final int duration = 200; + + public EPG(Context context) { + this(context, null); + } + + public EPG(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public EPG(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + setWillNotDraw(false); + + resetBoundaries(); + + mDrawingRect = new Rect(); + mClipRect = new Rect(); + mMeasuringRect = new Rect(); + mPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + mGestureDetector = new GestureDetector(context, new OnGestureListener()); + mChannelImageCache = Collections.synchronizedMap(new HashMap()); + mChannelImageTargetCache = Collections.synchronizedMap(new HashMap()); + + // Adding some friction that makes the epg less flappy. + mScroller = new Scroller(context); + mScroller.setFriction(0.2f); + + mEPGBackground = getResources().getColor(R.color.epg_background); + + mChannelLayoutMargin = getResources().getDimensionPixelSize(R.dimen.epg_channel_layout_margin); + mChannelLayoutPadding = getResources().getDimensionPixelSize(R.dimen.epg_channel_layout_padding); + mChannelLayoutHeight = getResources().getDimensionPixelSize(R.dimen.epg_channel_layout_height); + mChannelLayoutWidth = getResources().getDimensionPixelSize(R.dimen.epg_channel_layout_width); + mChannelLayoutBackground = getResources().getColor(R.color.epg_channel_layout_background); + + mEventLayoutBackground = getResources().getColor(R.color.epg_event_layout_background); + mEventLayoutBackgroundCurrent = getResources().getColor(R.color.epg_event_layout_background_current); + mEventLayoutBackgroundSelected = getResources().getColor(R.color.epg_event_layout_background_selected); + mEventLayoutBackgroundCurrentSelected = getResources().getColor(R.color.epg_event_layout_background_current_selected); + mEventLayoutTextColor = getResources().getColor(R.color.epg_event_layout_text); + mEventLayoutTextSize = getResources().getDimensionPixelSize(R.dimen.epg_event_layout_text); + + mTimeBarHeight = getResources().getDimensionPixelSize(R.dimen.epg_time_bar_height); + mTimeBarTextSize = getResources().getDimensionPixelSize(R.dimen.epg_time_bar_text); + mTimeBarLineWidth = getResources().getDimensionPixelSize(R.dimen.epg_time_bar_line_width); + mTimeBarLineColor = getResources().getColor(R.color.epg_time_bar); + + mResetButtonSize = getResources().getDimensionPixelSize(R.dimen.epg_reset_button_size); + mResetButtonMargin = getResources().getDimensionPixelSize(R.dimen.epg_reset_button_margin); + + BitmapFactory.Options options = new BitmapFactory.Options(); + options.outWidth = mResetButtonSize; + options.outHeight = mResetButtonSize; + mResetButtonIcon = BitmapFactory.decodeResource(getResources(), R.drawable.reset, options); + + setFocusable(true); + setFocusableInTouchMode(true); + requestFocus(); + channel=0; + playtime = System.currentTimeMillis(); + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + long delta = 0; + if ( event.getEventTime() < timer || + event.getAction()==KeyEvent.ACTION_UP) + return false; + timer = event.getEventTime()+duration+200; + List events = epgData.getEvents(channel); + switch (event.getKeyCode()) { + case KeyEvent.KEYCODE_ENTER: + for (int program = 0; program < events.size(); program++) { + EPGEvent epgEvnt = events.get(program); + if (playtime >= epgEvnt.getStart() && playtime < epgEvnt.getEnd()) { + mClickListener.onEventClicked(channel, program, epgData.getEvent(channel, program)); + return true; + } + } + return false; + case KeyEvent.KEYCODE_DPAD_DOWN: + channel++; + if (channel >= epgData.getChannelCount()) + channel = epgData.getChannelCount() - 1; + events = epgData.getEvents(channel); + if (playtime < events.get(0).getStart()) + playtime = events.get(0).getStart(); + if (getScrollY() != mMaxVerticalScroll && channel >= getLastVisibleChannelPosition() - 2) + delta = mChannelLayoutHeight + mChannelLayoutMargin; + if (getScrollY() + delta > mMaxVerticalScroll) + delta = mMaxVerticalScroll - getScrollY(); + mScroller.startScroll(getScrollX(), getScrollY(), + getXFrom(playtime - (HOURS_IN_VIEWPORT_MILLIS / 2)) - getScrollX(), + (int) delta, true ? duration : 0); + redraw(); + return true; + case KeyEvent.KEYCODE_DPAD_UP: + channel--; + if (channel < 0) + channel = 0; + events = epgData.getEvents(channel); + if (playtime < events.get(0).getStart()) + playtime = events.get(0).getStart(); + if (getScrollY() != 0 && channel <= getFirstVisibleChannelPosition()) + delta = -mChannelLayoutHeight - mChannelLayoutMargin; + if (getScrollY() + delta < 0) + delta = -getScrollY(); + mScroller.startScroll(getScrollX(), getScrollY(), + getXFrom(playtime - (HOURS_IN_VIEWPORT_MILLIS / 2)) - getScrollX(), + (int) delta, true ? duration : 0); + redraw(); + return true; + case KeyEvent.KEYCODE_DPAD_LEFT: + for (int program = 0; program < events.size(); program++) { + EPGEvent epgEvnt = events.get(program); + if (playtime > epgEvnt.getStart() && playtime <= epgEvnt.getEnd()) { + delta = epgEvnt.getStart() - epgEvnt.getEnd(); + playtime = epgEvnt.getStart(); + delta = delta / calculateMillisPerPixel(); + if (getScrollX() + delta < 0) + delta = 0 - getScrollX(); + mScroller.startScroll(getScrollX(), getScrollY(), + (int) delta, 0, true ? duration : 0); + redraw(); + return true; + } + } + return true; + case KeyEvent.KEYCODE_DPAD_RIGHT: + for (int program = 0; program < events.size(); program++) { + EPGEvent epgEvnt = events.get(program); + if (playtime >= epgEvnt.getStart() && playtime < epgEvnt.getEnd()) { + delta = epgEvnt.getEnd() - epgEvnt.getStart(); + playtime = epgEvnt.getEnd(); + delta = delta / calculateMillisPerPixel(); + if (getScrollX() + delta > mMaxHorizontalScroll) + delta = mMaxHorizontalScroll - getScrollX(); + mScroller.startScroll(getScrollX(), getScrollY(), + (int) delta, 0, true ? duration : 0); + redraw(); + return true; + } + } + } + return false; + } + + + public EPGData getEPGData() { return epgData; } + + + @Override + protected void onDraw(Canvas canvas) { + + if (epgData != null && epgData.hasData()) { + mTimeLowerBoundary = getTimeFrom(getScrollX()); + mTimeUpperBoundary = getTimeFrom(getScrollX() + getWidth()); + + Rect drawingRect = mDrawingRect; + drawingRect.left = getScrollX(); + drawingRect.top = getScrollY(); + drawingRect.right = drawingRect.left + getWidth(); + drawingRect.bottom = drawingRect.top + getHeight(); + + drawChannelListItems(canvas, drawingRect); + drawEvents(canvas, drawingRect); + drawTimebar(canvas, drawingRect); + drawTimeLine(canvas, drawingRect); + drawResetButton(canvas, drawingRect); + + // If scroller is scrolling/animating do scroll. This applies when doing a fling. + if (mScroller.computeScrollOffset()) { + scrollTo(mScroller.getCurrX(), mScroller.getCurrY()); + } + } + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + recalculateAndRedraw(false); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + return mGestureDetector.onTouchEvent(event); + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + } + + private void drawResetButton(Canvas canvas, Rect drawingRect) { + // Show button when scrolled 1/3 of screen width from current time + final long threshold = getWidth() / 3; + if (Math.abs(getXPositionStart() - getScrollX()) > threshold) { + drawingRect = calculateResetButtonHitArea(); + mPaint.setColor(mTimeBarLineColor); + canvas.drawCircle(drawingRect.right - (mResetButtonSize / 2), + drawingRect.bottom - (mResetButtonSize / 2), + Math.min(drawingRect.width(), drawingRect.height()) / 2, + mPaint); + + drawingRect.left += mResetButtonMargin; + drawingRect.right -= mResetButtonMargin; + drawingRect.top += mResetButtonMargin; + drawingRect.bottom -= mResetButtonMargin; + canvas.drawBitmap(mResetButtonIcon, null, drawingRect, mPaint); + } + } + + private void drawTimebarBottomStroke(Canvas canvas, Rect drawingRect) { + drawingRect.left = getScrollX(); + drawingRect.top = getScrollY() + mTimeBarHeight; + drawingRect.right = drawingRect.left + getWidth(); + drawingRect.bottom = drawingRect.top + mChannelLayoutMargin; + + // Bottom stroke + mPaint.setColor(mEPGBackground); + canvas.drawRect(drawingRect, mPaint); + } + + private void drawTimebar(Canvas canvas, Rect drawingRect) { + drawingRect.left = getScrollX() + mChannelLayoutWidth + mChannelLayoutMargin; + drawingRect.top = getScrollY(); + drawingRect.right = drawingRect.left + getWidth(); + drawingRect.bottom = drawingRect.top + mTimeBarHeight; + + mClipRect.left = getScrollX() + mChannelLayoutWidth + mChannelLayoutMargin; + mClipRect.top = getScrollY(); + mClipRect.right = getScrollX() + getWidth(); + mClipRect.bottom = mClipRect.top + mTimeBarHeight; + + canvas.save(); + canvas.clipRect(mClipRect); + + // Background + mPaint.setColor(mChannelLayoutBackground); + canvas.drawRect(drawingRect, mPaint); + + // Time stamps + mPaint.setColor(mEventLayoutTextColor); + mPaint.setTextSize(mTimeBarTextSize); + + for (int i = 0; i < HOURS_IN_VIEWPORT_MILLIS / TIME_LABEL_SPACING_MILLIS; i++) { + // Get time and round to nearest half hour + final long time = TIME_LABEL_SPACING_MILLIS * + (((mTimeLowerBoundary + (TIME_LABEL_SPACING_MILLIS * i)) + + (TIME_LABEL_SPACING_MILLIS / 2)) / TIME_LABEL_SPACING_MILLIS); + + canvas.drawText(EPGUtil.getShortTime(time), + getXFrom(time), + drawingRect.top + (((drawingRect.bottom - drawingRect.top) / 2) + (mTimeBarTextSize / 2)), mPaint); + } + + canvas.restore(); + + drawTimebarDayIndicator(canvas, drawingRect); + drawTimebarBottomStroke(canvas, drawingRect); + } + + private void drawTimebarDayIndicator(Canvas canvas, Rect drawingRect) { + drawingRect.left = getScrollX(); + drawingRect.top = getScrollY(); + drawingRect.right = drawingRect.left + mChannelLayoutWidth; + drawingRect.bottom = drawingRect.top + mTimeBarHeight; + + // Background + mPaint.setColor(mChannelLayoutBackground); + canvas.drawRect(drawingRect, mPaint); + + // Text + mPaint.setColor(mEventLayoutTextColor); + mPaint.setTextSize(mTimeBarTextSize); + mPaint.setTextAlign(Paint.Align.CENTER); + canvas.drawText(EPGUtil.getWeekdayName(mTimeLowerBoundary), + drawingRect.left + ((drawingRect.right - drawingRect.left) / 2), + drawingRect.top + (((drawingRect.bottom - drawingRect.top) / 2) + (mTimeBarTextSize / 2)), mPaint); + + mPaint.setTextAlign(Paint.Align.LEFT); + } + + private void drawTimeLine(Canvas canvas, Rect drawingRect) { + long now = System.currentTimeMillis(); + + if (shouldDrawTimeLine(now)) { + drawingRect.left = getXFrom(now); + drawingRect.top = getScrollY(); + drawingRect.right = drawingRect.left + mTimeBarLineWidth; + drawingRect.bottom = drawingRect.top + getHeight(); + + mPaint.setColor(mTimeBarLineColor); + canvas.drawRect(drawingRect, mPaint); + } + + } + + private void drawEvents(Canvas canvas, Rect drawingRect) { + final int firstPos = getFirstVisibleChannelPosition(); + final int lastPos = getLastVisibleChannelPosition(); + + for (int pos = firstPos; pos <= lastPos; pos++) { + + // Set clip rectangle + mClipRect.left = getScrollX() + mChannelLayoutWidth + mChannelLayoutMargin; + mClipRect.top = getTopFrom(pos); + mClipRect.right = getScrollX() + getWidth(); + mClipRect.bottom = mClipRect.top + mChannelLayoutHeight; + + canvas.save(); + canvas.clipRect(mClipRect); + + // Draw each event + boolean foundFirst = false; + + List epgEvents = epgData.getEvents(pos); + + for (EPGEvent event : epgEvents) { + if (isEventVisible(event.getStart(), event.getEnd())) { + drawEvent(canvas, pos, event, drawingRect); + foundFirst = true; + } else if (foundFirst) { + break; + } + } + + canvas.restore(); + } + + } + + private void drawEvent(final Canvas canvas, final int channelPosition, final EPGEvent event, final Rect drawingRect) { + + setEventDrawingRectangle(channelPosition, event.getStart(), event.getEnd(), drawingRect); + + // Background + if (channelPosition==channel && playtime>=event.getStart() && playtimeup) + mPaint.getTextBounds(event.getTitle(), 0, event.getTitle().length(), mMeasuringRect); + drawingRect.top += (((drawingRect.bottom - drawingRect.top) / 2) + (mMeasuringRect.height()/2)); + + String title = event.getTitle(); + title = title.substring(0, + mPaint.breakText(title, true, drawingRect.right - drawingRect.left, null)); + canvas.drawText(title, drawingRect.left, drawingRect.top, mPaint); + + } + + private void setEventDrawingRectangle(final int channelPosition, final long start, final long end, final Rect drawingRect) { + drawingRect.left = getXFrom(start); + drawingRect.top = getTopFrom(channelPosition); + drawingRect.right = getXFrom(end) - mChannelLayoutMargin; + drawingRect.bottom = drawingRect.top + mChannelLayoutHeight; + } + + private void drawChannelListItems(Canvas canvas, Rect drawingRect) { + // Background + mMeasuringRect.left = getScrollX(); + mMeasuringRect.top = getScrollY(); + mMeasuringRect.right = drawingRect.left + mChannelLayoutWidth; + mMeasuringRect.bottom = mMeasuringRect.top + getHeight(); + + mPaint.setColor(mChannelLayoutBackground); + canvas.drawRect(mMeasuringRect, mPaint); + + final int firstPos = getFirstVisibleChannelPosition(); + final int lastPos = getLastVisibleChannelPosition(); + + for (int pos = firstPos; pos <= lastPos; pos++) { + drawChannelItem(canvas, pos, drawingRect); + } + } + + private void drawChannelItem(final Canvas canvas, int position, Rect drawingRect) { + drawingRect.left = getScrollX(); + drawingRect.top = getTopFrom(position); + drawingRect.right = drawingRect.left + mChannelLayoutWidth; + drawingRect.bottom = drawingRect.top + mChannelLayoutHeight; + + // Loading channel image into target for + final String imageURL = epgData.getChannel(position).getImageURL(); + + if (mChannelImageCache.containsKey(imageURL)) { + Bitmap image = mChannelImageCache.get(imageURL); + drawingRect = getDrawingRectForChannelImage(drawingRect, image); + canvas.drawBitmap(image, null, drawingRect, null); + } else { + final int smallestSide = Math.min(mChannelLayoutHeight, mChannelLayoutWidth); + + if (!mChannelImageTargetCache.containsKey(imageURL)) { + mChannelImageTargetCache.put(imageURL, new Target() { + @Override + public void onBitmapLoaded(Bitmap bitmap, Picasso.LoadedFrom from) { + mChannelImageCache.put(imageURL, bitmap); + redraw(); + mChannelImageTargetCache.remove(imageURL); + } + + @Override + public void onBitmapFailed(Exception e, Drawable errorDrawable) { + + } + + @Override + public void onPrepareLoad(Drawable placeHolderDrawable) { + + } + }); + + EPGUtil.loadImageInto(getContext(), imageURL, smallestSide, smallestSide, mChannelImageTargetCache.get(imageURL)); + } + + } + } + + private Rect getDrawingRectForChannelImage(Rect drawingRect, Bitmap image) { + drawingRect.left += mChannelLayoutPadding; + drawingRect.top += mChannelLayoutPadding; + drawingRect.right -= mChannelLayoutPadding; + drawingRect.bottom -= mChannelLayoutPadding; + + final int imageWidth = image.getWidth(); + final int imageHeight = image.getHeight(); + final float imageRatio = imageHeight / (float) imageWidth; + + final int rectWidth = drawingRect.right - drawingRect.left; + final int rectHeight = drawingRect.bottom - drawingRect.top; + + // Keep aspect ratio. + if (imageWidth > imageHeight) { + final int padding = (int) (rectHeight - (rectWidth * imageRatio)) / 2; + drawingRect.top += padding; + drawingRect.bottom -= padding; + } else if (imageWidth <= imageHeight) { + final int padding = (int) (rectWidth - (rectHeight / imageRatio)) / 2; + drawingRect.left += padding; + drawingRect.right -= padding; + } + + return drawingRect; + } + + private boolean shouldDrawTimeLine(long now) { + return now >= mTimeLowerBoundary && now < mTimeUpperBoundary; + } + + private boolean isEventVisible(final long start, final long end) { + return (start >= mTimeLowerBoundary && start <= mTimeUpperBoundary) + || (end >= mTimeLowerBoundary && end <= mTimeUpperBoundary) + || (start <= mTimeLowerBoundary && end >= mTimeUpperBoundary); + } + + private long calculatedBaseLine() { + return LocalDateTime.now().toDateTime().minusMillis(DAYS_BACK_MILLIS).getMillis(); + } + + private int getFirstVisibleChannelPosition() { + final int y = getScrollY(); + + int position = (y - mChannelLayoutMargin - mTimeBarHeight) + / (mChannelLayoutHeight + mChannelLayoutMargin); + + if (position < 0) { + position = 0; + } + return position; + } + + private int getLastVisibleChannelPosition() { + final int y = getScrollY(); + final int totalChannelCount = epgData.getChannelCount(); + final int screenHeight = getHeight(); + int position = (y + screenHeight + mTimeBarHeight - mChannelLayoutMargin) + / (mChannelLayoutHeight + mChannelLayoutMargin); + + if (position > totalChannelCount - 1) { + position = totalChannelCount - 1; + } + + // Add one extra row if we don't fill screen with current.. + return (y + screenHeight) > (position * mChannelLayoutHeight) && position < totalChannelCount - 1 ? position + 1 : position; + } + + private void calculateMaxHorizontalScroll() { + mMaxHorizontalScroll = (int) ((DAYS_BACK_MILLIS + DAYS_FORWARD_MILLIS - HOURS_IN_VIEWPORT_MILLIS) / mMillisPerPixel); + } + + private void calculateMaxVerticalScroll() { + final int maxVerticalScroll = getTopFrom(epgData.getChannelCount() - 1) + mChannelLayoutHeight; + mMaxVerticalScroll = maxVerticalScroll < getHeight() ? 0 : maxVerticalScroll - getHeight(); + } + + private int getXFrom(long time) { + return (int) ((time - mTimeOffset) / mMillisPerPixel) + mChannelLayoutMargin + + mChannelLayoutWidth + mChannelLayoutMargin; + } + + private int getTopFrom(int position) { + int y = position * (mChannelLayoutHeight + mChannelLayoutMargin) + + mChannelLayoutMargin + mTimeBarHeight; + return y; + } + + private long getTimeFrom(int x) { + return (x * mMillisPerPixel) + mTimeOffset; + } + + private long calculateMillisPerPixel() { + return HOURS_IN_VIEWPORT_MILLIS / (getResources().getDisplayMetrics().widthPixels - mChannelLayoutWidth - mChannelLayoutMargin); + } + + private int getXPositionStart() { + return getXFrom(System.currentTimeMillis() - (HOURS_IN_VIEWPORT_MILLIS / 2)); + } + + private void resetBoundaries() { + mMillisPerPixel = calculateMillisPerPixel(); + mTimeOffset = calculatedBaseLine(); + mTimeLowerBoundary = getTimeFrom(0); + mTimeUpperBoundary = getTimeFrom(getWidth()); + } + + private Rect calculateChannelsHitArea() { + mMeasuringRect.top = mTimeBarHeight; + int visibleChannelsHeight = epgData.getChannelCount() * (mChannelLayoutHeight + mChannelLayoutMargin); + mMeasuringRect.bottom = visibleChannelsHeight < getHeight() ? visibleChannelsHeight : getHeight(); + mMeasuringRect.left = 0; + mMeasuringRect.right = mChannelLayoutWidth; + return mMeasuringRect; + } + + private Rect calculateProgramsHitArea() { + mMeasuringRect.top = mTimeBarHeight; + int visibleChannelsHeight = epgData.getChannelCount() * (mChannelLayoutHeight + mChannelLayoutMargin); + mMeasuringRect.bottom = visibleChannelsHeight < getHeight() ? visibleChannelsHeight : getHeight(); + mMeasuringRect.left = mChannelLayoutWidth; + mMeasuringRect.right = getWidth(); + return mMeasuringRect; + } + + private Rect calculateResetButtonHitArea() { + mMeasuringRect.left = getScrollX() + getWidth() - mResetButtonSize - mResetButtonMargin; + mMeasuringRect.top = getScrollY() + getHeight() - mResetButtonSize - mResetButtonMargin; + mMeasuringRect.right = mMeasuringRect.left + mResetButtonSize; + mMeasuringRect.bottom = mMeasuringRect.top + mResetButtonSize; + return mMeasuringRect; + } + + private int getChannelPosition(int y) { + y -= mTimeBarHeight; + int channelPosition = (y + mChannelLayoutMargin) + / (mChannelLayoutHeight + mChannelLayoutMargin); + + return epgData.getChannelCount() == 0 ? -1 : channelPosition; + } + + private int getProgramPosition(int channelPosition, long time) { + List events = epgData.getEvents(channelPosition); + + if (events != null) { + + for (int eventPos = 0; eventPos < events.size(); eventPos++) { + EPGEvent event = events.get(eventPos); + + if (event.getStart() <= time && event.getEnd() >= time) { + return eventPos; + } + } + } + return -1; + } + + /** + * Add click listener to the EPG. + * @param epgClickListener to add. + */ + public void setEPGClickListener(EPGClickListener epgClickListener) { + mClickListener = epgClickListener; + } + + /** + * Add data to EPG. This must be set for EPG to able to draw something. + * @param epgData pass in any implementation of EPGData. + */ + public void setEPGData(EPGData epgData) { + this.epgData = epgData; + } + + /** + * This will recalculate boundaries, maximal scroll and scroll to start position which is current time. + * To be used on device rotation etc since the device height and width will change. + * @param withAnimation true if scroll to current position should be animated. + */ + public void recalculateAndRedraw(boolean withAnimation) { + if (epgData != null && epgData.hasData()) { + resetBoundaries(); + + calculateMaxVerticalScroll(); + calculateMaxHorizontalScroll(); + + mScroller.startScroll(getScrollX(), getScrollY(), + getXPositionStart() - getScrollX(), + 0, withAnimation ? 600 : 0); + + redraw(); + } + } + + /** + * Does a invalidate() and requestLayout() which causes a redraw of screen. + */ + public void redraw() { + invalidate(); + requestLayout(); + } + + /** + * Clears the local image cache for channel images. Can be used when leaving epg and you want to + * free some memory. Images will be fetched again when loading EPG next time. + */ + public void clearEPGImageCache() { + mChannelImageCache.clear(); + } + + + private class OnGestureListener extends GestureDetector.SimpleOnGestureListener { + + @Override + public boolean onSingleTapUp(MotionEvent e) { + + // This is absolute coordinate on screen not taking scroll into account. + int x = (int) e.getX(); + int y = (int) e.getY(); + + // Adding scroll to clicked coordinate + int scrollX = getScrollX() + x; + int scrollY = getScrollY() + y; + + int channelPosition = getChannelPosition(scrollY); + if (channelPosition != -1 && mClickListener != null) { + if (calculateResetButtonHitArea().contains(scrollX,scrollY)) { + // Reset button clicked + mClickListener.onResetButtonClicked(); + } else if (calculateChannelsHitArea().contains(x, y)) { + // Channel area is clicked + mClickListener.onChannelClicked(channelPosition, epgData.getChannel(channelPosition)); + } else if (calculateProgramsHitArea().contains(x, y)) { + // Event area is clicked + int programPosition = getProgramPosition(channelPosition, getTimeFrom(getScrollX() + x - calculateProgramsHitArea().left)); + if (programPosition != -1) { + channel = channelPosition; + playtime = epgData.getEvent(channel,programPosition).getStart(); + redraw(); + mClickListener.onEventClicked(channelPosition, programPosition, epgData.getEvent(channelPosition, programPosition)); + } + } + } + + return true; + } + + @Override + public boolean onScroll(MotionEvent e1, MotionEvent e2, + float distanceX, float distanceY) { + int dx = (int) distanceX; + int dy = (int) distanceY; + int x = getScrollX(); + int y = getScrollY(); + + + // Avoid over scrolling + if (x + dx < 0) { + dx = 0 - x; + } + if (y + dy < 0) { + dy = 0 - y; + } + if (x + dx > mMaxHorizontalScroll) { + dx = mMaxHorizontalScroll - x; + } + if (y + dy > mMaxVerticalScroll) { + dy = mMaxVerticalScroll - y; + } + + scrollBy(dx, dy); + return true; + } + + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, + float vX, float vY) { + + mScroller.fling(getScrollX(), getScrollY(), -(int) vX, + -(int) vY, 0, mMaxHorizontalScroll, 0, mMaxVerticalScroll); + + redraw(); + return true; + } + + @Override + public boolean onDown(MotionEvent e) { + if (!mScroller.isFinished()) { + mScroller.forceFinished(true); + return true; + } + return true; + } + } +} diff --git a/app/src/main/java/com/example/exoplayertv/tvepg/epg/EPGClickListener.java b/app/src/main/java/com/example/exoplayertv/tvepg/epg/EPGClickListener.java new file mode 100644 index 0000000..219f693 --- /dev/null +++ b/app/src/main/java/com/example/exoplayertv/tvepg/epg/EPGClickListener.java @@ -0,0 +1,16 @@ +package com.example.exoplayertv.tvepg.epg; + +import com.example.exoplayertv.tvepg.epg.domain.EPGChannel; +import com.example.exoplayertv.tvepg.epg.domain.EPGEvent; + +/** + * Created by Kristoffer on 15-05-25. + */ +public interface EPGClickListener { + + void onChannelClicked(int channelPosition, EPGChannel epgChannel); + + void onEventClicked(int channelPosition, int programPosition, EPGEvent epgEvent); + + void onResetButtonClicked(); +} diff --git a/app/src/main/java/com/example/exoplayertv/tvepg/epg/EPGData.java b/app/src/main/java/com/example/exoplayertv/tvepg/epg/EPGData.java new file mode 100644 index 0000000..507cd84 --- /dev/null +++ b/app/src/main/java/com/example/exoplayertv/tvepg/epg/EPGData.java @@ -0,0 +1,24 @@ +package com.example.exoplayertv.tvepg.epg; + +import java.util.List; + +import com.example.exoplayertv.tvepg.epg.domain.EPGChannel; +import com.example.exoplayertv.tvepg.epg.domain.EPGEvent; + +/** + * Interface to implement and pass to EPG containing data to be used. + * Implementation can be a simple as simple as a Map/List or maybe an Adapter. + * Created by Kristoffer on 15-05-23. + */ +public interface EPGData { + + EPGChannel getChannel(int position); + + List getEvents(int channelPosition); + + EPGEvent getEvent(int channelPosition, int programPosition); + + int getChannelCount(); + + boolean hasData(); +} diff --git a/app/src/main/java/com/example/exoplayertv/tvepg/epg/domain/EPGChannel.java b/app/src/main/java/com/example/exoplayertv/tvepg/epg/domain/EPGChannel.java new file mode 100644 index 0000000..0e6b841 --- /dev/null +++ b/app/src/main/java/com/example/exoplayertv/tvepg/epg/domain/EPGChannel.java @@ -0,0 +1,47 @@ +package com.example.exoplayertv.tvepg.epg.domain; + +/** + * Created by Kristoffer. + */ +public class EPGChannel { + + private final String channelID; + private final String name; + private final String imageURL; + private final String streamURL; + private final String drm_scheme; + private final String drm_uri; + + public EPGChannel(String imageURL, String name, String channelID, String streamURL, String drm_scheme, String drm_uri) { + this.imageURL = imageURL; + this.name = name; + this.channelID = channelID; + this.streamURL = streamURL; + this.drm_scheme = drm_scheme; + this.drm_uri = drm_uri; + } + + public String getChannelID() { + return channelID; + } + + public String getName() { + return name; + } + + public String getImageURL() { + return imageURL; + } + + public String getStreamURL() { + return streamURL; + } + + public String getDrmScheme() { + return drm_scheme; + } + + public String getDrmUri() { + return drm_uri; + } +} diff --git a/app/src/main/java/com/example/exoplayertv/tvepg/epg/domain/EPGEvent.java b/app/src/main/java/com/example/exoplayertv/tvepg/epg/domain/EPGEvent.java new file mode 100644 index 0000000..9690f5a --- /dev/null +++ b/app/src/main/java/com/example/exoplayertv/tvepg/epg/domain/EPGEvent.java @@ -0,0 +1,90 @@ +package com.example.exoplayertv.tvepg.epg.domain; + +import java.text.DateFormat; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.List; + +/** + * Created by Kristoffer. + */ +public class EPGEvent { + + private final static DateFormat dateFormat = new SimpleDateFormat("yyyyMMddHHmmss Z"); + private final String start; + private final String end; + private final String title; + private final List categories; + private final String country; + private final String date; + private final String description; + private final String director; + private final String writer; + private final List actors; + + public EPGEvent(String start, String end, String title, + List categories, String country, String date, + String description, String director, String writer, List actors) { + this.start = start; + this.end = end; + this.title = title; + this.categories = categories; + this.country = country; + this.date = date; + this.description = description; + this.director = director; + this.writer = writer; + this.actors = actors; + } + + public String getStartAsString() { return start; } + public long getStart() { + try { + return dateFormat.parse(start).getTime(); + }catch(ParseException e){return 0;} + } + + public String getEndAsString() { return end; } + public long getEnd() { + try { + return dateFormat.parse(end).getTime(); + }catch(ParseException e){return 0;} + } + + public String getTitle() { + return title; + } + + public List getCategories() { + return categories; + } + + public String getCountry() { + return country; + } + + public String getDate() { + return date; + } + + public String getDescription() { + return description; + } + + public String getDirector() { + return director; + } + + public String getWriter() { + return writer; + } + + public List getActors() { + return actors; + } + + public boolean isCurrent() { + long now = System.currentTimeMillis(); + return now >= getStart() && now <= getEnd(); + } +} diff --git a/app/src/main/java/com/example/exoplayertv/tvepg/epg/misc/EPGUtil.java b/app/src/main/java/com/example/exoplayertv/tvepg/epg/misc/EPGUtil.java new file mode 100644 index 0000000..747c693 --- /dev/null +++ b/app/src/main/java/com/example/exoplayertv/tvepg/epg/misc/EPGUtil.java @@ -0,0 +1,56 @@ +package com.example.exoplayertv.tvepg.epg.misc; + +import android.content.Context; +import android.net.Uri; +import android.util.Log; + +import okhttp3.OkHttpClient; +import com.squareup.picasso.OkHttp3Downloader; +import com.squareup.picasso.Picasso; +import com.squareup.picasso.Target; + +import org.joda.time.LocalDate; +import org.joda.time.format.DateTimeFormat; +import org.joda.time.format.DateTimeFormatter; + +/** + * Created by Kristoffer. + */ +public class EPGUtil { + private static final String TAG = "EPGUtil"; + private static final DateTimeFormatter dtfShortTime = DateTimeFormat.forPattern("HH:mm"); + private static Picasso picasso = null; + + public static String getShortTime(long timeMillis) { + return dtfShortTime.print(timeMillis); + } + + public static String getWeekdayName(long dateMillis) { + LocalDate date = new LocalDate(dateMillis); + return date.dayOfWeek().getAsText(); + } + + public static void loadImageInto(Context context, String url, int width, int height, Target target) { + initPicasso(context); + + picasso.load(url) + .resize(width, height) + .centerInside() + .into(target); + } + + private static void initPicasso(Context context) { + if (picasso == null) { + picasso = new Picasso.Builder(context) + .downloader(new OkHttp3Downloader(new OkHttpClient())) + .listener(new Picasso.Listener() { + @Override + public void onImageLoadFailed(Picasso picasso, Uri uri, Exception exception) { + Log.e(TAG, exception.getMessage()); + } + }) + .build(); + } + } + +} diff --git a/app/src/main/java/com/example/exoplayertv/tvepg/xmltv/ContentTask.java b/app/src/main/java/com/example/exoplayertv/tvepg/xmltv/ContentTask.java new file mode 100644 index 0000000..9199009 --- /dev/null +++ b/app/src/main/java/com/example/exoplayertv/tvepg/xmltv/ContentTask.java @@ -0,0 +1,63 @@ +package com.example.exoplayertv.tvepg.xmltv; + +import com.example.exoplayertv.tvepg.xmltv.model.Channel; +import java.io.IOException; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.Calendar; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TimeZone; + +import android.os.AsyncTask; +import android.util.Log; + +import com.example.exoplayertv.tvepg.xmltv.grabber.Grabber; + +/** + * + * @author Daniel Oskarsson (daniel.oskarsson@gmail.com) + */ +public class ContentTask extends AsyncTask> { + + public interface TvGuide { + public void setTvGuide(List channels); + }; + + private Map> channels = new HashMap>(); + + private TvGuide tvGuide; + public ContentTask(TvGuide tvGuide) { + this.tvGuide = tvGuide; // Get reference to UI thread, so we don't have to push this code into TVActivity + } + + @Override + protected void onPostExecute(List channels) { + tvGuide.setTvGuide(channels); + } + + @Override + protected List doInBackground(String[]... args) { + List whitelist = Arrays.asList(args); // The interface to doInBackground want's varargs... + DateFormat timeFormat = new SimpleDateFormat("HH:mm"); + timeFormat.setTimeZone(TimeZone.getTimeZone("GMT+1")); + + // Fetch filtered channels from Map or from Xmltv + DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd"); + String today = dateFormat.format(Calendar.getInstance().getTime()); + List lChannels = channels.get(today); + if (lChannels == null) { + try { + lChannels = new Grabber(whitelist).getChannels(); + channels.put(today, lChannels); + } catch (IOException e) { + Log.e(ContentTask.class.getCanonicalName(), e.getMessage()); + return null; + } + } + + return lChannels; + } +} diff --git a/app/src/main/java/com/example/exoplayertv/tvepg/xmltv/WebAssets.java b/app/src/main/java/com/example/exoplayertv/tvepg/xmltv/WebAssets.java new file mode 100644 index 0000000..ca20e5c --- /dev/null +++ b/app/src/main/java/com/example/exoplayertv/tvepg/xmltv/WebAssets.java @@ -0,0 +1,61 @@ +package com.example.exoplayertv.tvepg.xmltv; + +import java.io.IOException; +import java.io.InputStream; + +import android.content.res.AssetManager; +import android.util.Log; + +/** + * + * @author Daniel Oskarsson (daniel.oskarsson@gmail.com) + */ +public /*final*/ class WebAssets { + private AssetManager assetManager; + + public WebAssets(AssetManager assetManager) { + this.assetManager = assetManager; + } + + /** + * This method returns a html template that contains the magic string %html%. + * The purpose is to replace %html% with generated html + * while still being able to edit the 'frame' around that data as in an asset. + * @return A string representation of the template asset. + */ + public String getTemplate() { + try { + InputStream inputStream = assetManager.open("template.html"); + byte[] buffer = new byte[inputStream.available()]; + inputStream.read(buffer); + inputStream.close(); + return new String(buffer).replace("", getStyleSheet()); + } catch (IOException e) { + Log.e("TVActivity.class.getCanonicalName()", "Could not read asset template.html"); + } + return "%html%"; + } + + /** + * This method is used by {@link #getTemplate()} to dynamically replace the link to the asset style sheet. + * The reason for doing that is that WebView does not seem to parse asset references. + * This way the same style sheet can be used by both the template and the about asset. + * @return A string representation of the style sheet asset. + */ + public String getStyleSheet() { + try { + InputStream inputStream = assetManager.open("stylesheet.css"); + byte[] buffer = new byte[inputStream.available()]; + inputStream.read(buffer); + inputStream.close(); + StringBuilder stylesheet = new StringBuilder(); + stylesheet.append(""); + return stylesheet.toString(); + } catch (IOException e) { + Log.e("TVActivity.class.getCanonicalName()", "Could not read asset stylesheet.css"); + } + return ""); + return stylesheet.toString(); + } catch (IOException e) { + Log.e("TVActivity.class.getCanonicalName()", "Could not read asset stylesheet.css"); + } + return " + + + + + + + diff --git a/app/src/test/java/com/example/exoplayertv/ExampleUnitTest.java b/app/src/test/java/com/example/exoplayertv/ExampleUnitTest.java new file mode 100644 index 0000000..4f232ef --- /dev/null +++ b/app/src/test/java/com/example/exoplayertv/ExampleUnitTest.java @@ -0,0 +1,17 @@ +package com.example.exoplayertv; + +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * Example local unit test, which will execute on the development machine (host). + * + * @see Testing documentation + */ +public class ExampleUnitTest { + @Test + public void addition_isCorrect() { + assertEquals(4, 2 + 2); + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..565f8c2 --- /dev/null +++ b/build.gradle @@ -0,0 +1,4 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { +alias(libs.plugins.android.application) apply false +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..4387edc --- /dev/null +++ b/gradle.properties @@ -0,0 +1,21 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. For more details, visit +# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..8798cc8 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,24 @@ +[versions] +agp = "8.13.2" +junit = "4.13.2" +junitVersion = "1.1.5" +espressoCore = "3.5.1" +appcompat = "1.6.1" +material = "1.10.0" +constraintlayout = "2.1.4" +navigationFragment = "2.6.0" +navigationUi = "2.6.0" + +[libraries] +junit = { group = "junit", name = "junit", version.ref = "junit" } +ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } +constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } +navigation-fragment = { group = "androidx.navigation", name = "navigation-fragment", version.ref = "navigationFragment" } +navigation-ui = { group = "androidx.navigation", name = "navigation-ui", version.ref = "navigationUi" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..e708b1c023ec8b20f512888fe07c5bd3ff77bb8f GIT binary patch literal 59203 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

K7UpUK$>l@ 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 E2O(~oYXATM literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..afe2cfc --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Sun Sep 28 18:30:44 CEST 2025 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..4f906e0 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..ac1b06f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..ba72dd7 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,23 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "ExoPlayerTV" +include ':app'